From cb17e8e91058cba213cf6f08f82110ee713a8623 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 15 Oct 2026 15:12:36 +0800 Subject: [PATCH 01/22] fix(orders): measure order distance and time by road using the company's routing engine Order distance/time came from the straight-line 'calculate' provider because DISTANCE_MATRIX_PROVIDER defaults to it and the routing setting is only read by the console map. Resolve the provider from an explicit DISTANCE_MATRIX_PROVIDER, then the company's routing engine when the server can call it (private OSRM host, Google key), then 'calculate'. Fall back to the straight-line estimate with a warning when a road provider fails instead of storing 0, cache road results for 24h and stop caching empty ones. Co-Authored-By: Claude --- server/config/fleetops.php | 8 +- .../Commands/TrackOrderDistanceAndTime.php | 9 +- server/src/Models/Order.php | 5 +- .../DistanceMatrixProviderResolver.php | 89 ++++++ server/src/Support/Utils.php | 74 +++-- server/tests/CommandContractsTest.php | 22 +- .../OrderControllerPersistenceHelpersTest.php | 9 +- .../OrderPayloadConfigAndDistanceTest.php | 59 ++++ .../DistanceMatrixProviderResolverTest.php | 273 ++++++++++++++++++ 9 files changed, 512 insertions(+), 36 deletions(-) create mode 100644 server/src/Support/Routing/DistanceMatrixProviderResolver.php create mode 100644 server/tests/Unit/Support/DistanceMatrixProviderResolverTest.php diff --git a/server/config/fleetops.php b/server/config/fleetops.php index 798e4e351..cd5668b09 100644 --- a/server/config/fleetops.php +++ b/server/config/fleetops.php @@ -63,10 +63,16 @@ |-------------------------------------------------------------------------- | Distance Matrix Calculator | Options: "calculate", "google", "osrm" + | + | Leave DISTANCE_MATRIX_PROVIDER unset to measure order distance and time + | with each company's routing engine (Fleet-Ops Settings -> Routing) when + | the server can call it: OSRM needs a self-hosted OSRM_HOST, Google needs + | GOOGLE_MAPS_API_KEY. Otherwise the straight-line "calculate" estimate is + | used. Setting it applies one provider to every company. |-------------------------------------------------------------------------- */ 'distance_matrix' => [ - 'provider' => env('DISTANCE_MATRIX_PROVIDER', 'calculate'), + 'provider' => env('DISTANCE_MATRIX_PROVIDER'), ], /* diff --git a/server/src/Console/Commands/TrackOrderDistanceAndTime.php b/server/src/Console/Commands/TrackOrderDistanceAndTime.php index dbbb10eb7..65bee3f01 100644 --- a/server/src/Console/Commands/TrackOrderDistanceAndTime.php +++ b/server/src/Console/Commands/TrackOrderDistanceAndTime.php @@ -13,7 +13,7 @@ class TrackOrderDistanceAndTime extends Command * The name and signature of the console command. * * Options: - * --provider= : distance/time provider (calculate, google, osrm). Defaults to config(fleetops.distance_matrix.provider) + * --provider= : distance/time provider (calculate, google, osrm). Defaults to each order's company routing settings * --days= : only include orders created in last N days (default: 2) * --chunk= : records per chunk (default: 250) * --no-lock : skip single-run lock (not recommended) @@ -33,13 +33,14 @@ public function handle(): int // Always operate in UTC date_default_timezone_set('UTC'); - $provider = $this->option('provider') ?: (string) config('fleetops.distance_matrix.provider'); + // Without --provider each order resolves its own company's provider + $provider = $this->option('provider') ?: null; $days = max(1, (int) $this->option('days')); // guardrail $perChunk = max(50, (int) $this->option('chunk')); // sane lower bound $dryRun = (bool) $this->option('dry'); $useLock = !$this->option('no-lock'); - $this->info("Using provider: {$provider}"); + $this->info('Using provider: ' . ($provider ?? 'company routing settings')); $this->info("Looking back: last {$days} day(s)"); $this->info("Chunk size: {$perChunk}" . ($dryRun ? ' (dry-run)' : '')); @@ -83,7 +84,7 @@ public function handle(): int foreach ($orders as $order) { try { if (!$dryRun) { - $order->setDistanceAndTime(['provider' => $provider]); + $order->setDistanceAndTime($provider ? ['provider' => $provider] : []); } $updated++; } catch (\Throwable $e) { diff --git a/server/src/Models/Order.php b/server/src/Models/Order.php index 0af1fae5a..f2e517c40 100644 --- a/server/src/Models/Order.php +++ b/server/src/Models/Order.php @@ -1917,7 +1917,8 @@ public function setPreliminaryDistanceAndTime() /** * Sets the accurate distance and time for the order based on driving distance. - * Uses a utility method to calculate the driving distance and time between the current origin and destination positions. + * Uses a utility method to calculate the driving distance and time between the current origin and destination positions, + * with the provider the order's company routing settings resolve to unless `$options['provider']` is given. * * @return Order the updated Order instance */ @@ -1935,7 +1936,7 @@ public function setDistanceAndTime(array $options = []): self // Unreachable: `getDrivingDistanceAndTime()` is typed `: DistanceMatrix` // and every provider path casts both fields to float before constructing // it, so neither the null check nor the isset check can fail. - $matrix = Utils::getDrivingDistanceAndTime($origin, $destination, $options); + $matrix = Utils::getDrivingDistanceAndTime($origin, $destination, array_merge(['company_uuid' => $this->company_uuid], $options)); // @codeCoverageIgnoreStart if (!$matrix || !isset($matrix->distance, $matrix->time)) { return $this; diff --git a/server/src/Support/Routing/DistanceMatrixProviderResolver.php b/server/src/Support/Routing/DistanceMatrixProviderResolver.php new file mode 100644 index 000000000..c0ed4263f --- /dev/null +++ b/server/src/Support/Routing/DistanceMatrixProviderResolver.php @@ -0,0 +1,89 @@ + 'osrm', + 'google' => 'google', + default => null, + }; + } + + public static function isAvailable(string $provider): bool + { + return match ($provider) { + 'osrm' => static::hasPrivateOsrmHost(), + 'google' => filled(static::googleMapsApiKey()), + default => false, + }; + } + + public static function hasPrivateOsrmHost(): bool + { + $host = parse_url((string) config('fleetops.osrm.host'), PHP_URL_HOST); + + return filled($host) && strcasecmp($host, static::PUBLIC_OSRM_HOST) !== 0; + } + + public static function googleMapsApiKey(): ?string + { + $configuredKey = config('services.google_maps.api_key'); + if ($configuredKey) { + return $configuredKey; + } + + return class_exists(\PhpOption\Option::class) ? env('GOOGLE_MAPS_API_KEY') : null; + } +} diff --git a/server/src/Support/Utils.php b/server/src/Support/Utils.php index 19167d790..c90ed873d 100644 --- a/server/src/Support/Utils.php +++ b/server/src/Support/Utils.php @@ -3,6 +3,7 @@ namespace Fleetbase\FleetOps\Support; use Fleetbase\FleetOps\Flow\Activity; +use Fleetbase\FleetOps\Support\Routing\DistanceMatrixProviderResolver; use Fleetbase\LaravelMysqlSpatial\Types\Point; use Fleetbase\Models\Company; use Fleetbase\Models\Setting; @@ -10,6 +11,7 @@ use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Redis; use Illuminate\Support\Str; @@ -22,6 +24,11 @@ class Utils extends FleetbaseUtils */ public const DRIVING_TIME_MULTIPLIER = 7.2; + /** + * Seconds a road distance stays cached for one origin and destination pair. + */ + public const DISTANCE_MATRIX_CACHE_TTL = 86400; + /** * Resolve the preferred transaction currency for a company. * @@ -791,16 +798,17 @@ public static function rawPointToPoint(string $data): Point * * This function attempts to find the driving distance and time from the origin to the destination * using various map APIs. It first attempts to resolve the origin and destination from various input types - * into geographical coordinates. Then, based on the provider specified in the options or defaulting to configured provider, - * it queries the respective service for the driving distance and time. The result is returned as a DistanceMatrix - * object with the distance in meters and the time in seconds. + * into geographical coordinates. The provider is `$options['provider']` when given, otherwise the one + * DistanceMatrixProviderResolver picks for `$options['company_uuid']` (or the session company). When a road + * provider fails or returns no route the straight-line estimate is returned instead, so the result is never + * zero for distinct points. The result is a DistanceMatrix with the distance in meters and the time in seconds. * * @param mixed $origin The origin of the journey. Can be a Place model, a string, or an array with latitude and longitude. * @param mixed $destination The destination of the journey. Similar to the origin, it accepts multiple input types. * * @return DistanceMatrix an object containing the distance and time of the journey * - * @throws \Throwable throws any exception encountered during the resolution of points or API calls + * @throws \Throwable throws any exception encountered during the resolution of points */ public static function getDrivingDistanceAndTime($origin, $destination, $options = []): DistanceMatrix { @@ -809,25 +817,37 @@ public static function getDrivingDistanceAndTime($origin, $destination, $options $origins = $origin->getLat() . ',' . $origin->getLng(); $destinations = $destination->getLat() . ',' . $destination->getLng(); - // Create empty distance matric - $distanceMatrix = new DistanceMatrix(0, 0); - $provider = data_get($options, 'provider', config('fleetops.distance_matrix.provider')); - switch ($provider) { - case 'calculate': - $distanceMatrix = static::calculateDrivingDistanceAndTime($origins, $destinations); - break; + $provider = data_get($options, 'provider') ?: DistanceMatrixProviderResolver::resolve(data_get($options, 'company_uuid', session('company'))); + $estimate = static::calculateDrivingDistanceAndTime($origins, $destinations); - case 'osrm': - $distanceMatrix = static::getDistanceMatrixFromOSRM($origins, $destinations); - break; + // Nothing to route between identical points, and `calculate` is the estimate itself + if ($provider === 'calculate' || $estimate->distance <= 0) { + return $estimate; + } - case 'google': - default: - $distanceMatrix = static::getDistanceMatrixFromGoogle($origins, $destinations); - break; + try { + $distanceMatrix = match ($provider) { + 'osrm' => static::getDistanceMatrixFromOSRM($origins, $destinations), + default => static::getDistanceMatrixFromGoogle($origins, $destinations), + }; + } catch (\Throwable $e) { + $distanceMatrix = null; + $error = $e->getMessage(); } - return $distanceMatrix; + if ($distanceMatrix && $distanceMatrix->distance > 0 && $distanceMatrix->time > 0) { + return $distanceMatrix; + } + + // A timeout, exhausted quota or missing route must not leave the order at zero + Log::warning('Road distance unavailable, using the straight-line estimate instead.', [ + 'provider' => $provider, + 'origin' => $origins, + 'destination' => $destinations, + 'error' => $error ?? 'no route returned', + ]); + + return $estimate; } /** @@ -859,7 +879,7 @@ public static function distanceMatrix($origins = [], $destinations = [], $option // Create empty distance matric $distanceMatrix = new DistanceMatrix(0, 0); - $provider = data_get($options, 'provider', config('fleetops.distance_matrix.provider')); + $provider = data_get($options, 'provider') ?: config('fleetops.distance_matrix.provider') ?: 'calculate'; switch ($provider) { case 'calculate': $distanceMatrix = static::calculateDrivingDistanceAndTime($origins, $destinations); @@ -944,7 +964,7 @@ public static function getDistanceMatrixFromGoogle(string $origins, string $dest 'origins' => $origins, 'destinations' => $destinations, 'mode' => data_get($options, 'mode', 'driving'), - 'key' => env('GOOGLE_MAPS_API_KEY'), + 'key' => DistanceMatrixProviderResolver::googleMapsApiKey(), ] )->json(); @@ -954,8 +974,10 @@ public static function getDistanceMatrixFromGoogle(string $origins, string $dest // Create distance matrix $distanceMatrix = new DistanceMatrix($distance, $time); - // Cache distance matrix - Redis::set($cacheKey, json_encode($distanceMatrix)); + // Cache distance matrix, but never an empty result so a failed lookup is retried + if ($distance > 0 && $time > 0) { + Redis::set($cacheKey, json_encode($distanceMatrix), 'EX', self::DISTANCE_MATRIX_CACHE_TTL); + } return $distanceMatrix; } @@ -1010,8 +1032,10 @@ public static function getDistanceMatrixFromOSRM(string $origins, string $destin // Create distance matrix $distanceMatrix = new DistanceMatrix($distance, $time); - // Cache distance matrix - Redis::set($cacheKey, json_encode($distanceMatrix)); + // Cache distance matrix, but never an empty result so a failed lookup is retried + if ($distance > 0 && $time > 0) { + Redis::set($cacheKey, json_encode($distanceMatrix), 'EX', self::DISTANCE_MATRIX_CACHE_TTL); + } return $distanceMatrix; } diff --git a/server/tests/CommandContractsTest.php b/server/tests/CommandContractsTest.php index b9c808bbe..f6d14ca32 100644 --- a/server/tests/CommandContractsTest.php +++ b/server/tests/CommandContractsTest.php @@ -1716,7 +1716,7 @@ public function getDropoffOrLastWaypoint(): object ]); expect($command->handle())->toBe(Command::SUCCESS) - ->and($command->messages)->toContain(['info', 'Using provider: ']) + ->and($command->messages)->toContain(['info', 'Using provider: company routing settings']) ->and($command->messages)->toContain(['info', 'Looking back: last 2 day(s)']) ->and($command->messages)->toContain(['info', 'Chunk size: 250']) ->and($command->messages)->toContain(['warn', 'Another run appears to be in progress (lock active). Use --no-lock to bypass.']) @@ -2489,6 +2489,26 @@ public function error($string, $verbosity = null) ->and(collect($command->messages)->pluck(0))->toContain('error'); }); +test('track order distance command lets each order resolve its company provider without --provider', function () { + Carbon::setTestNow(Carbon::parse('2026-07-26 12:00:00')); + + $order = new FleetOpsTrackOrderFake(201); + $command = new FleetOpsTrackOrderDistanceAndTimeProbe([ + 'provider' => null, + 'days' => 2, + 'chunk' => 100, + 'dry' => false, + 'no-lock' => true, + ], [$order]); + + expect($command->handle())->toBe(Command::SUCCESS) + ->and($command->messages)->toContain(['info', 'Using provider: company routing settings']) + ->and($command->messages)->toContain(['info', 'Updated 1/1 orders.']) + ->and($order->distanceCalls)->toBe([[]]); + + Carbon::setTestNow(); +}); + test('track order distance command exits early when no qualifying orders exist', function () { Carbon::setTestNow(Carbon::parse('2026-07-26 12:00:00')); diff --git a/server/tests/Feature/Http/Api/OrderControllerPersistenceHelpersTest.php b/server/tests/Feature/Http/Api/OrderControllerPersistenceHelpersTest.php index ecaafd307..67e8eb580 100644 --- a/server/tests/Feature/Http/Api/OrderControllerPersistenceHelpersTest.php +++ b/server/tests/Feature/Http/Api/OrderControllerPersistenceHelpersTest.php @@ -163,9 +163,12 @@ public function __call($method, $arguments) $probe->callHelper('dispatchFinalizeApiOrderCreation', 'order-1', 'sq-1', true); expect(DispatchRecorder::$dispatched)->toHaveCount(1); - // The routing engine resolves configuration through phpdotenv, which is - // unavailable in the harness — the delegation body still executes. - expect(fn () => $probe->callHelper('drivingDistanceAndTime', [1.30, 103.80], [1.35, 103.85]))->toThrow(Error::class); + // Without a configured road provider the delegation returns the straight-line estimate + config(['fleetops.distance_matrix.provider' => null, 'fleetops.osrm.host' => null]); + $matrix = $probe->callHelper('drivingDistanceAndTime', [1.30, 103.80], [1.35, 103.85]); + expect($matrix)->toBeInstanceOf(Fleetbase\FleetOps\Support\DistanceMatrix::class) + ->and($matrix->distance)->toBeGreaterThan(7000)->toBeLessThan(8000) + ->and($matrix->time)->toBe(round($matrix->distance / 100) * Fleetbase\FleetOps\Support\Utils::DRIVING_TIME_MULTIPLIER); }); test('proof file storage and settings helpers persist and resolve', function () { diff --git a/server/tests/Unit/Models/OrderPayloadConfigAndDistanceTest.php b/server/tests/Unit/Models/OrderPayloadConfigAndDistanceTest.php index f78a13420..a039e08d7 100644 --- a/server/tests/Unit/Models/OrderPayloadConfigAndDistanceTest.php +++ b/server/tests/Unit/Models/OrderPayloadConfigAndDistanceTest.php @@ -276,6 +276,65 @@ function fleetopsOrderPayloadFetch(SQLiteConnection $connection, string $uuid): expect($order->getCurrentOriginPosition()?->getLat())->toBe(-37.70); }); +test('accurate distance uses the company routing engine and keeps the estimate when it fails', function () { + $connection = fleetopsOrderPayloadBoot(); + $connection->getSchemaBuilder()->create('settings', function ($blueprint) { + $blueprint->increments('id'); + $blueprint->string('key')->nullable(); + $blueprint->text('value')->nullable(); + $blueprint->timestamps(); + }); + $connection->table('settings')->insert(['key' => 'company.company-1.routing', 'value' => json_encode(['display_engine' => 'osrm'])]); + // Sunshine -> Melton, Victoria: ~25 km straight line, ~31 km by road + $connection->table('places')->insert([ + ['uuid' => '11111111-1111-4111-8111-111111111111', 'public_id' => 'place_vicpickup', 'company_uuid' => 'company-1', 'location' => fleetopsOrderPayloadWkb(-37.78, 144.83)], + ['uuid' => '22222222-2222-4222-8222-222222222222', 'public_id' => 'place_vicdropoff', 'company_uuid' => 'company-1', 'location' => fleetopsOrderPayloadWkb(-37.68, 144.58)], + ]); + $connection->table('payloads')->insert(['uuid' => 'payload-1', 'company_uuid' => 'company-1', 'pickup_uuid' => '11111111-1111-4111-8111-111111111111', 'dropoff_uuid' => '22222222-2222-4222-8222-222222222222']); + $connection->table('orders')->insert(['uuid' => 'order-1', 'company_uuid' => 'company-1', 'payload_uuid' => 'payload-1']); + + $redis = new class { + public function get($key) + { + return null; + } + + public function set($key, $value, ...$options) + { + return true; + } + }; + app()->instance('redis', $redis); + Illuminate\Support\Facades\Redis::clearResolvedInstance('redis'); + // Queued listeners and the estimations command run without a session company + session(['company' => null]); + config(['fleetops.distance_matrix.provider' => null, 'fleetops.osrm.host' => 'https://osrm.fleetbase.test']); + + try { + Illuminate\Support\Facades\Http::clearResolvedInstances(); + app()->forgetInstance(Illuminate\Http\Client\Factory::class); + Illuminate\Support\Facades\Http::fake(['*' => Illuminate\Support\Facades\Http::response(['code' => 'Ok', 'routes' => [['distance' => 31520.0, 'duration' => 1610.0]]])]); + + Order::query()->where('uuid', 'order-1')->first()->setDistanceAndTime(); + expect((float) $connection->table('orders')->value('distance'))->toBe(31520.0) + ->and((float) $connection->table('orders')->value('time'))->toBe(1610.0); + + // The road provider failing leaves the straight-line estimate, never zero + Illuminate\Support\Facades\Http::clearResolvedInstances(); + app()->forgetInstance(Illuminate\Http\Client\Factory::class); + Illuminate\Support\Facades\Http::fake(['*' => Illuminate\Support\Facades\Http::response(['code' => 'NoRoute', 'routes' => []])]); + + Order::query()->where('uuid', 'order-1')->first()->setDistanceAndTime(); + $distance = (float) $connection->table('orders')->value('distance'); + expect($distance)->toBeGreaterThan(20000)->toBeLessThan(30000) + ->and((float) $connection->table('orders')->value('time'))->toBe(round($distance / 100) * 7.2); + } finally { + config(['fleetops.osrm.host' => null]); + Illuminate\Support\Facades\Http::clearResolvedInstances(); + app()->forgetInstance(Illuminate\Http\Client\Factory::class); + } +}); + test('distance setters leave the order untouched when an endpoint is an ungeocoded placeholder', function () { $connection = fleetopsOrderPayloadBoot(); $connection->table('places')->insert([ diff --git a/server/tests/Unit/Support/DistanceMatrixProviderResolverTest.php b/server/tests/Unit/Support/DistanceMatrixProviderResolverTest.php new file mode 100644 index 000000000..337863c09 --- /dev/null +++ b/server/tests/Unit/Support/DistanceMatrixProviderResolverTest.php @@ -0,0 +1,273 @@ + $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); + app()->instance('db', new class($connection) { + public function __construct(public SQLiteConnection $c) + { + } + + public function connection($name = null): SQLiteConnection + { + return $this->c; + } + + public function __call($method, $arguments) + { + return $this->c->{$method}(...$arguments); + } + }); + Illuminate\Support\Facades\DB::clearResolvedInstance('db'); + + $connection->getSchemaBuilder()->create('settings', function ($blueprint) { + $blueprint->increments('id'); + $blueprint->string('key')->nullable(); + $blueprint->text('value')->nullable(); + $blueprint->timestamps(); + }); + + config([ + 'fleetops.distance_matrix.provider' => null, + 'fleetops.osrm.host' => 'https://osrm.fleetbase.test', + 'services.google_maps.api_key' => null, + ]); + + $redis = new class { + public array $store = []; + public array $sets = []; + + public function get($key) + { + return $this->store[$key] ?? null; + } + + public function set($key, $value, ...$options) + { + $this->store[$key] = $value; + $this->sets[] = [$key, $value, ...$options]; + + return true; + } + }; + app()->instance('redis', $redis); + Redis::clearResolvedInstance('redis'); + $GLOBALS['fleetopsDistanceProviderRedis'] = $redis; + + $log = new class { + public array $warnings = []; + + public function warning($message, array $context = []) + { + $this->warnings[] = [$message, $context]; + } + + public function __call($method, $arguments) + { + return null; + } + }; + app()->instance('log', $log); + Log::clearResolvedInstance('log'); + $GLOBALS['fleetopsDistanceProviderLog'] = $log; + + Http::clearResolvedInstances(); + app()->forgetInstance(Illuminate\Http\Client\Factory::class); + + return $connection; +} + +function fleetopsDistanceProviderRouting(SQLiteConnection $connection, string $companyUuid, array $routing): void +{ + $connection->table('settings')->insert(['key' => 'company.' . $companyUuid . '.routing', 'value' => json_encode($routing)]); +} + +afterEach(function () { + config([ + 'fleetops.distance_matrix.provider' => null, + 'fleetops.osrm.host' => null, + 'services.google_maps.api_key' => null, + ]); + app()->forgetInstance('log'); + Log::clearResolvedInstance('log'); + Http::clearResolvedInstances(); + app()->forgetInstance(Illuminate\Http\Client\Factory::class); + session(['company' => null]); +}); + +test('an explicit DISTANCE_MATRIX_PROVIDER wins over the company routing engine', function () { + $connection = fleetopsDistanceProviderBoot(); + fleetopsDistanceProviderRouting($connection, 'company-1', ['display_engine' => 'osrm']); + + config(['fleetops.distance_matrix.provider' => 'google']); + expect(DistanceMatrixProviderResolver::resolve('company-1'))->toBe('google'); + + // `calculate` is the operator's kill switch back to straight-line estimates + config(['fleetops.distance_matrix.provider' => 'calculate']); + expect(DistanceMatrixProviderResolver::resolve('company-1'))->toBe('calculate'); +}); + +test('the company routing engine is used when the server can call it', function () { + $connection = fleetopsDistanceProviderBoot(); + fleetopsDistanceProviderRouting($connection, 'company-osrm', ['router' => 'osrm', 'display_engine' => 'osrm']); + fleetopsDistanceProviderRouting($connection, 'company-google', ['display_engine' => 'google']); + fleetopsDistanceProviderRouting($connection, 'company-legacy', ['router' => 'google']); + fleetopsDistanceProviderRouting($connection, 'company-valhalla', ['display_engine' => 'valhalla']); + + // A company that never saved routing settings uses OSRM, as the map does + expect(DistanceMatrixProviderResolver::resolve('company-osrm'))->toBe('osrm') + ->and(DistanceMatrixProviderResolver::resolve('company-unsaved'))->toBe('osrm') + ->and(DistanceMatrixProviderResolver::resolve(null))->toBe('osrm'); + + // Google needs a Maps API key on the server + expect(DistanceMatrixProviderResolver::resolve('company-google'))->toBe('calculate'); + config(['services.google_maps.api_key' => 'test-maps-key']); + expect(DistanceMatrixProviderResolver::resolve('company-google'))->toBe('google') + ->and(DistanceMatrixProviderResolver::resolve('company-legacy'))->toBe('google') + ->and(DistanceMatrixProviderResolver::googleMapsApiKey())->toBe('test-maps-key'); + + // Engines the server cannot call fall back to the straight-line estimate + expect(DistanceMatrixProviderResolver::fromRoutingSettings('company-valhalla'))->toBeNull() + ->and(DistanceMatrixProviderResolver::resolve('company-valhalla'))->toBe('calculate') + ->and(DistanceMatrixProviderResolver::isAvailable('valhalla'))->toBeFalse(); +}); + +test('company OSRM selection never sends server traffic to the public demo server', function () { + $connection = fleetopsDistanceProviderBoot(); + fleetopsDistanceProviderRouting($connection, 'company-1', ['display_engine' => 'osrm']); + + foreach (['https://router.project-osrm.org', 'https://ROUTER.project-osrm.org/', null, 'not a url'] as $host) { + config(['fleetops.osrm.host' => $host]); + expect(DistanceMatrixProviderResolver::hasPrivateOsrmHost())->toBeFalse() + ->and(DistanceMatrixProviderResolver::resolve('company-1'))->toBe('calculate'); + } + + // The operator can still opt into the demo server explicitly + config(['fleetops.distance_matrix.provider' => 'osrm']); + expect(DistanceMatrixProviderResolver::resolve('company-1'))->toBe('osrm'); +}); + +test('an unreadable settings store falls back to the default routing engine', function () { + $connection = fleetopsDistanceProviderBoot(); + $connection->getSchemaBuilder()->drop('settings'); + + expect(DistanceMatrixProviderResolver::fromRoutingSettings('company-1'))->toBe('osrm') + ->and(DistanceMatrixProviderResolver::resolve('company-1'))->toBe('osrm'); +}); + +test('order distance follows the company routing engine and caches the road result', function () { + $connection = fleetopsDistanceProviderBoot(); + fleetopsDistanceProviderRouting($connection, 'company-1', ['display_engine' => 'osrm']); + $redis = $GLOBALS['fleetopsDistanceProviderRedis']; + + $requests = []; + Http::fake(function ($request) use (&$requests) { + $requests[] = (string) $request->url(); + + return Http::response(['code' => 'Ok', 'routes' => [['distance' => 31520.4, 'duration' => 1610.2]]]); + }); + + // Sunshine -> Melton, Victoria + $matrix = Utils::getDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58], ['company_uuid' => 'company-1']); + + expect($matrix->distance)->toBe(31520.4) + ->and($matrix->time)->toBe(1610.2) + ->and($requests)->toHaveCount(1) + ->and($requests[0])->toStartWith('https://osrm.fleetbase.test/route/v1/driving/144.83,-37.78;144.58,-37.68') + ->and($redis->sets)->toHaveCount(1) + ->and(array_slice($redis->sets[0], 2))->toBe(['EX', Utils::DISTANCE_MATRIX_CACHE_TTL]) + ->and($GLOBALS['fleetopsDistanceProviderLog']->warnings)->toBe([]); + + // Without a company option the session company decides + session(['company' => 'company-1']); + $fromSession = Utils::getDrivingDistanceAndTime([-37.70, 144.70], [-37.68, 144.58]); + expect($fromSession->distance)->toBe(31520.4) + ->and($requests)->toHaveCount(2); +}); + +test('a road provider without a route falls back to the straight-line estimate and logs it', function () { + fleetopsDistanceProviderBoot(); + $redis = $GLOBALS['fleetopsDistanceProviderRedis']; + $log = $GLOBALS['fleetopsDistanceProviderLog']; + Http::fake(['*' => Http::response(['code' => 'NoRoute', 'routes' => []])]); + + $estimate = Utils::calculateDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58]); + $matrix = Utils::getDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58], ['provider' => 'osrm']); + + expect($estimate->distance)->toBeGreaterThan(20000) + ->and($matrix->distance)->toBe($estimate->distance) + ->and($matrix->time)->toBe($estimate->time) + // Empty results are not cached, so the next run asks the provider again + ->and($redis->sets)->toBe([]) + ->and($log->warnings)->toHaveCount(1) + ->and($log->warnings[0][0])->toBe('Road distance unavailable, using the straight-line estimate instead.') + ->and($log->warnings[0][1]['provider'])->toBe('osrm') + ->and($log->warnings[0][1]['error'])->toBe('no route returned'); +}); + +test('a failing google request falls back to the straight-line estimate and logs the error', function () { + fleetopsDistanceProviderBoot(); + $log = $GLOBALS['fleetopsDistanceProviderLog']; + Http::fake(function () { + throw new ConnectionException('cURL error 28: Operation timed out'); + }); + + $estimate = Utils::calculateDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58]); + $matrix = Utils::getDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58], ['provider' => 'google']); + + expect($matrix->distance)->toBe($estimate->distance) + ->and($matrix->time)->toBe($estimate->time) + ->and($log->warnings[0][1]['provider'])->toBe('google') + ->and($log->warnings[0][1]['error'])->toBe('cURL error 28: Operation timed out'); + + // A google response with a zero element is treated as no route + Http::clearResolvedInstances(); + app()->forgetInstance(Illuminate\Http\Client\Factory::class); + Http::fake(['*' => Http::response(['rows' => [['elements' => [['status' => 'ZERO_RESULTS']]]]])]); + $zero = Utils::getDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58], ['provider' => 'google']); + expect($zero->distance)->toBe($estimate->distance) + ->and($log->warnings)->toHaveCount(2); +}); + +test('straight-line and same-point lookups never call a road provider', function () { + $connection = fleetopsDistanceProviderBoot(); + fleetopsDistanceProviderRouting($connection, 'company-1', ['display_engine' => 'osrm']); + $requests = 0; + Http::fake(function () use (&$requests) { + $requests++; + + return Http::response(['code' => 'Ok', 'routes' => [['distance' => 1.0, 'duration' => 1.0]]]); + }); + + $samePoint = Utils::getDrivingDistanceAndTime([-37.78, 144.83], [-37.78, 144.83], ['company_uuid' => 'company-1']); + $calculated = Utils::getDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58], ['provider' => 'calculate']); + + // With no company and no explicit provider the public demo default stays straight-line + config(['fleetops.osrm.host' => 'https://router.project-osrm.org']); + $default = Utils::getDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58]); + + expect($samePoint->distance)->toBe(0.0) + ->and($samePoint->time)->toBe(0.0) + ->and($calculated->time)->toBe(round($calculated->distance / 100) * Utils::DRIVING_TIME_MULTIPLIER) + ->and($default->distance)->toBe($calculated->distance) + ->and($requests)->toBe(0); +}); From b7dffb49aa8ece198eb7fb33e75b5131339a10a3 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 22 Oct 2026 20:43:56 +0800 Subject: [PATCH 02/22] fix(orders): allow partial payload edits and keep addresses sent with coordinates - UpdateOrderRequest no longer applies creation's route requirements to a payload object: an update may send only entities (with entities_mode=replace), only a new dropoff, or an empty waypoints list. - Place::createFromMixed treats an array as bare coordinates only when it has no address fields. A structured address sent with latitude/longitude keeps its street and uses those coordinates as its location, with no geocoding lookup. Previously the coordinates sent the place down the reverse-geocoding path, which discarded the street and stored a nearby address instead. --- .../src/Http/Requests/UpdateOrderRequest.php | 23 ++++ server/src/Models/Place.php | 69 +++++++++++- server/tests/RequestContractsTest.php | 48 ++++++++ .../Models/PlaceCreationAndImportTest.php | 106 ++++++++++++++++++ 4 files changed, 243 insertions(+), 3 deletions(-) diff --git a/server/src/Http/Requests/UpdateOrderRequest.php b/server/src/Http/Requests/UpdateOrderRequest.php index c43458007..fe6532360 100644 --- a/server/src/Http/Requests/UpdateOrderRequest.php +++ b/server/src/Http/Requests/UpdateOrderRequest.php @@ -4,4 +4,27 @@ class UpdateOrderRequest extends CreateOrderRequest { + /** + * Get the validation rules that apply to the request. + * + * An update edits an order that already has a payload, so a `payload` object may carry only + * the parts that change: entities alone, a new dropoff alone, and so on. The route endpoints + * that creation requires are therefore optional here. An empty waypoints list is allowed: + * the controller treats it as "remove the intermediate stops". + * + * @return array + */ + public function rules(): array + { + $validations = parent::rules(); + + if ($this->has('payload') && $this->isArray('payload')) { + $validations['payload'] = 'array'; + $validations['payload.pickup'] = 'nullable'; + $validations['payload.dropoff'] = 'nullable'; + $validations['payload.waypoints'] = 'array'; + } + + return $validations; + } } diff --git a/server/src/Models/Place.php b/server/src/Models/Place.php index ce68be110..d60d3d66d 100644 --- a/server/src/Models/Place.php +++ b/server/src/Models/Place.php @@ -405,6 +405,63 @@ public static function insertFromGeocodingLookup(string $address) return static::insertGetUuid(static::getValuesFromGeocodingLookup($address)); } + /** + * Whether a place array carries address fields, as opposed to being only coordinates. + */ + public static function hasStructuredAddress(mixed $place): bool + { + if (!is_array($place)) { + return false; + } + + foreach (['street1', 'address', 'city', 'postal_code'] as $field) { + if (static::normalizePlaceValue(data_get($place, $field)) !== null) { + return true; + } + } + + return false; + } + + /** + * Turn `latitude`/`longitude` (or `lat`/`lng`) on a structured place array into its + * `location`, unless a location was given explicitly. The loose keys are removed so they are + * not mass assigned. + * + * @param array $place + * + * @return array + */ + public static function withProvidedLocation(array $place): array + { + $coordinates = array_intersect_key($place, array_flip(['latitude', 'longitude', 'lat', 'lng', 'lon'])); + + if (empty($place['location']) && count($coordinates) >= 2) { + $point = Utils::getPointFromCoordinatesStrict($coordinates); + if ($point && !($point->getLat() == 0.0 && $point->getLng() == 0.0)) { + $place['location'] = new SpatialPoint($point->getLat(), $point->getLng()); + } + } + + return array_diff_key($place, array_flip(['latitude', 'longitude', 'lat', 'lng', 'lon'])); + } + + /** + * Whether a structured place already has both a street and a usable location. + * + * @param array $place + */ + public static function hasProvidedLocationAndStreet(array $place): bool + { + if (static::normalizePlaceValue(data_get($place, 'street1')) === null || empty($place['location'])) { + return false; + } + + $point = Utils::getPointFromMixed($place['location']); + + return $point instanceof SpatialPoint && !($point->getLat() == 0.0 && $point->getLng() == 0.0); + } + /** * Compose a fuller geocoding query from structured place attributes. */ @@ -708,8 +765,9 @@ public static function createFromMixed($place, $attributes = [], $saveInstance = // Return a new Place object created from a geocoding lookup return static::createFromGeocodingLookup($place, $saveInstance); } - // If $place is an array of coordinates - elseif (Utils::isCoordinatesStrict($place)) { + // If $place is only coordinates. An array that also carries address fields is a + // structured place: its address is kept and the coordinates become its location. + elseif (Utils::isCoordinatesStrict($place) && !static::hasStructuredAddress($place)) { return static::createFromCoordinates($place, $attributes, $saveInstance); } // If $place is an array @@ -730,12 +788,17 @@ public static function createFromMixed($place, $attributes = [], $saveInstance = return $existingPlace; } + // Coordinates sent alongside the address are the caller's location for it. + $place = static::withProvidedLocation($place); + $query = static::composeGeocodingQuery($place) ?? static::normalizePlaceValue(data_get($place, 'address')) ?? static::normalizePlaceValue(data_get($place, 'street1')); + // A street address that arrives with its own coordinates needs no lookup: geocoding + // would only add latency and could move the pin to a different nearby address. $geocoded = []; - if ($query) { + if ($query && !static::hasProvidedLocationAndStreet($place)) { $geocoded = static::getValuesFromGeocodingLookup($query); } diff --git a/server/tests/RequestContractsTest.php b/server/tests/RequestContractsTest.php index 045909958..a77d8d20e 100644 --- a/server/tests/RequestContractsTest.php +++ b/server/tests/RequestContractsTest.php @@ -154,6 +154,7 @@ public function __toString(): string use Fleetbase\FleetOps\Http\Requests\UpdateDeviceRequest; use Fleetbase\FleetOps\Http\Requests\UpdateFuelReportRequest; use Fleetbase\FleetOps\Http\Requests\UpdateIssueRequest; + use Fleetbase\FleetOps\Http\Requests\UpdateOrderRequest; use Fleetbase\FleetOps\Http\Requests\UpdateWorkOrderRequest; use Fleetbase\FleetOps\Http\Requests\VerifyCreateCustomerRequest; use Fleetbase\FleetOps\Rules\ComputableAlgo; @@ -197,6 +198,19 @@ function ruleStrings(array $rules): array return $strings; } + class FleetOpsPublicUpdateOrderRequestProbe extends UpdateOrderRequest + { + public function isArray(string $key): bool + { + return is_array($this->input($key)); + } + + public function isString(string $key): bool + { + return is_string($this->input($key)); + } + } + class FleetOpsPublicCreateOrderRequestProbe extends CreateOrderRequest { public function isArray(string $key): bool @@ -608,6 +622,40 @@ protected function canUpdateDriver(): bool ]); }); + test('public update order request accepts partial payload edits', function () { + app('config')->set('fleetops.pod_methods', ['scan', 'signature']); + + // Entities alone: an edit to an existing order need not resend its route + $entitiesOnly = FleetOpsPublicUpdateOrderRequestProbe::create('/fleetops-test', 'PUT', [ + 'payload' => ['entities' => [['name' => 'Loaf']]], + ])->rules(); + + expect($entitiesOnly['payload'])->toBe('array') + ->and($entitiesOnly['payload.entities'])->toBe('array') + ->and($entitiesOnly['payload.pickup'])->toBe('nullable') + ->and($entitiesOnly['payload.dropoff'])->toBe('nullable') + ->and($entitiesOnly['payload.waypoints'])->toBe('array') + ->and($entitiesOnly['payload.return'])->toBe('nullable'); + + // A new dropoff alone, and an empty waypoints list that clears intermediate stops + $dropoffOnly = FleetOpsPublicUpdateOrderRequestProbe::create('/fleetops-test', 'PUT', [ + 'payload' => ['dropoff' => ['street1' => '500 Pine St'], 'waypoints' => []], + ])->rules(); + + expect($dropoffOnly['payload.pickup'])->toBe('nullable') + ->and($dropoffOnly['payload.waypoints'])->toBe('array'); + + // A payload id and top-level fields keep the create contract + $payloadId = FleetOpsPublicUpdateOrderRequestProbe::create('/fleetops-test', 'PUT', [ + 'payload' => 'payload_abc1234', + ])->rules(); + $noPayload = FleetOpsPublicUpdateOrderRequestProbe::create('/fleetops-test', 'PUT', ['notes' => 'x'])->rules(); + + expect($payloadId['payload'])->toBe('required|exists:payloads,public_id') + ->and($noPayload)->not->toHaveKeys(['payload', 'pickup', 'dropoff', 'waypoints']) + ->and($noPayload['customer'][1])->toBeInstanceOf(CustomerIdOrDetails::class); + }); + test('internal create order request validates uuid payload contracts and messages', function () { app('config')->set('fleetops.pod_methods', ['scan', 'signature']); diff --git a/server/tests/Unit/Models/PlaceCreationAndImportTest.php b/server/tests/Unit/Models/PlaceCreationAndImportTest.php index 551dd7413..7d2df89a0 100644 --- a/server/tests/Unit/Models/PlaceCreationAndImportTest.php +++ b/server/tests/Unit/Models/PlaceCreationAndImportTest.php @@ -389,3 +389,109 @@ public function reverseQuery($query) expect(Place::insertFromCoordinates([1.42, 103.92], ['name' => 'Array Inserted Stop']))->toBeFalse() ->and($connection->table('places')->where('name', 'Array Inserted Stop')->count())->toBe(0); }); + +/** + * Geocoder fake that records whether it was asked anything and answers reverse lookups with a + * different, nearby address, which is what used to replace the street a caller sent. + */ +function fleetopsPlaceRecordingGeocoder(): object +{ + $geocoder = new class { + public array $calls = []; + + public function geocodeQuery($query) + { + $this->calls[] = 'geocode'; + + return $this; + } + + public function reverseQuery($query) + { + $this->calls[] = 'reverse'; + + return $this; + } + + public function all() + { + return []; + } + + public function __call($method, $arguments) + { + return $this; + } + }; + app()->instance('fleetops.geocoder', $geocoder); + + return $geocoder; +} + +test('a structured address sent with coordinates keeps its street and location without a lookup', function () { + $connection = fleetopsPlaceCreationBoot(); + $geocoder = fleetopsPlaceRecordingGeocoder(); + + $place = Place::createFromMixed([ + 'street1' => '500 Pine St', + 'city' => 'Seattle', + 'province' => 'WA', + 'postal_code' => '98101', + 'country' => 'US', + 'latitude' => 47.6114, + 'longitude' => -122.3373, + ]); + + $location = Fleetbase\FleetOps\Support\Utils::getPointFromMixed($place->location); + + expect($place)->toBeInstanceOf(Place::class) + ->and($place->street1)->toBe('500 Pine St') + ->and($place->city)->toBe('Seattle') + ->and($location->getLat())->toBe(47.6114) + ->and($location->getLng())->toBe(-122.3373) + ->and($geocoder->calls)->toBe([]) + ->and($connection->table('places')->where('street1', '500 Pine St')->count())->toBe(1); +}); + +test('bare coordinates still resolve through a reverse lookup', function () { + fleetopsPlaceCreationBoot(); + $geocoder = fleetopsPlaceRecordingGeocoder(); + + $place = Place::createFromMixed(['latitude' => 47.6114, 'longitude' => -122.3373], [], true); + + expect($place)->toBeInstanceOf(Place::class) + ->and($geocoder->calls)->toBe(['reverse']); +}); + +test('an address without coordinates is still geocoded', function () { + fleetopsPlaceCreationBoot(); + $geocoder = fleetopsPlaceRecordingGeocoder(); + + $place = Place::createFromMixed(['street1' => '1100 4th Ave', 'city' => 'Seattle', 'country' => 'US']); + + expect($place->street1)->toBe('1100 4th Ave') + ->and($geocoder->calls)->toBe(['geocode']); +}); + +test('structured address helpers detect addresses and provided locations', function () { + expect(Place::hasStructuredAddress(['latitude' => 1.3, 'longitude' => 103.8]))->toBeFalse() + ->and(Place::hasStructuredAddress(['latitude' => 1.3, 'longitude' => 103.8, 'street1' => ' ']))->toBeFalse() + ->and(Place::hasStructuredAddress(['postal_code' => '98101', 'lat' => 1.3, 'lng' => 103.8]))->toBeTrue() + ->and(Place::hasStructuredAddress('1100 4th Ave'))->toBeFalse(); + + $converted = Place::withProvidedLocation(['street1' => 'A', 'lat' => 1.3, 'lng' => 103.8]); + expect($converted)->not->toHaveKeys(['lat', 'lng']) + ->and($converted['location'])->toBeInstanceOf(Point::class) + ->and($converted['location']->getLat())->toBe(1.3); + + // An explicit location wins, a 0,0 pair is a geocoder miss and is dropped, a lone key is ignored + $explicit = Place::withProvidedLocation(['street1' => 'A', 'location' => 'keep', 'latitude' => 9, 'longitude' => 9]); + expect($explicit['location'])->toBe('keep') + ->and(Place::withProvidedLocation(['street1' => 'A', 'latitude' => 0, 'longitude' => 0]))->not->toHaveKey('location') + ->and(Place::withProvidedLocation(['street1' => 'A', 'latitude' => 1.3]))->toBe(['street1' => 'A']); + + expect(Place::hasProvidedLocationAndStreet(['street1' => 'A', 'location' => new Point(1.3, 103.8)]))->toBeTrue() + ->and(Place::hasProvidedLocationAndStreet(['street1' => 'A', 'location' => new Point(0, 0)]))->toBeFalse() + ->and(Place::hasProvidedLocationAndStreet(['street1' => 'A']))->toBeFalse() + ->and(Place::hasProvidedLocationAndStreet(['location' => new Point(1.3, 103.8)]))->toBeFalse(); +}); From 80cbed11220f24a557b66b606ca3b064bc423d87 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 22 Oct 2026 20:58:07 +0800 Subject: [PATCH 03/22] fix(orders,quotes): idempotent order creation, UTC scheduling, quote currency, expiry and preview - POST /v1/orders honours an Idempotency-Key header: repeats within a day return the first order (Idempotent-Replayed: true); a concurrent duplicate gets 409. - scheduled_at and time windows sent with a UTC offset are stored as the same instant in the app timezone. The datetime cast used to drop the offset. The schedule endpoint's company-timezone input is now stored in UTC as well. - Service quotes: the preliminary path read currency with has(), filtering by a boolean; it is now the currency code. Stored quotes get expired_at (default 48h, fleetops.service_quotes.ttl_minutes). preview=1 prices without storing quote rows, for display-only callers such as storefront checkouts. --- .../Controllers/Api/v1/OrderController.php | 69 ++++++++ .../Api/v1/ServiceQuoteController.php | 66 ++++++-- server/src/Models/Order.php | 31 +++- .../tests/ControllerHelperContractsTest.php | 2 +- .../Api/OrderControllerIdempotencyTest.php | 157 ++++++++++++++++++ .../Api/OrderControllerLookupHelpersTest.php | 3 +- .../Api/ServiceQuoteControllerQueryTest.php | 84 ++++++++++ .../Models/OrderSchedulingTimezoneTest.php | 41 +++++ 8 files changed, 439 insertions(+), 14 deletions(-) create mode 100644 server/tests/Feature/Http/Api/OrderControllerIdempotencyTest.php create mode 100644 server/tests/Unit/Models/OrderSchedulingTimezoneTest.php diff --git a/server/src/Http/Controllers/Api/v1/OrderController.php b/server/src/Http/Controllers/Api/v1/OrderController.php index 75e7cf96b..b4e654289 100644 --- a/server/src/Http/Controllers/Api/v1/OrderController.php +++ b/server/src/Http/Controllers/Api/v1/OrderController.php @@ -41,6 +41,7 @@ use Illuminate\Http\UploadedFile; use Illuminate\Support\Arr; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; @@ -58,6 +59,74 @@ class OrderController extends Controller * @return \Fleetbase\Http\Resources\Order */ public function create(CreateOrderRequest $request) + { + $idempotencyKey = $this->idempotencyKeyFromRequest($request); + if ($idempotencyKey === null) { + return $this->createOrderFromRequest($request); + } + + // An `Idempotency-Key` makes retries safe: the first request creates the order, and any + // repeat with the same key (same company and credential) within a day returns that order + // instead of creating another. A concurrent duplicate is refused while the first runs. + $cacheKey = $this->orderIdempotencyCacheKey($idempotencyKey); + if ($replayed = $this->replayIdempotentOrder($cacheKey, $request)) { + return $replayed; + } + + $lock = Cache::lock($cacheKey . ':lock', 180); + if (!$lock->get()) { + return response()->json(['errors' => ['A request with this Idempotency-Key is already being processed.']], 409); + } + + try { + if ($replayed = $this->replayIdempotentOrder($cacheKey, $request)) { + return $replayed; + } + + $response = $this->createOrderFromRequest($request); + if ($response instanceof OrderResource && $response->resource instanceof Order && $response->resource->public_id) { + Cache::put($cacheKey, $response->resource->public_id, now()->addDay()); + } + + return $response; + } finally { + $lock->release(); + } + } + + protected function idempotencyKeyFromRequest(Request $request): ?string + { + $key = trim((string) $request->header('Idempotency-Key', '')); + + return $key !== '' && strlen($key) <= 255 ? $key : null; + } + + protected function orderIdempotencyCacheKey(string $idempotencyKey): string + { + return 'fleetops:order-create:' . sha1(implode('|', [(string) $this->sessionCompany(), (string) session('api_credential'), $idempotencyKey])); + } + + protected function replayIdempotentOrder(string $cacheKey, Request $request) + { + $publicId = Cache::get($cacheKey); + if (!is_string($publicId) || $publicId === '') { + return null; + } + + $order = Order::where('public_id', $publicId)->where('company_uuid', $this->sessionCompany())->first(); + if (!$order) { + return null; + } + + return $this->orderResource($order)->response($request)->header('Idempotent-Replayed', 'true'); + } + + /** + * Creates a Fleetbase Order resource from the request (the body of create()). + * + * @return \Fleetbase\Http\Resources\v1\Order + */ + protected function createOrderFromRequest(CreateOrderRequest $request) { set_time_limit(180); diff --git a/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php b/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php index e3861fa30..17518b775 100644 --- a/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php +++ b/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php @@ -26,15 +26,16 @@ class ServiceQuoteController extends Controller */ public function query(QueryServiceQuotesRequest $request) { - $payload = $request->input('payload'); - $currency = $request->input('currency'); - $facilitator = $request->input('facilitator'); - $scheduledAt = $request->input('scheduled_at'); - $service = $request->input('service', 'all'); // the specific service rate to query - defaults to `all` - $serviceType = $request->input('service_type'); // the specific type of service rate to query - $single = $request->boolean('single'); - $isRouteOptimized = $request->boolean('is_route_optimized', true); - $requestId = $this->generateServiceQuoteRequestId(); + $this->previewOnly = $request->boolean('preview'); + $payload = $request->input('payload'); + $currency = $this->currencyFromRequest($request); + $facilitator = $request->input('facilitator'); + $scheduledAt = $request->input('scheduled_at'); + $service = $request->input('service', 'all'); // the specific service rate to query - defaults to `all` + $serviceType = $request->input('service_type'); // the specific type of service rate to query + $single = $request->boolean('single'); + $isRouteOptimized = $request->boolean('is_route_optimized', true); + $requestId = $this->generateServiceQuoteRequestId(); if (Utils::isPublicId($payload)) { $payload = Payload::with(['pickup', 'dropoff', 'waypoints', 'entities']) @@ -172,7 +173,7 @@ public function queryFromPreliminary(QueryServiceQuotesRequest $request) $service = $request->input('service', 'all'); // the specific service rate to query - defaults to `all` $serviceType = $request->input('service_type'); // the specific type of service rate to query $isCashOnDelivery = $request->has('cod'); - $currency = $request->has('currency'); + $currency = $this->currencyFromRequest($request); $totalDistance = $request->input('distance'); $totalTime = $request->input('time'); $preliminaryData = $this->preliminaryDataFromRequest($request); @@ -377,16 +378,59 @@ protected function getServicableServiceRates(iterable $waypoints, ?string $servi return ServiceRate::getServicableForPlaces($waypoints, $serviceType, $currency, $callback); } + /** + * Whether this request only previews prices. Preview quotes are calculated the same way but + * never stored, so display-only callers (a storefront checkout pricing every address a + * customer types) do not leave a quote row behind per request. They carry no id and cannot + * be attached to an order; request a stored quote for that. + */ + protected bool $previewOnly = false; + protected function createServiceQuote(array $attributes): ServiceQuote { + $attributes['expired_at'] ??= now()->addMinutes($this->quoteTtlMinutes()); + + if ($this->previewOnly) { + $quote = new ServiceQuote($attributes); + $quote->uuid = (string) Str::uuid(); + + return $quote; + } + return ServiceQuote::create($attributes); } protected function createServiceQuoteItem(array $attributes): ServiceQuoteItem { + if ($this->previewOnly) { + return new ServiceQuoteItem($attributes); + } + return ServiceQuoteItem::create($attributes); } + /** + * How long a stored quote stays valid. Unpurchased quotes are purged after 48 hours + * (fleetops:purge-service-quotes), so the default matches that window. + */ + protected function quoteTtlMinutes(): int + { + $minutes = config('fleetops.service_quotes.ttl_minutes'); + + return is_numeric($minutes) && (int) $minutes > 0 ? (int) $minutes : 2880; + } + + /** + * The requested currency as a code, or null. `currency` used to be read with `has()`, which + * turned it into a boolean and filtered every rate out. + */ + protected function currencyFromRequest(Request $request): ?string + { + $currency = $request->input('currency'); + + return is_string($currency) && trim($currency) !== '' ? strtoupper(trim($currency)) : null; + } + protected function serviceQuoteResource(ServiceQuote $serviceQuote) { return new ServiceQuoteResource($serviceQuote); @@ -406,7 +450,7 @@ protected function preliminaryDataFromRequest(Request $request): array 'waypoints' => $this->requestFirst($request, ['payload.waypoints', 'waypoints'], []), 'entities' => $this->requestFirst($request, ['payload.entities', 'entities']), 'cod' => $request->has('cod'), - 'currency' => $request->has('currency'), + 'currency' => $this->currencyFromRequest($request), ]; } diff --git a/server/src/Models/Order.php b/server/src/Models/Order.php index 0af1fae5a..3a916ee28 100644 --- a/server/src/Models/Order.php +++ b/server/src/Models/Order.php @@ -768,6 +768,35 @@ public function setStatusAttribute(?string $status = null): void $this->attributes['status'] = is_string($status) ? Str::snake($status) : 'created'; } + /** + * Store scheduled_at in the application timezone. + * + * @see toStorageDateTime() + */ + public function setScheduledAtAttribute($value): void + { + $this->attributes['scheduled_at'] = static::toStorageDateTime($value); + } + + /** + * Format a datetime for storage in the application timezone. + * + * The `datetime` cast formats a parsed value as Y-m-d H:i:s and drops its UTC offset, so + * "2026-10-21T11:00:00-07:00" was stored as 11:00 UTC instead of 18:00 UTC. Converting to the + * application timezone first keeps the instant. Values without an offset parse in the + * application timezone and are stored unchanged. + * + * @param mixed $value Anything Carbon::parse() accepts, or null + */ + public static function toStorageDateTime($value): ?string + { + if ($value === null || $value === '') { + return null; + } + + return Carbon::parse($value)->setTimezone(config('app.timezone', 'UTC'))->toDateTimeString(); + } + /** * Normalise the date portion of time_window_start on write. * @@ -823,7 +852,7 @@ protected function normaliseTimeWindowValue($value): ?string $parsed->setDate($ref->year, $ref->month, $ref->day); } - return $parsed->toDateTimeString(); + return $parsed->setTimezone(config('app.timezone', 'UTC'))->toDateTimeString(); } /** diff --git a/server/tests/ControllerHelperContractsTest.php b/server/tests/ControllerHelperContractsTest.php index cc0cbcdaf..7fd04389e 100644 --- a/server/tests/ControllerHelperContractsTest.php +++ b/server/tests/ControllerHelperContractsTest.php @@ -1175,7 +1175,7 @@ function fleetopsControllerStaticMethod(string $class, string $method): Reflecti ['name' => 'Box'], ], 'cod' => true, - 'currency' => true, + 'currency' => 'USD', ])->and($controller->callHelper('preliminaryStops', $pickup, [null, 'waypoint-public'], $dropoff)->values()->all())->toBe([ $pickup, 'waypoint-public', diff --git a/server/tests/Feature/Http/Api/OrderControllerIdempotencyTest.php b/server/tests/Feature/Http/Api/OrderControllerIdempotencyTest.php new file mode 100644 index 000000000..bf7b466ea --- /dev/null +++ b/server/tests/Feature/Http/Api/OrderControllerIdempotencyTest.php @@ -0,0 +1,157 @@ + $this->resource->public_id]; + } +} + +class FleetOpsOrderIdempotencyProbe extends OrderController +{ + public int $created = 0; + public array $orders = []; + public mixed $nextResponse = null; + + public function __construct() + { + } + + protected function createOrderFromRequest(CreateOrderRequest $request) + { + $this->created++; + if ($this->nextResponse !== null) { + return $this->nextResponse; + } + + $order = new Order(); + $order->setRawAttributes(['uuid' => 'order-uuid-' . $this->created, 'public_id' => 'order_idem' . $this->created, 'company_uuid' => 'company-1'], true); + $this->orders[$order->public_id] = $order; + + return new OrderResource($order); + } + + protected function sessionCompany(): ?string + { + return 'company-1'; + } + + protected function orderResource(Order $order) + { + return new FleetOpsOrderIdempotencyResource($order); + } + + public function callProtected(string $method, array $arguments = []): mixed + { + $reflection = new ReflectionMethod(OrderController::class, $method); + $reflection->setAccessible(true); + + return $reflection->invoke($this, ...$arguments); + } +} + +function fleetopsIdempotencyRequest(?string $key): CreateOrderRequest +{ + $request = CreateOrderRequest::create('/v1/orders', 'POST', [], [], [], $key === null ? [] : ['HTTP_IDEMPOTENCY_KEY' => $key]); + $request->setLaravelSession(app('session.store')); + + return $request; +} + +function fleetopsIdempotencyBoot(): Illuminate\Database\SQLiteConnection +{ + Cache::flush(); + session(['company' => 'company-1', 'api_credential' => 'cred-1']); + $connection = new Illuminate\Database\SQLiteConnection(new PDO('sqlite::memory:')); + $resolver = new Illuminate\Database\ConnectionResolver(['default' => $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + Illuminate\Database\Eloquent\Model::setConnectionResolver($resolver); + $connection->getSchemaBuilder()->create('orders', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + + return $connection; +} + +test('requests without an idempotency key create every time', function () { + fleetopsIdempotencyBoot(); + $controller = new FleetOpsOrderIdempotencyProbe(); + + $controller->create(fleetopsIdempotencyRequest(null)); + $controller->create(fleetopsIdempotencyRequest(' ')); + $controller->create(fleetopsIdempotencyRequest(str_repeat('k', 256))); + + expect($controller->created)->toBe(3); +}); + +test('a repeated idempotency key replays the first order', function () { + $connection = fleetopsIdempotencyBoot(); + $controller = new FleetOpsOrderIdempotencyProbe(); + + $first = $controller->create(fleetopsIdempotencyRequest('shopify:shop1:5001')); + expect($first)->toBeInstanceOf(OrderResource::class) + ->and($controller->created)->toBe(1); + + $connection->table('orders')->insert(['uuid' => 'order-uuid-1', 'public_id' => 'order_idem1', 'company_uuid' => 'company-1']); + $replay = $controller->create(fleetopsIdempotencyRequest('shopify:shop1:5001')); + expect($controller->created)->toBe(1) + ->and($replay->headers->get('Idempotent-Replayed'))->toBe('true') + ->and(json_decode($replay->getContent(), true)['data']['id'] ?? json_decode($replay->getContent(), true)['id'] ?? null)->toBe('order_idem1'); + + // A different key creates a new order + $controller->create(fleetopsIdempotencyRequest('shopify:shop1:5002')); + expect($controller->created)->toBe(2); +}); + +test('keys are scoped to the company and credential, and a vanished order is created again', function () { + fleetopsIdempotencyBoot(); + $controller = new FleetOpsOrderIdempotencyProbe(); + + $a = $controller->callProtected('orderIdempotencyCacheKey', ['k']); + session(['api_credential' => 'cred-2']); + $b = $controller->callProtected('orderIdempotencyCacheKey', ['k']); + expect($a)->not->toBe($b); + + // The cached order no longer exists: replay finds nothing and the order is created + Cache::put($b, 'order_gone', 60); + $controller->create(fleetopsIdempotencyRequest('k')); + expect($controller->created)->toBe(1); +}); + +test('a concurrent duplicate is refused and failed creations are not remembered', function () { + fleetopsIdempotencyBoot(); + $controller = new FleetOpsOrderIdempotencyProbe(); + $cacheKey = $controller->callProtected('orderIdempotencyCacheKey', ['busy']); + + $lock = Cache::lock($cacheKey . ':lock', 60); + $lock->get(); + $busy = $controller->create(fleetopsIdempotencyRequest('busy')); + expect($busy->getStatusCode())->toBe(409) + ->and($controller->created)->toBe(0); + $lock->release(); + + // An error response is passed through and does not claim the key + $controller->nextResponse = response()->json(['errors' => ['nope']], 400); + $failed = $controller->create(fleetopsIdempotencyRequest('busy')); + expect($failed->getStatusCode())->toBe(400) + ->and(Cache::get($cacheKey))->toBeNull(); + + $controller->nextResponse = null; + $controller->create(fleetopsIdempotencyRequest('busy')); + expect(Cache::get($cacheKey))->toBe('order_idem2'); +}); diff --git a/server/tests/Feature/Http/Api/OrderControllerLookupHelpersTest.php b/server/tests/Feature/Http/Api/OrderControllerLookupHelpersTest.php index f77c99dcb..2cae77b97 100644 --- a/server/tests/Feature/Http/Api/OrderControllerLookupHelpersTest.php +++ b/server/tests/Feature/Http/Api/OrderControllerLookupHelpersTest.php @@ -199,7 +199,8 @@ protected function findOrder(string $id, array $with = [], array $withCount = [] ]); $response = $probe->scheduleOrder('order_test', $request); - expect($connection->table('orders')->value('scheduled_at'))->toContain('2026-08-01 14:30'); + // 14:30 in the company timezone (Singapore, UTC+8) is stored as the same instant in UTC + expect($connection->table('orders')->value('scheduled_at'))->toContain('2026-08-01 06:30'); $missing = $probe->scheduleOrder('missing', $request); expect($missing->getStatusCode())->toBe(404) diff --git a/server/tests/Feature/Http/Api/ServiceQuoteControllerQueryTest.php b/server/tests/Feature/Http/Api/ServiceQuoteControllerQueryTest.php index 9228ab184..d92be4128 100644 --- a/server/tests/Feature/Http/Api/ServiceQuoteControllerQueryTest.php +++ b/server/tests/Feature/Http/Api/ServiceQuoteControllerQueryTest.php @@ -403,3 +403,87 @@ public function callProtected(string $method, array $arguments = []): mixed expect(fn () => $controller->callProtected('findServiceQuote', ['missing'])) ->toThrow(Illuminate\Database\Eloquent\ModelNotFoundException::class); }); + +test('stored quotes expire with the purge window and preview quotes are never stored', function () { + $connection = fleetopsApiServiceQuoteQueryBoot(); + $controller = new class extends ServiceQuoteController { + public function callProtected(string $method, array $arguments = []): mixed + { + $reflection = new ReflectionMethod(ServiceQuoteController::class, $method); + $reflection->setAccessible(true); + + return $reflection->invoke($this, ...$arguments); + } + + public function preview(bool $on): void + { + $this->previewOnly = $on; + } + }; + + Illuminate\Support\Carbon::setTestNow('2026-10-22 12:00:00'); + config()->set('fleetops.service_quotes.ttl_minutes', 30); + $attributes = ['request_id' => 'request_x', 'company_uuid' => 'company-1', 'service_rate_uuid' => 'rate-uuid', 'amount' => 100, 'currency' => 'USD']; + + $stored = $controller->callProtected('createServiceQuote', [$attributes]); + expect($stored->exists)->toBeTrue() + ->and($stored->expired_at->toDateTimeString())->toBe('2026-10-22 12:30:00') + ->and($connection->table('service_quotes')->count())->toBe(1); + + // An explicit expiry is kept; the default follows the 48 hour purge window + config()->set('fleetops.service_quotes.ttl_minutes', null); + expect($controller->callProtected('quoteTtlMinutes'))->toBe(2880) + ->and($controller->callProtected('createServiceQuote', [$attributes + ['expired_at' => '2026-10-23 00:00:00']])->expired_at->toDateTimeString()) + ->toBe('2026-10-23 00:00:00'); + + $controller->preview(true); + $preview = $controller->callProtected('createServiceQuote', [$attributes]); + $item = $controller->callProtected('createServiceQuoteItem', [['service_quote_uuid' => $preview->uuid, 'amount' => 100, 'currency' => 'USD', 'details' => 'Base', 'code' => 'BASE']]); + expect($preview->exists)->toBeFalse() + ->and($preview->uuid)->toBeString() + ->and($item->exists)->toBeFalse() + ->and($connection->table('service_quotes')->count())->toBe(2) + ->and($connection->table('service_quote_items')->count())->toBe(0); + + Illuminate\Support\Carbon::setTestNow(); +}); + +test('the requested currency is read as a code, never as a boolean', function () { + $controller = new class extends ServiceQuoteController { + public function currency(array $input): ?string + { + $reflection = new ReflectionMethod(ServiceQuoteController::class, 'currencyFromRequest'); + $reflection->setAccessible(true); + + return $reflection->invoke($this, new Illuminate\Http\Request($input)); + } + + public function preliminary(array $input): array + { + $reflection = new ReflectionMethod(ServiceQuoteController::class, 'preliminaryDataFromRequest'); + $reflection->setAccessible(true); + + return $reflection->invoke($this, new Illuminate\Http\Request($input)); + } + }; + + expect($controller->currency(['currency' => ' usd ']))->toBe('USD') + ->and($controller->currency(['currency' => '']))->toBeNull() + ->and($controller->currency(['currency' => ['USD']]))->toBeNull() + ->and($controller->currency([]))->toBeNull() + ->and($controller->preliminary(['currency' => 'sgd'])['currency'])->toBe('SGD'); +}); + +test('query switches to preview mode when asked', function () { + fleetopsApiServiceQuoteQueryBoot(); + $probe = new FleetOpsApiServiceQuoteQueryProbe(); + + $probe->query(fleetopsApiServiceQuoteQueryRequest(['preview' => true])); + $previewOnly = new ReflectionProperty(ServiceQuoteController::class, 'previewOnly'); + $previewOnly->setAccessible(true); + expect($previewOnly->getValue($probe))->toBeTrue(); + + $probe->query(fleetopsApiServiceQuoteQueryRequest([])); + expect($previewOnly->getValue($probe))->toBeFalse() + ->and($probe->preliminaryCalls)->toHaveCount(2); +}); diff --git a/server/tests/Unit/Models/OrderSchedulingTimezoneTest.php b/server/tests/Unit/Models/OrderSchedulingTimezoneTest.php new file mode 100644 index 000000000..39286b9a1 --- /dev/null +++ b/server/tests/Unit/Models/OrderSchedulingTimezoneTest.php @@ -0,0 +1,41 @@ +set('app.timezone', 'UTC'); + $order = new Order(); + + $order->scheduled_at = '2026-10-21T11:00:00-07:00'; + expect($order->getAttributes()['scheduled_at'])->toBe('2026-10-21 18:00:00'); + + $order->scheduled_at = '2026-10-21T18:00:00Z'; + expect($order->getAttributes()['scheduled_at'])->toBe('2026-10-21 18:00:00'); + + // Values without an offset are read in the app timezone and stored unchanged + $order->scheduled_at = '2026-10-21 09:30:00'; + expect($order->getAttributes()['scheduled_at'])->toBe('2026-10-21 09:30:00'); + + $order->scheduled_at = new DateTimeImmutable('2026-10-21 11:00:00', new DateTimeZone('America/Los_Angeles')); + expect($order->getAttributes()['scheduled_at'])->toBe('2026-10-21 18:00:00'); + + $order->scheduled_at = ''; + expect($order->getAttributes()['scheduled_at'])->toBeNull() + ->and(Order::toStorageDateTime(null))->toBeNull(); +}); + +test('time windows with an offset are converted too, and epoch-dated times take the order date', function () { + config()->set('app.timezone', 'UTC'); + $order = new Order(); + + $order->time_window_start = '2026-10-21T09:00:00-07:00'; + expect($order->getAttributes()['time_window_start'])->toBe('2026-10-21 16:00:00'); + + $order->scheduled_at = '2026-10-23 08:00:00'; + $order->time_window_end = '1970-01-01 11:00:00'; + expect($order->getAttributes()['time_window_end'])->toBe('2026-10-23 11:00:00'); +}); From 7db38fa2a39733091bc1bac101f9839d7e683866 Mon Sep 17 00:00:00 2001 From: Shivanath Devinarayanan Date: Fri, 23 Oct 2026 01:50:14 +0400 Subject: [PATCH 04/22] fix(tracking): accept single inner spaces in tracking number lookups Tracking numbers are generated with the first three characters of the company name as their prefix, so a company named "Al Noor" issues numbers like "AL 7796486490AE". The lookup pattern added in #352 has no space in its character class, so the public tracking page and fleet-ops/lookup now answer "not found" for those numbers. Allow a space only when it sits between two allowed characters. Leading, trailing and repeated spaces, tabs, newlines, quotes and other symbols are still refused, and the 100 character limit is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Internal/v1/OrderController.php | 2 +- .../Support/TrackingPage/TrackingResolver.php | 2 +- .../Internal/OrderControllerContractsTest.php | 8 ++++++++ .../TrackingPage/TrackingResolverTest.php | 20 +++++++++++++++++++ 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/server/src/Http/Controllers/Internal/v1/OrderController.php b/server/src/Http/Controllers/Internal/v1/OrderController.php index 14cd0a4f5..2bbe6420d 100644 --- a/server/src/Http/Controllers/Internal/v1/OrderController.php +++ b/server/src/Http/Controllers/Internal/v1/OrderController.php @@ -1984,7 +1984,7 @@ protected function normalizeTrackingLookup($input): ?string $input = trim($input); - return preg_match('/^[A-Za-z0-9][A-Za-z0-9._\-]{0,99}$/', $input) === 1 ? $input : null; + return preg_match('/^[A-Za-z0-9](?:[A-Za-z0-9._\-]| (?=[A-Za-z0-9._\-])){0,99}$/', $input) === 1 ? $input : null; } /** diff --git a/server/src/Support/TrackingPage/TrackingResolver.php b/server/src/Support/TrackingPage/TrackingResolver.php index 09b3091fd..927116e0f 100644 --- a/server/src/Support/TrackingPage/TrackingResolver.php +++ b/server/src/Support/TrackingPage/TrackingResolver.php @@ -22,7 +22,7 @@ */ class TrackingResolver { - public const NUMBER_PATTERN = '/^[A-Za-z0-9][A-Za-z0-9._\-]{0,99}$/'; + public const NUMBER_PATTERN = '/^[A-Za-z0-9](?:[A-Za-z0-9._\-]| (?=[A-Za-z0-9._\-])){0,99}$/'; /** * The tracking number as typed, trimmed, or null when it can't be one. diff --git a/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php b/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php index 9b5e62f19..ff183cbe5 100644 --- a/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php +++ b/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php @@ -1651,6 +1651,14 @@ function fleetopsSuppressStrNullDeprecations(): Closure expect($controller->lookup(new Request(['tracking' => 'TN-404']))->getData(true))->toBe($notFound) ->and($controller->trackingLookups)->toBe(['TN-404']); + // Numbers generated for companies with a space in the first three letters of + // their name ("AL 7796486490AE") reach the query; other spacing does not. + foreach (['AL 1', "AL\t1", "AL\n1", 'AL&1'] as $tracking) { + expect($controller->lookup(new Request(['tracking' => $tracking]))->getData(true))->toBe($notFound); + } + expect($controller->lookup(new Request(['tracking' => 'AL 7796486490AE']))->getData(true))->toBe($notFound) + ->and($controller->trackingLookups)->toBe(['TN-404', 'AL 7796486490AE']); + $trackedOrder = fleetopsInternalOrderLifecycleOrder('tracked-order'); $trackedOrder->trackerForTest = new FleetOpsInternalOrderLifecycleTrackerFake($trackedOrder); $controller->trackingOrder = $trackedOrder; diff --git a/server/tests/Unit/Support/TrackingPage/TrackingResolverTest.php b/server/tests/Unit/Support/TrackingPage/TrackingResolverTest.php index 40f2ee855..73ecd83e1 100644 --- a/server/tests/Unit/Support/TrackingPage/TrackingResolverTest.php +++ b/server/tests/Unit/Support/TrackingPage/TrackingResolverTest.php @@ -80,6 +80,26 @@ function trackingResolverSetting($db, string $key, mixed $value): void ->and((new TrackingResolver())->resolve('../etc'))->toBeNull(); }); +test('tracking numbers may hold single inner spaces, as generated prefixes can', function () { + // generateTrackingNumber() takes the first three letters of the company name, + // so a company called "Al Noor" issues numbers like "AL 7796486490AE". + $matches = fn (string $input) => preg_match(TrackingResolver::NUMBER_PATTERN, $input) === 1; + + expect(TrackingResolver::normalize('AL 7796486490AE'))->toBe('AL 7796486490AE') + ->and(TrackingResolver::normalize('ABC1234567890AE'))->toBe('ABC1234567890AE') + ->and(TrackingResolver::normalize(' AL 7796486490AE '))->toBe('AL 7796486490AE') + ->and($matches(' AL1'))->toBeFalse() + ->and($matches('AL1 '))->toBeFalse() + ->and(TrackingResolver::normalize('AL 1'))->toBeNull() + ->and(TrackingResolver::normalize("AL'1"))->toBeNull() + ->and(TrackingResolver::normalize('AL&1'))->toBeNull() + ->and(TrackingResolver::normalize("AL\t1"))->toBeNull() + ->and(TrackingResolver::normalize("AL\n1"))->toBeNull() + ->and(TrackingResolver::normalize('AL 1 2'))->toBe('AL 1 2') + ->and(TrackingResolver::normalize(str_repeat('A B', 33) . 'A'))->toBe(str_repeat('A B', 33) . 'A') + ->and(TrackingResolver::normalize(str_repeat('A B', 33) . 'AB'))->toBeNull(); +}); + test('each kind of tracking number opens its order for the right customer', function () { trackingResolverFixture(); $resolver = new TrackingResolver(); From 4ee3a825961bfe7f2af3e7d6167573009da5819d Mon Sep 17 00:00:00 2001 From: Shivanath Devinarayanan Date: Fri, 23 Oct 2026 01:50:50 +0400 Subject: [PATCH 05/22] fix(tracking): keep only letters and digits in the generated number prefix generateTrackingNumber() takes the first three characters of the company name as they are, so names such as "Al Noor" or "A&B Logistics" produce numbers containing a space or "&", and names in non-Latin scripts produce partial multibyte characters. Keep only A-Z and 0-9 from those three characters and fall back to FLB when nothing is left. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/src/Models/TrackingNumber.php | 16 +++++++++++++--- server/tests/Unit/Models/TrackingNumberTest.php | 10 ++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/server/src/Models/TrackingNumber.php b/server/src/Models/TrackingNumber.php index a5c9f0c77..eadb881ea 100644 --- a/server/src/Models/TrackingNumber.php +++ b/server/src/Models/TrackingNumber.php @@ -184,9 +184,8 @@ public function getTypeAttribute() */ public static function generateTrackingNumber($region = 'SG', $length = 10): string { - $company = \Fleetbase\Models\Company::where('uuid', session('company'))->withoutGlobalScopes()->first(); - $companyName = $company ? strtoupper(substr($company->name, 0, 3)) : null; - $number = $companyName ?? 'FLB'; + $company = \Fleetbase\Models\Company::where('uuid', session('company'))->withoutGlobalScopes()->first(); + $number = static::trackingNumberPrefix($company ? $company->name : null); for ($i = 0; $i < $length; $i++) { $number .= mt_rand(0, 9); @@ -195,6 +194,17 @@ public static function generateTrackingNumber($region = 'SG', $length = 10): str return $number . strtoupper($region); } + /** + * The tracking number prefix for a company: the letters and digits among the + * first three characters of its name, upper-cased, or FLB when none are left. + */ + public static function trackingNumberPrefix(?string $companyName): string + { + $prefix = (string) preg_replace('/[^A-Z0-9]/', '', strtoupper(substr((string) $companyName, 0, 3))); + + return $prefix !== '' ? $prefix : 'FLB'; + } + /** * Generates a unique fleetbase tracking number. * diff --git a/server/tests/Unit/Models/TrackingNumberTest.php b/server/tests/Unit/Models/TrackingNumberTest.php index c0084249d..6722729c7 100644 --- a/server/tests/Unit/Models/TrackingNumberTest.php +++ b/server/tests/Unit/Models/TrackingNumberTest.php @@ -258,3 +258,13 @@ public function getBarcodePNG(...$arguments) } } }); + +test('tracking number prefixes keep only letters and digits from the company name', function () { + expect(TrackingNumber::trackingNumberPrefix('Nordic Freight'))->toBe('NOR') + ->and(TrackingNumber::trackingNumberPrefix('Al Noor Transport'))->toBe('AL') + ->and(TrackingNumber::trackingNumberPrefix('A&B Logistics'))->toBe('AB') + ->and(TrackingNumber::trackingNumberPrefix('3PL Hub'))->toBe('3PL') + ->and(TrackingNumber::trackingNumberPrefix('النور'))->toBe('FLB') + ->and(TrackingNumber::trackingNumberPrefix(' '))->toBe('FLB') + ->and(TrackingNumber::trackingNumberPrefix(null))->toBe('FLB'); +}); From 02a6a6aa1bb66e7553388068cd4d40d6e0dc6cd9 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 23 Oct 2026 10:23:01 +0800 Subject: [PATCH 06/22] fix(security): never link a profile to another organization's login account Creating a driver, customer or contact looked up the login account by email or phone across the whole instance and linked any managed account of the same type to the new profile, adding it to the caller's organization. Any API key holder could reveal another tenant's driver (name, email, phone came back in the response) by guessing a phone number, attach that login to their own organization, and then change its email, phone or password through the profile update and credential endpoints. - ProfileAccountManager only reuses an account that already belongs to the requesting organization (or belongs to none). Another organization's account is refused with the generic "already in use by another account" error, which reveals neither its details nor its type. - Accounts already shared with another organization only take the name from profile updates, and driver/customer credential and login actions refuse them. - POST /v1/drivers no longer creates drivers in whatever company the `company` parameter names: anything but the credential's own organization is refused like an unknown company. --- .../Controllers/Api/v1/DriverController.php | 18 ++-- .../Internal/v1/CustomerController.php | 19 ++-- .../Internal/v1/DriverController.php | 7 +- server/src/Models/Contact.php | 2 +- server/src/Support/ProfileAccountManager.php | 58 ++++++++-- .../ApiDriverControllerContractsTest.php | 31 ++++-- .../DriverControllerSessionHelpersTest.php | 8 +- .../CustomerControllerCredentialsTest.php | 21 ++++ ...iverControllerExistingUserAdoptionTest.php | 33 +++++- .../DriverControllerLoginManagementTest.php | 26 +++++ server/tests/ModelAccessorContractsTest.php | 2 +- .../Unit/Models/SmallModelSurfacesTest.php | 8 ++ .../Support/ProfileAccountManagerTest.php | 101 ++++++++++++++++-- 13 files changed, 277 insertions(+), 57 deletions(-) diff --git a/server/src/Http/Controllers/Api/v1/DriverController.php b/server/src/Http/Controllers/Api/v1/DriverController.php index e1b331c50..cdb187245 100644 --- a/server/src/Http/Controllers/Api/v1/DriverController.php +++ b/server/src/Http/Controllers/Api/v1/DriverController.php @@ -91,11 +91,12 @@ public function create(CreateDriverRequest $request) // get user details for driver $userDetails = $request->only(['name', 'password', 'email', 'phone', 'timezone']); - // Get current company session - $company = $request->has('company') ? $this->companyFromRequest($request) : $this->currentCompany(); - - // Debugging: Ensure company is retrieved correctly - if (!$company) { + // Drivers are created in the credential's own organization. A request + // may still name it, but naming any other organization is refused the + // same way as one that does not exist. + $company = $this->currentCompany(); + $requestedCompany = $request->input('company'); + if (!$company || ($request->has('company') && !in_array($requestedCompany, [$company->uuid, $company->public_id], true))) { return $this->apiError('Company not found.'); } @@ -206,7 +207,7 @@ public function update($id, UpdateDriverRequest $request) // update the driver's login account through its proxy fields try { - ProfileAccountManager::syncProxyFields($driver->getUser(), $userDetails); + ProfileAccountManager::syncProxyFields($driver->getUser(), $userDetails, $driver->company_uuid); } catch (ProfileIdentityConflictException $exception) { return $this->jsonResponse(['error' => $exception->getMessage()], 422); } @@ -996,11 +997,6 @@ protected function driverInputFromRequest(Request $request, ?string $companyUuid return $input; } - protected function companyFromRequest(Request $request): ?Company - { - return Auth::getCompanyFromRequest($request); - } - protected function currentCompany(): ?Company { return Auth::getCompany(); diff --git a/server/src/Http/Controllers/Internal/v1/CustomerController.php b/server/src/Http/Controllers/Internal/v1/CustomerController.php index b20ed00b4..03851bb39 100644 --- a/server/src/Http/Controllers/Internal/v1/CustomerController.php +++ b/server/src/Http/Controllers/Internal/v1/CustomerController.php @@ -36,7 +36,7 @@ public function createPortalLogin(Request $request) return response()->error('Unable to create customer portal login.'); } - if ($error = $this->staffLinkedError($user)) { + if ($error = $this->staffLinkedError($user, $customer)) { return $error; } @@ -58,7 +58,7 @@ public function sendCredentials(Request $request) return response()->error('Unable to send customer portal credentials.'); } - if ($error = $this->staffLinkedError($user)) { + if ($error = $this->staffLinkedError($user, $customer)) { return $error; } @@ -80,7 +80,7 @@ public function deactivatePortalLogin(Request $request) return response()->error('Customer portal login not found.'); } - if ($error = $this->staffLinkedError($user)) { + if ($error = $this->staffLinkedError($user, $customer)) { return $error; } @@ -100,7 +100,7 @@ public function reactivatePortalLogin(Request $request) return response()->error('Customer portal login not found.'); } - if ($error = $this->staffLinkedError($user)) { + if ($error = $this->staffLinkedError($user, $customer)) { return $error; } @@ -180,7 +180,7 @@ public function resetCredentials(Request $request) return response()->error('Unable to reset customer credentials'); } - if ($error = $this->staffLinkedError($user)) { + if ($error = $this->staffLinkedError($user, $customer)) { return $error; } @@ -200,14 +200,19 @@ public function resetCredentials(Request $request) /** * A customer linked to a team member's account signs in with that account, - * which is managed in IAM. + * which is managed in IAM. A login another organization also uses is not + * this organization's to change either. */ - protected function staffLinkedError(User $user): ?\Illuminate\Http\JsonResponse + protected function staffLinkedError(User $user, Contact $customer): ?\Illuminate\Http\JsonResponse { if (ProfileAccountManager::isStaffAccount($user)) { return response()->error('This customer signs in with a team member account. Manage this login in IAM.', 422); } + if ($customer->company_uuid && ProfileAccountManager::isSharedWithAnotherCompany($user, $customer->company_uuid)) { + return response()->error(ProfileAccountManager::SHARED_LOGIN_MESSAGE, 422); + } + return null; } diff --git a/server/src/Http/Controllers/Internal/v1/DriverController.php b/server/src/Http/Controllers/Internal/v1/DriverController.php index 6e4dcc212..f121aeb83 100644 --- a/server/src/Http/Controllers/Internal/v1/DriverController.php +++ b/server/src/Http/Controllers/Internal/v1/DriverController.php @@ -179,7 +179,7 @@ function (&$request, &$driver, &$input) { $input = $input->except(['name', 'password', 'email', 'phone', 'meta', 'avatar_uuid', 'photo_uuid', 'user_uuid', 'user'])->toArray(); // Update the driver's login account through its proxy fields - ProfileAccountManager::syncProxyFields($driver->getUser(), $userInput); + ProfileAccountManager::syncProxyFields($driver->getUser(), $userInput, $driver->company_uuid); // Flush cache $driver->flushAttributesCache(); @@ -333,6 +333,11 @@ protected function resolveDriverLogin(string $id): array return [$driver, $user, response()->error('This driver signs in with a team member account. Manage this login in IAM.', 422)]; } + // Its password and status are not this organization's to change + if (ProfileAccountManager::isSharedWithAnotherCompany($user, $driver->company_uuid)) { + return [$driver, $user, response()->error(ProfileAccountManager::SHARED_LOGIN_MESSAGE, 422)]; + } + return [$driver, $user, null]; } diff --git a/server/src/Models/Contact.php b/server/src/Models/Contact.php index 9f0af9374..60c75c6d5 100644 --- a/server/src/Models/Contact.php +++ b/server/src/Models/Contact.php @@ -580,7 +580,7 @@ public function syncWithUser(): bool return false; } - return ProfileAccountManager::syncProxyFields($this->getUser(), $updates); + return ProfileAccountManager::syncProxyFields($this->getUser(), $updates, $this->company_uuid); } /** diff --git a/server/src/Support/ProfileAccountManager.php b/server/src/Support/ProfileAccountManager.php index 8619a957b..c73626fea 100644 --- a/server/src/Support/ProfileAccountManager.php +++ b/server/src/Support/ProfileAccountManager.php @@ -24,6 +24,11 @@ * staff account (type `user` or `admin`) of the same organization. A staff * account is managed in IAM, so a profile never changes its email or phone, * resets its password, deactivates it or deletes it. + * + * Emails and phone numbers identify one account across the whole instance, + * but an organization only ever reuses an account that already belongs to it. + * An account of another organization is never linked, and the refusal does + * not say whose it is. */ class ProfileAccountManager { @@ -32,6 +37,12 @@ class ProfileAccountManager */ public const MANAGED_TYPES = ['driver', 'contact', 'customer']; + /** + * Why an organization can't change the password or status of a login that + * another organization's profile also holds. + */ + public const SHARED_LOGIN_MESSAGE = 'This login is also used with another organization, so it can only be managed by its holder.'; + /** * The organization role given to each managed account type. */ @@ -126,8 +137,8 @@ public static function lookup(string $companyUuid, string $type, ?string $email, /** * Resolve the account for a new profile. A staff member of the organization - * or a managed account of the same type with the email/phone is linked; - * otherwise a managed account is created. + * or a managed account of the same type in the organization with the + * email/phone is linked; otherwise a managed account is created. * * @param array $attributes extra user attributes: password, status, timezone, avatar_uuid, country, ip_address, meta * @@ -191,18 +202,21 @@ public static function createManagedAccount(string $companyUuid, string $type, ? /** * Push the profile's proxy fields to its account. A staff account only - * takes the name: its email and phone are its console identity. + * takes the name: its email and phone are its console identity. So does a + * managed account that another organization's profile also holds, since + * its sign-in details are not this organization's to change. * * @throws ProfileIdentityConflictException */ - public static function syncProxyFields(?User $user, array $attributes): bool + public static function syncProxyFields(?User $user, array $attributes, ?string $companyUuid = null): bool { if (!$user) { return false; } - $fields = static::isStaffAccount($user) ? ['name'] : ['name', 'email', 'phone', 'timezone', 'avatar_uuid', 'password']; - $input = array_intersect_key($attributes, array_flip($fields)); + $nameOnly = static::isStaffAccount($user) || ($companyUuid && static::isSharedWithAnotherCompany($user, $companyUuid)); + $fields = $nameOnly ? ['name'] : ['name', 'email', 'phone', 'timezone', 'avatar_uuid', 'password']; + $input = array_intersect_key($attributes, array_flip($fields)); if (array_key_exists('email', $input)) { $input['email'] = static::normalizeEmail($input['email']); @@ -312,11 +326,13 @@ public static function deliverCredentials(Driver|Contact $profile, User $user, s */ public static function assertAccountCanHoldProfile(User $account, string $companyUuid, string $type, string $field = 'email'): void { - if (static::isStaffAccount($account)) { - if (!static::isCompanyMember($account, $companyUuid)) { - throw new ProfileIdentityConflictException(static::takenMessage($field), $field); - } - } elseif ($account->type !== $type) { + // An account of another organization is never linked, and nothing + // about it (its name, type or which organization holds it) is told + if (!static::isCompanyMember($account, $companyUuid) && !(static::isManagedAccount($account) && static::isUnowned($account))) { + throw new ProfileIdentityConflictException(static::takenMessage($field), $field); + } + + if (static::isManagedAccount($account) && $account->type !== $type) { throw new ProfileIdentityConflictException('This ' . static::fieldLabel($field) . ' is already used by a ' . $account->type . '.', $field); } @@ -345,6 +361,26 @@ public static function profileCompanies(User $user): \Illuminate\Support\Collect return $drivers->merge($contacts)->filter()->unique()->values(); } + /** + * Whether another organization holds the account through a membership or a + * profile. The account's own `company_uuid` is left out: it is only where + * the account was first created, and stays after that organization lets go. + */ + public static function isSharedWithAnotherCompany(User $user, string $companyUuid): bool + { + $memberships = CompanyUser::where('user_uuid', $user->uuid)->pluck('company_uuid'); + + return $memberships->merge(static::profileCompanies($user))->filter()->contains(fn ($uuid) => $uuid !== $companyUuid); + } + + /** + * Whether the account belongs to no organization at all. + */ + public static function isUnowned(User $user): bool + { + return !$user->company_uuid && !CompanyUser::where('user_uuid', $user->uuid)->exists(); + } + public static function isCompanyMember(User $user, ?string $companyUuid): bool { if (!$companyUuid) { diff --git a/server/tests/ApiDriverControllerContractsTest.php b/server/tests/ApiDriverControllerContractsTest.php index 27f52381e..39ead8ea5 100644 --- a/server/tests/ApiDriverControllerContractsTest.php +++ b/server/tests/ApiDriverControllerContractsTest.php @@ -41,13 +41,6 @@ class FleetOpsApiDriverControllerProbe extends DriverController public array $resolvedFiles = []; public string $sessionCompanyUuid = 'company-uuid'; - protected function companyFromRequest(Request $request): ?Company - { - $this->companyCalls[] = ['request', $request->input('company')]; - - return $this->missingCompany ? null : $this->company(); - } - protected function currentCompany(): ?Company { $this->companyCalls[] = ['current']; @@ -401,7 +394,7 @@ public function or(array $keys, mixed $default = null): mixed ])); expect($response)->toBe(['resource' => 'driver', 'driver' => $controller->driver]) - ->and($controller->companyCalls)->toBe([['request', 'company_public']]) + ->and($controller->companyCalls)->toBe([['current']]) // The login account is resolved by ProfileAccountManager in the driver's company ->and($controller->resolvedAccounts)->toBe(['company-uuid']) ->and($controller->createdUsers[0])->toMatchArray([ @@ -444,6 +437,28 @@ public function or(array $keys, mixed $default = null): mixed ->and($controller->createdUsers)->toBe([]); }); +test('api driver controller only creates drivers in the credential organization', function (string $company) { + // Naming the credential's own organization, by uuid or public id, is fine + $controller = new FleetOpsApiDriverControllerProbe(); + $controller->create(new CreateDriverRequest(['company' => $company, 'name' => 'Driver One'])); + + expect($controller->resolvedAccounts)->toBe(['company-uuid']) + ->and($controller->createdDrivers[0]['company_uuid'])->toBe('company-uuid'); +})->with(['company-uuid', 'company_public']); + +test('api driver controller refuses to create drivers in another organization', function (mixed $company) { + // Any other organization is refused exactly like one that does not exist, + // so the endpoint can't be used to probe or write into another tenant + $controller = new FleetOpsApiDriverControllerProbe(); + + expect($controller->create(new CreateDriverRequest(['company' => $company, 'name' => 'Driver One'])))->toBe([ + 'apiError' => 'Company not found.', + 'status' => 400, + ]) + ->and($controller->resolvedAccounts)->toBe([]) + ->and($controller->createdDrivers)->toBe([]); +})->with(['other-company-uuid', 'company_other', '', null]); + test('api driver controller updates drivers user details assignments location and photo', function () { fleetopsApiDriverContractsUsersTable(); diff --git a/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php b/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php index 5a7fdcae9..257c8918d 100644 --- a/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +++ b/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php @@ -174,12 +174,8 @@ public function parameters() expect($probe->callHelper('sessionCompany'))->toBe('22222222-2222-4222-8222-222222222222') ->and($probe->callHelper('currentCompany')?->public_id)->toBe('company_one') - ->and(true)->toBeTrue(); - - // With no identifying inputs the core auth resolver finds nothing and - // violates its own non-nullable declaration — the helper body still - // executes, which is the covered contract here. - expect(fn () => $probe->callHelper('companyFromRequest', Request::create('/x', 'GET')))->toThrow(TypeError::class); + // A create request can no longer pick its organization from the input + ->and(method_exists($probe, 'companyFromRequest'))->toBeFalse(); }); test('user and driver persistence helpers write records', function () { diff --git a/server/tests/Feature/Http/Internal/CustomerControllerCredentialsTest.php b/server/tests/Feature/Http/Internal/CustomerControllerCredentialsTest.php index 82dc5f916..ef2979ccb 100644 --- a/server/tests/Feature/Http/Internal/CustomerControllerCredentialsTest.php +++ b/server/tests/Feature/Http/Internal/CustomerControllerCredentialsTest.php @@ -127,6 +127,8 @@ public function __call($method, $arguments) 'companies' => ['uuid', 'public_id', 'name', 'country'], // session_status reads the user's membership of the session company 'company_users' => ['uuid', 'company_uuid', 'user_uuid', 'status'], + // a login held by another organization's driver profile is shared + 'drivers' => ['uuid', 'company_uuid', 'user_uuid'], 'files' => ['uuid', 'public_id', 'company_uuid', 'subject_uuid', 'subject_type', 'name', 'path', 'disk', 'type', '_key'], ]; foreach ($tables as $table => $columns) { @@ -180,6 +182,25 @@ public function __call($method, $arguments) ->and($GLOBALS['fleetopsCustomerMailFake']->sent)->not->toBeEmpty(); }); +test('credential actions refuse a login another organization also holds', function () { + $connection = fleetopsCustomerCredentialsBoot(); + $connection->table('company_users')->insert(['uuid' => 'cu-elsewhere', 'company_uuid' => 'company-2', 'user_uuid' => 'user-1', 'status' => 'active']); + $connection->table('users')->where('uuid', 'user-1')->update(['password' => 'hashed:original']); + $controller = new CustomerController(); + $request = Request::create('/x', 'POST', ['customer' => 'contact_custcred1', 'password' => 'takeover', 'password_confirmation' => 'takeover']); + + foreach (['resetCredentials', 'sendCredentials', 'createPortalLogin', 'deactivatePortalLogin', 'reactivatePortalLogin'] as $action) { + $response = $controller->{$action}($request); + + expect($response->getStatusCode())->toBe(422) + ->and($response->getData(true))->toBe(['error' => 'This login is also used with another organization, so it can only be managed by its holder.']); + } + + expect($connection->table('users')->value('password'))->toBe('hashed:original') + ->and($connection->table('users')->value('status'))->toBe('active') + ->and($GLOBALS['fleetopsCustomerMailFake']->sent)->toBeEmpty(); +}); + test('customer resolution and payload helpers expose user details', function () { fleetopsCustomerCredentialsBoot(); $probe = new FleetOpsInternalCustomerProbe(); diff --git a/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php b/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php index 38aa20dd3..3cff3d1bf 100644 --- a/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php +++ b/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php @@ -408,13 +408,14 @@ function fleetopsDriverAdoptionRoles(SQLiteConnection $connection, string $userU ->and($connection->table('drivers')->value('company_uuid'))->toBe('company-1'); }); -test('a managed driver account of another organization is linked and joins the company', function () { +test('a managed driver account of the organization is linked', function () { + // e.g. a driver whose profile was deleted while the account stayed a member $connection = fleetopsDriverAdoptionBoot([]); - $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111112', 'company_uuid' => 'company-2', 'name' => 'Shared Driver', 'phone' => '+6591234568', 'slug' => 'shared', 'type' => 'driver']); - $connection->table('drivers')->insert(['uuid' => 'driver-elsewhere', 'company_uuid' => 'company-2', 'user_uuid' => '11111111-1111-4111-8111-111111111112']); + $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111112', 'company_uuid' => 'company-2', 'name' => 'Returning Driver', 'phone' => '+6591234568', 'slug' => 'returning', 'type' => 'driver']); + $connection->table('company_users')->insert(['uuid' => 'cu-returning', 'company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111112', 'status' => 'active']); $result = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest([ - 'name' => 'Shared Driver', + 'name' => 'Returning Driver', 'phone' => '+6591234568', ])); @@ -422,7 +423,7 @@ function fleetopsDriverAdoptionRoles(SQLiteConnection $connection, string $userU ->and($result['driver']->resource->user_uuid)->toBe('11111111-1111-4111-8111-111111111112') ->and($connection->table('drivers')->where('company_uuid', 'company-1')->count())->toBe(1) ->and($connection->table('company_users')->where(['company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111112'])->count())->toBe(1) - ->and(fleetopsDriverAdoptionRoles($connection, '11111111-1111-4111-8111-111111111112'))->toBe(['Driver']) + ->and($connection->table('users')->count())->toBe(1) ->and($connection->table('invites')->count())->toBe(0); }); @@ -438,7 +439,11 @@ function fleetopsDriverAdoptionRoles(SQLiteConnection $connection, string $userU expect($result)->toBeInstanceOf(Illuminate\Http\JsonResponse::class) ->and($result->getStatusCode())->toBe(422) ->and($result->getData(true)['error'])->toBe($message) + // Nothing about the existing account is echoed back + ->and($result->getContent())->not->toContain('Existing') + ->and($result->getContent())->not->toContain('11111111-1111-4111-8111-111111111111') ->and($connection->table('drivers')->where('company_uuid', 'company-1')->where('user_uuid', '!=', '11111111-1111-4111-8111-111111111111')->count())->toBe(0) + ->and($connection->table('company_users')->where('company_uuid', 'company-1')->count())->toBe(0) ->and($connection->table('users')->count())->toBe(1); })->with([ 'staff of another organization' => [ @@ -447,6 +452,24 @@ function fleetopsDriverAdoptionRoles(SQLiteConnection $connection, string $userU ['email' => 'outsider@example.com'], 'This email is already in use by another account.', ], + 'a driver of another organization, by email' => [ + ['company_uuid' => 'company-2', 'email' => 'erin@example.com', 'type' => 'driver'], + ['uuid' => 'driver-elsewhere', 'company_uuid' => 'company-2', 'user_uuid' => '11111111-1111-4111-8111-111111111111'], + ['email' => 'Erin@Example.com', 'phone' => '+6599990000'], + 'This email is already in use by another account.', + ], + 'a driver of another organization, by phone' => [ + ['company_uuid' => 'company-2', 'email' => 'erin@example.com', 'phone' => '+6591234568', 'type' => 'driver'], + ['uuid' => 'driver-elsewhere', 'company_uuid' => 'company-2', 'user_uuid' => '11111111-1111-4111-8111-111111111111'], + ['email' => 'olive@example.com', 'phone' => '+65 9123 4568'], + 'This phone number is already in use by another account.', + ], + 'a customer of another organization does not reveal its type' => [ + ['company_uuid' => 'company-2', 'phone' => '+6591112222', 'type' => 'customer'], + null, + ['phone' => '+6591112222'], + 'This phone number is already in use by another account.', + ], 'a customer account' => [ ['company_uuid' => 'company-1', 'phone' => '+6591112222', 'type' => 'customer'], null, diff --git a/server/tests/Feature/Http/Internal/DriverControllerLoginManagementTest.php b/server/tests/Feature/Http/Internal/DriverControllerLoginManagementTest.php index 16bb6cd74..df8e93e0d 100644 --- a/server/tests/Feature/Http/Internal/DriverControllerLoginManagementTest.php +++ b/server/tests/Feature/Http/Internal/DriverControllerLoginManagementTest.php @@ -474,6 +474,32 @@ function fleetopsDriverLoginUser(SQLiteConnection $connection, string $uuid = '1 ->and(Illuminate\Support\Facades\Mail::getFacadeRoot()->sent)->toBe([]); }); +test('login management endpoints refuse a login another organization also holds', function () { + $connection = fleetopsDriverLoginFixture(); + // The managed driver account also drives for another organization + $connection->table('drivers')->insert(['uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa9', 'public_id' => 'driver_elsewhere', 'company_uuid' => 'company-2', 'user_uuid' => '11111111-1111-4111-8111-111111111111']); + $before = fleetopsDriverLoginUser($connection); + $controller = new DriverController(); + $reset = fn (string $id) => $controller->resetCredentials(Request::create('/x', 'POST', ['password' => 'taken-over', 'password_confirmation' => 'taken-over']), $id); + + foreach ([ + fn (string $id) => $controller->sendCredentials($id), + $reset, + fn (string $id) => $controller->deactivateLogin($id), + fn (string $id) => $controller->reactivateLogin($id), + ] as $endpoint) { + $shared = $endpoint('driver_managed'); + + expect($shared->getStatusCode())->toBe(422) + ->and($shared->getData(true))->toBe(['error' => 'This login is also used with another organization, so it can only be managed by its holder.']); + } + + $after = fleetopsDriverLoginUser($connection); + expect($after->password)->toBe($before->password) + ->and($after->status)->toBe($before->status) + ->and(Illuminate\Support\Facades\Mail::getFacadeRoot()->sent)->toBe([]); +}); + test('reset credentials validates the password and optionally sends it', function () { $connection = fleetopsDriverLoginFixture(); $controller = new DriverController(); diff --git a/server/tests/ModelAccessorContractsTest.php b/server/tests/ModelAccessorContractsTest.php index ca9fd6052..2e034c2ff 100644 --- a/server/tests/ModelAccessorContractsTest.php +++ b/server/tests/ModelAccessorContractsTest.php @@ -159,7 +159,7 @@ function fleetopsModelAccessorsUseProfileAccountTables(): void $table->string('phone')->nullable(); $table->timestamp('deleted_at')->nullable(); }); - foreach (['drivers', 'contacts'] as $profileTable) { + foreach (['drivers', 'contacts', 'company_users'] as $profileTable) { $schema->create($profileTable, function ($table) { $table->string('uuid')->nullable(); $table->string('user_uuid')->nullable(); diff --git a/server/tests/Unit/Models/SmallModelSurfacesTest.php b/server/tests/Unit/Models/SmallModelSurfacesTest.php index 6aecd0683..a782c58a6 100644 --- a/server/tests/Unit/Models/SmallModelSurfacesTest.php +++ b/server/tests/Unit/Models/SmallModelSurfacesTest.php @@ -212,6 +212,14 @@ public function __call($method, $arguments) test('contact observer rejects saves whose email or phone belongs to another account', function () { $connection = fleetopsSmallModelBoot(); + // The sync checks whether another organization holds the account + $connection->getSchemaBuilder()->create('drivers', function ($blueprint) { + $blueprint->increments('id'); + $blueprint->string('uuid')->nullable(); + $blueprint->string('company_uuid')->nullable(); + $blueprint->string('user_uuid')->nullable(); + $blueprint->timestamp('deleted_at')->nullable(); + }); $connection->table('users')->insert([ ['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'type' => 'contact', 'email' => 'free@example.test', 'phone' => '+6599999999'], ['uuid' => '22222222-2222-4222-8222-222222222222', 'company_uuid' => 'company-1', 'type' => 'user', 'email' => 'taken@example.test', 'phone' => '+6512345678'], diff --git a/server/tests/Unit/Support/ProfileAccountManagerTest.php b/server/tests/Unit/Support/ProfileAccountManagerTest.php index 079beea04..1d15d2bee 100644 --- a/server/tests/Unit/Support/ProfileAccountManagerTest.php +++ b/server/tests/Unit/Support/ProfileAccountManagerTest.php @@ -312,11 +312,10 @@ function fleetopsProfileAccountUser(array $attributes): User ->and($staff['conflict'])->toBeNull() ->and($outsider['staff'])->toBeNull() ->and($outsider['conflict'])->toBe('This email is already in use by another account.') - ->and($customer['conflict'])->toBe('This phone number is already used by a driver.') - // A driver account of another organization is linked, not reported as staff - ->and($sameType['account']?->uuid)->toBe('driver-1') + ->and($customer['conflict'])->toBe('This phone number is already in use by another account.') + // A driver account of another organization is never linked, whatever its type ->and($sameType['staff'])->toBeNull() - ->and($sameType['conflict'])->toBeNull() + ->and($sameType['conflict'])->toBe('This phone number is already in use by another account.') // An existing profile can't swap its account for another one ->and($taken['conflict'])->toBe('This email is already in use by another account.') ->and($taken['staff'])->toBeNull() @@ -369,15 +368,16 @@ function fleetopsProfileAccountUser(array $attributes): User ->and($connection->table('company_users')->where('user_uuid', $contact->uuid)->count())->toBe(0); }); -test('resolve for profile links staff members and managed accounts of the same type', function () { +test('resolve for profile links staff members and managed accounts of the organization', function () { $connection = fleetopsProfileAccountBoot(); fleetopsProfileAccountRows($connection, 'users', [ ['uuid' => 'staff-1', 'company_uuid' => 'company-2', 'name' => 'Staff', 'email' => 'staff@example.com', 'type' => 'user'], ['uuid' => 'driver-1', 'company_uuid' => 'company-2', 'phone' => '+6590001111', 'type' => 'driver'], ['uuid' => 'driver-2', 'company_uuid' => null, 'phone' => '+6590003333', 'type' => 'driver'], ]); - // The staff member belongs to company-1 through a membership row + // The staff member and the driver account belong to company-1 through membership rows $connection->table('company_users')->insert(['uuid' => 'cu-staff', 'company_uuid' => 'company-1', 'user_uuid' => 'staff-1', 'status' => 'active']); + $connection->table('company_users')->insert(['uuid' => 'cu-driver', 'company_uuid' => 'company-1', 'user_uuid' => 'driver-1', 'status' => 'active']); $staff = ProfileAccountManager::resolveForProfile('company-1', 'driver', 'Other Name', 'STAFF@example.com', null); $driver = ProfileAccountManager::resolveForProfile('company-1', 'driver', 'Driver', null, '+6590001111'); @@ -434,6 +434,95 @@ function fleetopsProfileAccountUser(array $attributes): User ->and(new ProfileIdentityConflictException('Taken'))->getField()->toBe('email'); }); +test('resolve for profile never links an account of another organization nor tells whose it is', function () { + $connection = fleetopsProfileAccountBoot(); + fleetopsProfileAccountRows($connection, 'users', [ + ['uuid' => 'erin-1', 'company_uuid' => 'company-2', 'name' => 'Erin Driver', 'email' => 'erin@example.com', 'phone' => '+6590001111', 'type' => 'driver'], + ['uuid' => 'cust-1', 'company_uuid' => 'company-2', 'name' => 'Carla Customer', 'email' => 'carla@example.com', 'type' => 'customer'], + ['uuid' => 'member-1', 'company_uuid' => null, 'name' => 'Member Elsewhere', 'phone' => '+6590002222', 'type' => 'driver'], + ]); + $connection->table('drivers')->insert(['uuid' => 'd-erin', 'company_uuid' => 'company-2', 'user_uuid' => 'erin-1']); + // An account without its own organization but a membership elsewhere is held too + $connection->table('company_users')->insert(['uuid' => 'cu-member', 'company_uuid' => 'company-2', 'user_uuid' => 'member-1', 'status' => 'active']); + + $conflict = function (callable $callback): ?ProfileIdentityConflictException { + try { + $callback(); + } catch (ProfileIdentityConflictException $exception) { + return $exception; + } + + return null; + }; + + $byPhone = $conflict(fn () => ProfileAccountManager::resolveForProfile('company-1', 'driver', 'Olive Odoo', 'olive@example.com', '+65 9000 1111')); + $byEmail = $conflict(fn () => ProfileAccountManager::resolveForProfile('company-1', 'driver', 'Olive Odoo', 'ERIN@example.com', '+6599999999')); + $customer = $conflict(fn () => ProfileAccountManager::resolveForProfile('company-1', 'customer', 'Carla', 'carla@example.com', null)); + $wrongType = $conflict(fn () => ProfileAccountManager::resolveForProfile('company-1', 'contact', 'Carla', 'carla@example.com', null)); + $membership = $conflict(fn () => ProfileAccountManager::resolveForProfile('company-1', 'driver', 'M', null, '+6590002222')); + + expect($byPhone?->getMessage())->toBe('This phone number is already in use by another account.') + ->and($byPhone?->getField())->toBe('phone') + ->and($byEmail?->getMessage())->toBe('This email is already in use by another account.') + ->and($byEmail?->getField())->toBe('email') + ->and($customer?->getMessage())->toBe('This email is already in use by another account.') + // Not "already used by a customer": the type of another organization's account is not told + ->and($wrongType?->getMessage())->toBe('This email is already in use by another account.') + ->and($membership?->getMessage())->toBe('This phone number is already in use by another account.'); + + foreach ([$byPhone, $byEmail, $customer, $wrongType, $membership] as $exception) { + expect(json_encode([$exception->getMessage(), $exception->getErrors()])) + ->not->toContain('Erin') + ->not->toContain('erin@') + ->not->toContain('Carla') + ->not->toContain('company-2'); + } + + // Nothing was linked, created or added to company-1 + expect($connection->table('users')->count())->toBe(3) + ->and($connection->table('company_users')->where('company_uuid', 'company-1')->count())->toBe(0) + ->and($connection->table('users')->where('uuid', 'member-1')->value('company_uuid'))->toBeNull(); +}); + +test('accounts held by another organization are told apart from unowned ones', function () { + $connection = fleetopsProfileAccountBoot(); + $own = fleetopsProfileAccountUser(['uuid' => 'own-1', 'company_uuid' => 'company-1', 'type' => 'driver']); + $created = fleetopsProfileAccountUser(['uuid' => 'created-1', 'company_uuid' => 'company-2', 'type' => 'driver']); + $member = fleetopsProfileAccountUser(['uuid' => 'member-1', 'company_uuid' => 'company-1', 'type' => 'driver']); + $profile = fleetopsProfileAccountUser(['uuid' => 'profile-1', 'company_uuid' => 'company-1', 'type' => 'contact']); + $orphan = fleetopsProfileAccountUser(['uuid' => 'orphan-1', 'company_uuid' => null, 'type' => 'driver']); + $connection->table('company_users')->insert(['uuid' => 'cu-1', 'company_uuid' => 'company-2', 'user_uuid' => 'member-1', 'status' => 'active']); + $connection->table('contacts')->insert(['uuid' => 'c-1', 'company_uuid' => 'company-2', 'user_uuid' => 'profile-1', 'type' => 'contact']); + + expect(ProfileAccountManager::isSharedWithAnotherCompany($own, 'company-1'))->toBeFalse() + // Being created by another organization that no longer holds it is not sharing + ->and(ProfileAccountManager::isSharedWithAnotherCompany($created, 'company-1'))->toBeFalse() + ->and(ProfileAccountManager::isSharedWithAnotherCompany($member, 'company-1'))->toBeTrue() + ->and(ProfileAccountManager::isSharedWithAnotherCompany($profile, 'company-1'))->toBeTrue() + ->and(ProfileAccountManager::isSharedWithAnotherCompany($orphan, 'company-1'))->toBeFalse() + ->and(ProfileAccountManager::isUnowned($orphan))->toBeTrue() + ->and(ProfileAccountManager::isUnowned($own))->toBeFalse() + ->and(ProfileAccountManager::isUnowned($member))->toBeFalse(); +}); + +test('sync proxy fields only pushes the name to a managed account another organization also holds', function () { + $connection = fleetopsProfileAccountBoot(); + $shared = fleetopsProfileAccountUser(['uuid' => 'shared-1', 'company_uuid' => 'company-2', 'name' => 'Shared', 'email' => 'shared@example.com', 'phone' => '+6590000000', 'type' => 'driver']); + $connection->table('company_users')->insert([ + ['uuid' => 'cu-1', 'company_uuid' => 'company-1', 'user_uuid' => 'shared-1', 'status' => 'active'], + ['uuid' => 'cu-2', 'company_uuid' => 'company-2', 'user_uuid' => 'shared-1', 'status' => 'active'], + ]); + + expect(ProfileAccountManager::syncProxyFields($shared, ['name' => 'Renamed', 'email' => 'mine@example.com', 'phone' => '+6591111111', 'password' => 'taken-over'], 'company-1'))->toBeTrue() + ->and($connection->table('users')->where('uuid', 'shared-1')->first()) + ->name->toBe('Renamed') + ->email->toBe('shared@example.com') + ->phone->toBe('+6590000000') + ->password->not->toBe('hashed:taken-over') + // Nor can its own organization while another one also holds it + ->and(ProfileAccountManager::syncProxyFields($shared, ['email' => 'new@example.com'], 'company-2'))->toBeFalse(); +}); + test('sync proxy fields pushes normalized changes to managed accounts and only the name to staff', function () { $connection = fleetopsProfileAccountBoot(); $connection->table('users')->insert(['uuid' => 'other-1', 'email' => 'taken@example.com', 'phone' => '+6599990000', 'type' => 'user']); From 6845d8f120bc30bb580c5c9cc9c746826b801433 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 23 Oct 2026 11:17:18 +0800 Subject: [PATCH 07/22] fix(orders): keep accepting Unix timestamps for scheduled_at, cover the locked replay toStorageDateTime() parsed every value with Carbon::parse(), which throws on a numeric string such as "1792605600". The datetime cast it replaces accepted Unix timestamps, so form-encoded API clients that send one would have started getting a 500. Numeric values now go through Carbon::createFromTimestamp(). Also covers the replay found after the lock is taken (a duplicate that finished first), the one line codecov/patch flagged. --- server/src/Models/Order.php | 9 ++++--- .../Api/OrderControllerIdempotencyTest.php | 26 +++++++++++++++++++ .../Models/OrderSchedulingTimezoneTest.php | 7 +++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/server/src/Models/Order.php b/server/src/Models/Order.php index 3a916ee28..7afd523b8 100644 --- a/server/src/Models/Order.php +++ b/server/src/Models/Order.php @@ -784,9 +784,10 @@ public function setScheduledAtAttribute($value): void * The `datetime` cast formats a parsed value as Y-m-d H:i:s and drops its UTC offset, so * "2026-10-21T11:00:00-07:00" was stored as 11:00 UTC instead of 18:00 UTC. Converting to the * application timezone first keeps the instant. Values without an offset parse in the - * application timezone and are stored unchanged. + * application timezone and are stored unchanged. Unix timestamps, numeric strings included, + * are accepted as the cast accepted them. * - * @param mixed $value Anything Carbon::parse() accepts, or null + * @param mixed $value A Unix timestamp, anything Carbon::parse() accepts, or null */ public static function toStorageDateTime($value): ?string { @@ -794,7 +795,9 @@ public static function toStorageDateTime($value): ?string return null; } - return Carbon::parse($value)->setTimezone(config('app.timezone', 'UTC'))->toDateTimeString(); + $date = is_numeric($value) ? Carbon::createFromTimestamp($value) : Carbon::parse($value); + + return $date->setTimezone(config('app.timezone', 'UTC'))->toDateTimeString(); } /** diff --git a/server/tests/Feature/Http/Api/OrderControllerIdempotencyTest.php b/server/tests/Feature/Http/Api/OrderControllerIdempotencyTest.php index bf7b466ea..fd3fd4646 100644 --- a/server/tests/Feature/Http/Api/OrderControllerIdempotencyTest.php +++ b/server/tests/Feature/Http/Api/OrderControllerIdempotencyTest.php @@ -155,3 +155,29 @@ function fleetopsIdempotencyBoot(): Illuminate\Database\SQLiteConnection $controller->create(fleetopsIdempotencyRequest('busy')); expect(Cache::get($cacheKey))->toBe('order_idem2'); }); + +test('an order stored by a duplicate that finished first is replayed once the lock is taken', function () { + $connection = fleetopsIdempotencyBoot(); + $controller = new class extends FleetOpsOrderIdempotencyProbe { + public int $lookups = 0; + + protected function replayIdempotentOrder(string $cacheKey, Illuminate\Http\Request $request) + { + // The first lookup misses; the duplicate finishes before this request takes the lock + if ($this->lookups++ === 0) { + Cache::put($cacheKey, 'order_raced', 60); + + return null; + } + + return parent::replayIdempotentOrder($cacheKey, $request); + } + }; + $connection->table('orders')->insert(['uuid' => 'order-uuid-raced', 'public_id' => 'order_raced', 'company_uuid' => 'company-1']); + + $replay = $controller->create(fleetopsIdempotencyRequest('raced')); + + expect($controller->created)->toBe(0) + ->and($controller->lookups)->toBe(2) + ->and($replay->headers->get('Idempotent-Replayed'))->toBe('true'); +}); diff --git a/server/tests/Unit/Models/OrderSchedulingTimezoneTest.php b/server/tests/Unit/Models/OrderSchedulingTimezoneTest.php index 39286b9a1..1bd479ece 100644 --- a/server/tests/Unit/Models/OrderSchedulingTimezoneTest.php +++ b/server/tests/Unit/Models/OrderSchedulingTimezoneTest.php @@ -23,6 +23,13 @@ $order->scheduled_at = new DateTimeImmutable('2026-10-21 11:00:00', new DateTimeZone('America/Los_Angeles')); expect($order->getAttributes()['scheduled_at'])->toBe('2026-10-21 18:00:00'); + // Unix timestamps, as an integer or a numeric string from a form body, are the same instant + $order->scheduled_at = 1792605600; + expect($order->getAttributes()['scheduled_at'])->toBe('2026-10-21 18:00:00'); + + $order->scheduled_at = '1792605600'; + expect($order->getAttributes()['scheduled_at'])->toBe('2026-10-21 18:00:00'); + $order->scheduled_at = ''; expect($order->getAttributes()['scheduled_at'])->toBeNull() ->and(Order::toStorageDateTime(null))->toBeNull(); From 416137554f0f218b01dbceca448f6fa5a1304b53 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 23 Oct 2026 11:19:50 +0800 Subject: [PATCH 08/22] fix(security): an account with a live profile elsewhere is not unowned isUnowned() only looked at users.company_uuid and company_users, so an account with neither but a live driver or contact profile in another organization (a customer who signed up through a storefront, for example) could still be adopted by any organization that knew its email or phone. It now also requires that the account holds no profile anywhere. --- server/src/Support/ProfileAccountManager.php | 7 +++++-- .../Unit/Support/ProfileAccountManagerTest.php | 16 +++++++++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/server/src/Support/ProfileAccountManager.php b/server/src/Support/ProfileAccountManager.php index c73626fea..c54db2be9 100644 --- a/server/src/Support/ProfileAccountManager.php +++ b/server/src/Support/ProfileAccountManager.php @@ -374,11 +374,14 @@ public static function isSharedWithAnotherCompany(User $user, string $companyUui } /** - * Whether the account belongs to no organization at all. + * Whether the account belongs to no organization at all: no organization of + * its own, no membership and no live profile anywhere. */ public static function isUnowned(User $user): bool { - return !$user->company_uuid && !CompanyUser::where('user_uuid', $user->uuid)->exists(); + return !$user->company_uuid + && !CompanyUser::where('user_uuid', $user->uuid)->exists() + && static::profileCompanies($user)->isEmpty(); } public static function isCompanyMember(User $user, ?string $companyUuid): bool diff --git a/server/tests/Unit/Support/ProfileAccountManagerTest.php b/server/tests/Unit/Support/ProfileAccountManagerTest.php index 1d15d2bee..fa1eda3c9 100644 --- a/server/tests/Unit/Support/ProfileAccountManagerTest.php +++ b/server/tests/Unit/Support/ProfileAccountManagerTest.php @@ -491,8 +491,11 @@ function fleetopsProfileAccountUser(array $attributes): User $member = fleetopsProfileAccountUser(['uuid' => 'member-1', 'company_uuid' => 'company-1', 'type' => 'driver']); $profile = fleetopsProfileAccountUser(['uuid' => 'profile-1', 'company_uuid' => 'company-1', 'type' => 'contact']); $orphan = fleetopsProfileAccountUser(['uuid' => 'orphan-1', 'company_uuid' => null, 'type' => 'driver']); + // No organization of its own and no membership, but a live profile elsewhere (a self-signup customer) + $signup = fleetopsProfileAccountUser(['uuid' => 'signup-1', 'company_uuid' => null, 'email' => 'signup@example.com', 'type' => 'customer']); $connection->table('company_users')->insert(['uuid' => 'cu-1', 'company_uuid' => 'company-2', 'user_uuid' => 'member-1', 'status' => 'active']); $connection->table('contacts')->insert(['uuid' => 'c-1', 'company_uuid' => 'company-2', 'user_uuid' => 'profile-1', 'type' => 'contact']); + $connection->table('contacts')->insert(['uuid' => 'c-2', 'company_uuid' => 'company-2', 'user_uuid' => 'signup-1', 'type' => 'customer']); expect(ProfileAccountManager::isSharedWithAnotherCompany($own, 'company-1'))->toBeFalse() // Being created by another organization that no longer holds it is not sharing @@ -502,7 +505,18 @@ function fleetopsProfileAccountUser(array $attributes): User ->and(ProfileAccountManager::isSharedWithAnotherCompany($orphan, 'company-1'))->toBeFalse() ->and(ProfileAccountManager::isUnowned($orphan))->toBeTrue() ->and(ProfileAccountManager::isUnowned($own))->toBeFalse() - ->and(ProfileAccountManager::isUnowned($member))->toBeFalse(); + ->and(ProfileAccountManager::isUnowned($member))->toBeFalse() + ->and(ProfileAccountManager::isUnowned($signup))->toBeFalse(); + + // So another organization can't adopt it by its email + $conflict = null; + try { + ProfileAccountManager::resolveForProfile('company-1', 'customer', 'X', $signup->email, null); + } catch (ProfileIdentityConflictException $exception) { + $conflict = $exception; + } + expect($conflict?->getMessage())->toBe('This email is already in use by another account.') + ->and($connection->table('company_users')->where('user_uuid', 'signup-1')->count())->toBe(0); }); test('sync proxy fields only pushes the name to a managed account another organization also holds', function () { From 9ca9a78b25b4af456e7aa426b3677fd2ab16cba9 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 23 Oct 2026 11:21:04 +0800 Subject: [PATCH 09/22] chore(release): v0.6.75 --- RELEASE.md | 12 ++++++++---- composer.json | 2 +- extension.json | 2 +- package.json | 2 +- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index d30e45903..7af403bbb 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,11 +1,15 @@ -> v0.6.74 ~ "Settings-backed geocoding everywhere" +> v0.6.75 ~ "Safer integrations and tenant-isolated profile accounts" --- ## Highlights -- **Fix: the console geocoder endpoints return results.** `int/v1/geocoder/query` and `int/v1/geocoder/reverse` returned `[]` for valid addresses whenever the Google Maps API key was set in the console instead of in the environment. This broke address lookup in the coordinates and autocomplete inputs, for example when editing a place. Both endpoints now use the same geocoder as `places/lookup`. (#408, fleetbase/fleetbase#723) -- **Fix: every other geocoding path uses the settings-backed key.** Place creation from an address or coordinates, place search, address-only place creation on the public API, and driver tracking locality all go through the same geocoder. Failed lookups are now logged instead of hidden. (#409) -- **Fix: `config/geocoder.php` passes the region rather than the locale to Google Maps, and uses the default cache store.** (#409) +- **Security: a profile never links to another organization's login account.** Creating a driver, customer or contact with an email or phone that belongs to another organization's account linked that account and exposed its name, email and phone. The new organization could then reset its password or deactivate it. Only accounts that already belong to the organization, or belong to no organization, are reused now. The refusal does not say whose account it is. `POST /v1/drivers` also ignores a `company` that is not the API credential's own. Logins already shared by the old behaviour can only be managed by their holder. (#414) +- **Fix: orders can be edited with a partial payload.** `PUT /v1/orders/{id}` accepts a `payload` with only the parts that change, such as only entities or only a new dropoff. (#411) +- **Fix: addresses sent with coordinates keep their street.** A place given as a street address plus `latitude`/`longitude` was reverse-geocoded and stored as a different nearby address. The street and the coordinates are now kept as sent, without a geocoding call. (#411) +- **Feature: idempotent order creation.** `POST /v1/orders` accepts an `Idempotency-Key` header. A retry with the same key within 24 hours returns the order already created, with `Idempotent-Replayed: true`, and a concurrent duplicate gets `409`. (#412) +- **Fix: `scheduled_at` keeps its UTC offset.** `2026-10-21T11:00:00-07:00` is now stored as 18:00 UTC instead of 11:00, and `PUT /v1/orders/{id}/schedule` stores the UTC instant of a date and time in the company timezone. Rows written before this keep their old values. (#412) +- **Fix: service quotes.** The `currency` filter works again, stored quotes expire after 48 hours by default (`fleetops.service_quotes.ttl_minutes`), and `preview=1` prices a request without storing quote rows. (#412) +- **Fix: tracking numbers with a space can be looked up again.** Since v0.6.72, a number such as `AL 7796486490AE`, generated for a company whose name has a space in its first three letters, got "not found". Single inner spaces are accepted now, and new numbers only use letters and digits in their prefix. (#413) --- ## Upgrading diff --git a/composer.json b/composer.json index 393bc6489..e59cb943f 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/fleetops-api", - "version": "0.6.74", + "version": "0.6.75", "description": "Fleet & Transport Management Extension for Fleetbase", "keywords": [ "fleetbase-extension", diff --git a/extension.json b/extension.json index 69fdf5802..c0757d489 100644 --- a/extension.json +++ b/extension.json @@ -1,6 +1,6 @@ { "name": "Fleet-Ops", - "version": "0.6.74", + "version": "0.6.75", "description": "Fleet & Transport Management Extension for Fleetbase", "repository": "https://github.com/fleetbase/fleetops", "license": "AGPL-3.0-or-later", diff --git a/package.json b/package.json index 70027d7b4..0822d957b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/fleetops-engine", - "version": "0.6.74", + "version": "0.6.75", "description": "Fleet & Transport Management Extension for Fleetbase", "fleetbase": { "route": "fleet-ops" From fdaa94a8b21ef4e1e2c9e0478e323ca405b7d3a2 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 23 Oct 2026 11:44:32 +0800 Subject: [PATCH 10/22] fix(tracking): generate tracking numbers from letters and digits only Two parts of a generated number could carry a space or punctuation: - The prefix took the first three characters of the company name, then dropped what was not a letter or digit, so "Al Noor Transport" gave AL and "A&B Logistics" gave AB. It now skips those characters first and takes the first three letters or digits (ALN, ABL), falling back to FLB. - The suffix appended the region as given. The region falls back to the pickup's province or district when it has no country, and POST /v1/tracking-numbers accepts any string, so a pickup in "New York" issued ...NEW YORK. It now keeps only letters and digits, falling back to SG. Numbers already issued keep working through the lookup change in this PR. --- server/src/Models/TrackingNumber.php | 30 ++++++++++++++++--- .../tests/Unit/Models/TrackingNumberTest.php | 27 +++++++++++++++-- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/server/src/Models/TrackingNumber.php b/server/src/Models/TrackingNumber.php index eadb881ea..4f23fe95d 100644 --- a/server/src/Models/TrackingNumber.php +++ b/server/src/Models/TrackingNumber.php @@ -191,20 +191,42 @@ public static function generateTrackingNumber($region = 'SG', $length = 10): str $number .= mt_rand(0, 9); } - return $number . strtoupper($region); + return $number . static::trackingNumberSuffix($region); } /** - * The tracking number prefix for a company: the letters and digits among the - * first three characters of its name, upper-cased, or FLB when none are left. + * The tracking number prefix for a company: the first three letters or + * digits of its name, upper-cased, or FLB when it has none. Spaces and + * punctuation are skipped, so "Al Noor Transport" gives ALN. */ public static function trackingNumberPrefix(?string $companyName): string { - $prefix = (string) preg_replace('/[^A-Z0-9]/', '', strtoupper(substr((string) $companyName, 0, 3))); + $prefix = substr(static::trackingNumberCharacters($companyName), 0, 3); return $prefix !== '' ? $prefix : 'FLB'; } + /** + * The tracking number suffix for a region: its letters and digits, + * upper-cased, or SG when it has none. A region can be a province or + * district name when the pickup has no country ("New York" gives NEWYORK). + */ + public static function trackingNumberSuffix(?string $region): string + { + $suffix = static::trackingNumberCharacters($region); + + return $suffix !== '' ? $suffix : 'SG'; + } + + /** + * The ASCII letters and digits of a value, upper-cased. A tracking number + * holds nothing else, so it can always be typed, scanned and looked up. + */ + protected static function trackingNumberCharacters(?string $value): string + { + return (string) preg_replace('/[^A-Z0-9]/', '', strtoupper((string) $value)); + } + /** * Generates a unique fleetbase tracking number. * diff --git a/server/tests/Unit/Models/TrackingNumberTest.php b/server/tests/Unit/Models/TrackingNumberTest.php index 6722729c7..0dbd70f61 100644 --- a/server/tests/Unit/Models/TrackingNumberTest.php +++ b/server/tests/Unit/Models/TrackingNumberTest.php @@ -261,10 +261,33 @@ public function getBarcodePNG(...$arguments) test('tracking number prefixes keep only letters and digits from the company name', function () { expect(TrackingNumber::trackingNumberPrefix('Nordic Freight'))->toBe('NOR') - ->and(TrackingNumber::trackingNumberPrefix('Al Noor Transport'))->toBe('AL') - ->and(TrackingNumber::trackingNumberPrefix('A&B Logistics'))->toBe('AB') + ->and(TrackingNumber::trackingNumberPrefix('Al Noor Transport'))->toBe('ALN') + ->and(TrackingNumber::trackingNumberPrefix('A&B Logistics'))->toBe('ABL') + ->and(TrackingNumber::trackingNumberPrefix("O'Brien Couriers"))->toBe('OBR') ->and(TrackingNumber::trackingNumberPrefix('3PL Hub'))->toBe('3PL') + ->and(TrackingNumber::trackingNumberPrefix('Ab'))->toBe('AB') + ->and(TrackingNumber::trackingNumberPrefix('Café Express'))->toBe('CAF') ->and(TrackingNumber::trackingNumberPrefix('النور'))->toBe('FLB') ->and(TrackingNumber::trackingNumberPrefix(' '))->toBe('FLB') ->and(TrackingNumber::trackingNumberPrefix(null))->toBe('FLB'); }); + +test('tracking number suffixes keep only letters and digits from the region', function () { + expect(TrackingNumber::trackingNumberSuffix('sg'))->toBe('SG') + ->and(TrackingNumber::trackingNumberSuffix('New York'))->toBe('NEWYORK') + ->and(TrackingNumber::trackingNumberSuffix(' AE '))->toBe('AE') + ->and(TrackingNumber::trackingNumberSuffix('Île-de-France'))->toBe('LEDEFRANCE') + ->and(TrackingNumber::trackingNumberSuffix('القاهرة'))->toBe('SG') + ->and(TrackingNumber::trackingNumberSuffix(''))->toBe('SG') + ->and(TrackingNumber::trackingNumberSuffix(null))->toBe('SG'); +}); + +test('generated tracking numbers hold only letters and digits whatever the company name and region', function () { + session(['company' => null]); + + foreach (['New York', 'Abu Dhabi', 'ae', "Côte d'Ivoire", ''] as $region) { + expect(TrackingNumber::generateTrackingNumber($region, 10))->toMatch('/^[A-Z0-9]+$/'); + } + + expect(TrackingNumber::generateTrackingNumber('New York', 4))->toMatch('/^FLB\d{4}NEWYORK$/'); +}); From bb54a0f0c047f94317b78bd6c3cead7db07ace8b Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 23 Oct 2026 12:05:40 +0800 Subject: [PATCH 11/22] docs(release): v0.6.75 notes cover #416 --- RELEASE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE.md b/RELEASE.md index 7af403bbb..123c1667e 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -9,7 +9,7 @@ - **Feature: idempotent order creation.** `POST /v1/orders` accepts an `Idempotency-Key` header. A retry with the same key within 24 hours returns the order already created, with `Idempotent-Replayed: true`, and a concurrent duplicate gets `409`. (#412) - **Fix: `scheduled_at` keeps its UTC offset.** `2026-10-21T11:00:00-07:00` is now stored as 18:00 UTC instead of 11:00, and `PUT /v1/orders/{id}/schedule` stores the UTC instant of a date and time in the company timezone. Rows written before this keep their old values. (#412) - **Fix: service quotes.** The `currency` filter works again, stored quotes expire after 48 hours by default (`fleetops.service_quotes.ttl_minutes`), and `preview=1` prices a request without storing quote rows. (#412) -- **Fix: tracking numbers with a space can be looked up again.** Since v0.6.72, a number such as `AL 7796486490AE`, generated for a company whose name has a space in its first three letters, got "not found". Single inner spaces are accepted now, and new numbers only use letters and digits in their prefix. (#413) +- **Fix: tracking numbers with a space can be looked up again.** Since v0.6.72, a number such as `AL 7796486490AE`, generated for a company whose name has a space in its first three letters, got "not found". Single inner spaces are accepted now. New numbers only use letters and digits: the company prefix skips spaces and punctuation ("Al Noor" gives `ALN`), and a region such as "New York" becomes `NEWYORK`. (#413, #416) --- ## Upgrading From 088e3b515941948738ad4a899941270b0a050ce7 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 24 Oct 2026 11:06:56 +0800 Subject: [PATCH 12/22] feat(routing): Valhalla for order distance and tracking; Google only by explicit opt-in Add a Valhalla client (X-Api-Key) used for order distance/time and a Valhalla tracking provider. The company's routing engine or the platform's Valhalla/ private OSRM server is used when configured; Google is never picked from company settings. Add a switch to disable Google Routes tracking. Co-Authored-By: Claude --- server/config/fleetops.php | 39 ++++- .../src/Providers/FleetOpsServiceProvider.php | 3 + server/src/Support/Encoding/Polyline6.php | 11 ++ .../DistanceMatrixProviderResolver.php | 48 ++++-- server/src/Support/Routing/ValhallaClient.php | 76 +++++++++ server/src/Support/Utils.php | 51 +++++- .../GoogleRoutesTrackingProvider.php | 10 +- .../Providers/ValhallaTrackingProvider.php | 67 ++++++++ server/tests/TrackingIntelligenceTest.php | 91 ++++++++++ .../DistanceMatrixProviderResolverTest.php | 155 ++++++++++++++++-- 10 files changed, 512 insertions(+), 39 deletions(-) create mode 100644 server/src/Support/Encoding/Polyline6.php create mode 100644 server/src/Support/Routing/ValhallaClient.php create mode 100644 server/src/Tracking/Providers/ValhallaTrackingProvider.php diff --git a/server/config/fleetops.php b/server/config/fleetops.php index cd5668b09..89b36f5ea 100644 --- a/server/config/fleetops.php +++ b/server/config/fleetops.php @@ -40,6 +40,23 @@ 'host' => env('OSRM_HOST', 'https://router.project-osrm.org'), ], + /* + |-------------------------------------------------------------------------- + | Valhalla + |-------------------------------------------------------------------------- + | + | A self-hosted Valhalla server used for order distance, time and tracking + | ETAs. Shares its variables with the Valhalla extension. The API key is + | sent as the X-Api-Key header. The public community servers are ignored. + | + */ + 'valhalla' => [ + 'host' => env('VALHALLA_HOST', env('VALHALLA_BASE_URI')), + 'api_key' => env('VALHALLA_API_KEY'), + 'costing' => env('VALHALLA_COSTING', 'auto'), + 'timeout' => env('VALHALLA_TIMEOUT', 5), + ], + /* |-------------------------------------------------------------------------- | VROOM Route Optimisation Engine @@ -62,14 +79,17 @@ /* |-------------------------------------------------------------------------- | Distance Matrix Calculator - | Options: "calculate", "google", "osrm" + |-------------------------------------------------------------------------- + | + | Options: "calculate", "valhalla", "osrm", "google" | | Leave DISTANCE_MATRIX_PROVIDER unset to measure order distance and time - | with each company's routing engine (Fleet-Ops Settings -> Routing) when - | the server can call it: OSRM needs a self-hosted OSRM_HOST, Google needs - | GOOGLE_MAPS_API_KEY. Otherwise the straight-line "calculate" estimate is - | used. Setting it applies one provider to every company. - |-------------------------------------------------------------------------- + | with a self-hosted engine: the company's routing engine when the server + | is configured for it, otherwise VALHALLA_HOST, then a private OSRM_HOST, + | otherwise the straight-line "calculate" estimate. Google is only used + | when this is set to "google", because it is billed per request. Setting + | it applies one provider to every company. + | */ 'distance_matrix' => [ 'provider' => env('DISTANCE_MATRIX_PROVIDER'), @@ -83,11 +103,16 @@ | Provider-neutral tracking data for active orders. The provider registry | can be extended by third-party packages without editing FleetOps core. | - | Built-in providers: "google_routes", "osrm", "calculated" + | Built-in providers: "google_routes", "valhalla", "osrm", "calculated" + | + | Google Routes is billed per request. TRACKING_GOOGLE_ROUTES_ENABLED=false + | stops every Google Routes tracking call, whatever a company selects, and + | tracking falls through to the next provider. |-------------------------------------------------------------------------- */ 'tracking' => [ 'provider' => env('TRACKING_PROVIDER', 'google_routes'), + 'google_routes_enabled' => env('TRACKING_GOOGLE_ROUTES_ENABLED', true), 'fallbacks' => array_filter(explode(',', env('TRACKING_PROVIDER_FALLBACKS', 'osrm,calculated'))), 'traffic_enabled' => env('TRACKING_TRAFFIC_ENABLED', true), 'cache_ttl_seconds' => env('TRACKING_CACHE_TTL_SECONDS', 60), diff --git a/server/src/Providers/FleetOpsServiceProvider.php b/server/src/Providers/FleetOpsServiceProvider.php index bb24c8932..344c6c098 100644 --- a/server/src/Providers/FleetOpsServiceProvider.php +++ b/server/src/Providers/FleetOpsServiceProvider.php @@ -191,6 +191,9 @@ function (\Fleetbase\FleetOps\Tracking\TrackingProviderRegistry $registry) { if (!$registry->has('osrm')) { $registry->register(new \Fleetbase\FleetOps\Tracking\Providers\OsrmTrackingProvider()); } + if (!$registry->has('valhalla')) { + $registry->register(new \Fleetbase\FleetOps\Tracking\Providers\ValhallaTrackingProvider()); + } if (!$registry->has('calculated')) { $registry->register(new \Fleetbase\FleetOps\Tracking\Providers\CalculatedTrackingProvider()); } diff --git a/server/src/Support/Encoding/Polyline6.php b/server/src/Support/Encoding/Polyline6.php new file mode 100644 index 000000000..a8e1c9952 --- /dev/null +++ b/server/src/Support/Encoding/Polyline6.php @@ -0,0 +1,11 @@ + 'osrm', - 'google' => 'google', - default => null, + 'osrm' => 'osrm', + 'valhalla' => 'valhalla', + default => null, }; } public static function isAvailable(string $provider): bool { return match ($provider) { - 'osrm' => static::hasPrivateOsrmHost(), - 'google' => filled(static::googleMapsApiKey()), - default => false, + 'osrm' => static::hasPrivateOsrmHost(), + 'valhalla' => ValhallaClient::isConfigured(), + default => false, }; } diff --git a/server/src/Support/Routing/ValhallaClient.php b/server/src/Support/Routing/ValhallaClient.php new file mode 100644 index 000000000..8d1b48d9e --- /dev/null +++ b/server/src/Support/Routing/ValhallaClient.php @@ -0,0 +1,76 @@ +acceptJson(); + if ($apiKey = static::apiKey()) { + $request = $request->withHeaders(['X-Api-Key' => $apiKey]); + } + + $response = $request->post(static::host() . '/route', array_merge([ + 'locations' => array_map(fn (Point $point) => ['lat' => $point->getLat(), 'lon' => $point->getLng()], array_values($points)), + 'costing' => config('fleetops.valhalla.costing', 'auto'), + 'units' => 'kilometers', + 'directions_type' => 'none', + 'shape_format' => 'polyline6', + ], $options)); + + if (!$response->successful()) { + throw new \RuntimeException('Valhalla request failed with status ' . $response->status() . ': ' . data_get($response->json(), 'error', 'unknown error')); + } + + $trip = data_get($response->json(), 'trip'); + if (!is_array($trip) || (int) data_get($trip, 'status', -1) !== 0) { + throw new \RuntimeException('Valhalla returned no route.'); + } + + return $trip; + } +} diff --git a/server/src/Support/Utils.php b/server/src/Support/Utils.php index c90ed873d..a02e55519 100644 --- a/server/src/Support/Utils.php +++ b/server/src/Support/Utils.php @@ -4,6 +4,7 @@ use Fleetbase\FleetOps\Flow\Activity; use Fleetbase\FleetOps\Support\Routing\DistanceMatrixProviderResolver; +use Fleetbase\FleetOps\Support\Routing\ValhallaClient; use Fleetbase\LaravelMysqlSpatial\Types\Point; use Fleetbase\Models\Company; use Fleetbase\Models\Setting; @@ -827,8 +828,10 @@ public static function getDrivingDistanceAndTime($origin, $destination, $options try { $distanceMatrix = match ($provider) { - 'osrm' => static::getDistanceMatrixFromOSRM($origins, $destinations), - default => static::getDistanceMatrixFromGoogle($origins, $destinations), + 'valhalla' => static::getDistanceMatrixFromValhalla($origins, $destinations), + 'osrm' => static::getDistanceMatrixFromOSRM($origins, $destinations), + 'google' => static::getDistanceMatrixFromGoogle($origins, $destinations), + default => throw new \InvalidArgumentException('Unsupported distance matrix provider: ' . $provider), }; } catch (\Throwable $e) { $distanceMatrix = null; @@ -1040,6 +1043,50 @@ public static function getDistanceMatrixFromOSRM(string $origins, string $destin return $distanceMatrix; } + /** + * Retrieves the driving distance and time from the configured Valhalla server. + * + * Origins and destinations use the same `lat,lng` strings (separated by `|`) as the + * other providers and are routed in order. Results are cached per coordinate pair; + * a failed request or missing route throws so the caller can fall back. + * + * @param string $origins a string of origins in 'latitude,longitude' format, separated by `|` + * @param string $destinations a string of destinations in a similar format to origins + * + * @return DistanceMatrix the distance in metres and time in seconds + */ + public static function getDistanceMatrixFromValhalla(string $origins, string $destinations): DistanceMatrix + { + $cacheKey = 'valhalla:' . md5($origins . '_' . $destinations); + $cachedResult = Redis::get($cacheKey); + if ($cachedResult) { + $json = json_decode($cachedResult); + + if (!empty($json->distance) && !empty($json->time)) { + return new DistanceMatrix($json->distance, $json->time); + } + } + + $points = array_map(function ($coordinate) { + [$latitude, $longitude] = array_map('floatval', explode(',', $coordinate)); + + return new Point($latitude, $longitude); + }, explode('|', $origins . '|' . $destinations)); + + $trip = ValhallaClient::route($points); + $distance = (float) data_get($trip, 'summary.length', 0) * 1000; + $time = (float) data_get($trip, 'summary.time', 0); + + $distanceMatrix = new DistanceMatrix($distance, $time); + + // Cache distance matrix, but never an empty result so a failed lookup is retried + if ($distance > 0 && $time > 0) { + Redis::set($cacheKey, json_encode($distanceMatrix), 'EX', self::DISTANCE_MATRIX_CACHE_TTL); + } + + return $distanceMatrix; + } + /** * Calculates driving distance and time between two points using Vincenty's formula. * Returns distance in meters and time in seconds. diff --git a/server/src/Tracking/Providers/GoogleRoutesTrackingProvider.php b/server/src/Tracking/Providers/GoogleRoutesTrackingProvider.php index 328ddc67a..6b1dfe77d 100644 --- a/server/src/Tracking/Providers/GoogleRoutesTrackingProvider.php +++ b/server/src/Tracking/Providers/GoogleRoutesTrackingProvider.php @@ -24,7 +24,7 @@ public function capabilities(): TrackingProviderCapabilities public function canTrack(TrackingContext $context): bool { - return $context->canRoute() && filled($this->apiKey()); + return $context->canRoute() && $this->enabled() && filled($this->apiKey()); } public function track(TrackingContext $context, TrackingOptions $options): TrackingProviderResult @@ -111,6 +111,14 @@ protected function durationToSeconds(?string $duration): ?float return (float) Str::replaceLast('s', '', $duration); } + /** + * Operator kill switch for billed Google Routes calls. + */ + protected function enabled(): bool + { + return filter_var(config('fleetops.tracking.google_routes_enabled', true), FILTER_VALIDATE_BOOLEAN); + } + protected function apiKey(): ?string { $configuredKey = config('services.google_maps.api_key'); diff --git a/server/src/Tracking/Providers/ValhallaTrackingProvider.php b/server/src/Tracking/Providers/ValhallaTrackingProvider.php new file mode 100644 index 000000000..d67c2be45 --- /dev/null +++ b/server/src/Tracking/Providers/ValhallaTrackingProvider.php @@ -0,0 +1,67 @@ +canRoute() && ValhallaClient::isConfigured(); + } + + public function track(TrackingContext $context, TrackingOptions $options): TrackingProviderResult + { + $trip = ValhallaClient::route($context->routePoints()); + + $legs = []; + $points = []; + foreach (data_get($trip, 'legs', []) as $index => $leg) { + $legs[] = [ + 'index' => $index, + 'distance_m' => (float) data_get($leg, 'summary.length', 0) * 1000, + 'duration_s' => (float) data_get($leg, 'summary.time', 0), + 'duration_in_traffic_s' => null, + 'provider' => $this->key(), + ]; + + // Stitch Valhalla's polyline6 leg shapes into one polyline5, the encoding OSRM and Google return + foreach (Polyline6::decode((string) data_get($leg, 'shape', '')) as $point) { + $points[] = [$point->getLng(), $point->getLat()]; + } + } + + $polyline = $points ? Polyline::encode($points) : null; + + return new TrackingProviderResult( + provider: $this->key(), + distanceMeters: (float) data_get($trip, 'summary.length', 0) * 1000, + durationSeconds: (float) data_get($trip, 'summary.time', 0), + durationInTrafficSeconds: null, + polyline: $polyline, + coordinates: $polyline ? Polyline::decode($polyline) : [], + legs: $legs, + warnings: ['no_live_traffic'], + confidence: 'medium', + raw: $trip + ); + } +} diff --git a/server/tests/TrackingIntelligenceTest.php b/server/tests/TrackingIntelligenceTest.php index d79c7c6dc..507a4e524 100644 --- a/server/tests/TrackingIntelligenceTest.php +++ b/server/tests/TrackingIntelligenceTest.php @@ -4,9 +4,12 @@ use Fleetbase\FleetOps\Models\Order; use Fleetbase\FleetOps\Models\Payload; use Fleetbase\FleetOps\Models\Place; +use Fleetbase\FleetOps\Support\Encoding\Polyline; +use Fleetbase\FleetOps\Support\Encoding\Polyline6; use Fleetbase\FleetOps\Tracking\Providers\CalculatedTrackingProvider; use Fleetbase\FleetOps\Tracking\Providers\GoogleRoutesTrackingProvider; use Fleetbase\FleetOps\Tracking\Providers\OsrmTrackingProvider; +use Fleetbase\FleetOps\Tracking\Providers\ValhallaTrackingProvider; use Fleetbase\FleetOps\Tracking\Support\FakeTrackingProvider; use Fleetbase\FleetOps\Tracking\TrackingContext; use Fleetbase\FleetOps\Tracking\TrackingContextBuilder; @@ -857,3 +860,91 @@ function googleRoutesTrackingContext(): TrackingContext expect(fn () => $provider->track($context, new TrackingOptions())) ->toThrow(RuntimeException::class, 'Google Routes returned no route.'); }); + +test('google routes tracking is skipped when the operator switches it off', function () { + $provider = new GoogleRoutesTrackingProviderProbe(); + $provider->fakeApiKey = 'test-api-key'; + $context = googleRoutesTrackingContext(); + + try { + app('config')->set('fleetops.tracking.google_routes_enabled', false); + expect($provider->canTrack($context))->toBeFalse(); + + app('config')->set('fleetops.tracking.google_routes_enabled', 'true'); + expect($provider->canTrack($context))->toBeTrue(); + } finally { + app('config')->set('fleetops.tracking.google_routes_enabled', true); + } +}); + +test('valhalla provider tracks through the configured server and stitches leg shapes', function () { + resetTrackingHttp(); + $provider = new ValhallaTrackingProvider(); + $context = osrmTrackingContext(); + + try { + // Unconfigured or public community servers never route production traffic + foreach ([null, 'https://valhalla1.openstreetmap.de'] as $host) { + app('config')->set('fleetops.valhalla.host', $host); + expect($provider->canTrack($context))->toBeFalse(); + } + + app('config')->set('fleetops.valhalla.host', 'https://valhalla.routing.fleetbase.test/'); + app('config')->set('fleetops.valhalla.api_key', 'test-valhalla-key'); + expect($provider->key())->toBe('valhalla') + ->and($provider->capabilities()->toArray())->toBe([ + 'traffic' => false, + 'per_leg_eta' => true, + 'map_matching' => false, + 'route_geometry' => true, + ]) + ->and($provider->canTrack($context))->toBeTrue(); + + $firstLeg = [[1.30, 103.80], [1.305, 103.805]]; + $secondLeg = [[1.305, 103.805], [1.31, 103.81]]; + $sent = null; + Http::fake(function ($request) use (&$sent, $firstLeg, $secondLeg) { + $sent = $request; + + return Http::response(['trip' => [ + 'status' => 0, + 'summary' => ['length' => 1.85, 'time' => 412.5], + 'legs' => [ + ['summary' => ['length' => 0.8, 'time' => 190.0], 'shape' => Polyline6::encode($firstLeg)], + ['summary' => ['length' => 1.05, 'time' => 222.5], 'shape' => Polyline6::encode($secondLeg)], + ], + ]]); + }); + + $result = $provider->track($context, new TrackingOptions()); + + expect((string) $sent->url())->toBe('https://valhalla.routing.fleetbase.test/route') + ->and($sent->header('X-Api-Key'))->toBe(['test-valhalla-key']) + ->and($sent->data()['locations'])->toBe([['lat' => 1.30, 'lon' => 103.80], ['lat' => 1.31, 'lon' => 103.81]]) + ->and($sent->data()['shape_format'])->toBe('polyline6') + ->and($result->provider)->toBe('valhalla') + ->and($result->distanceMeters)->toBe(1850.0) + ->and($result->durationSeconds)->toBe(412.5) + ->and($result->durationInTrafficSeconds)->toBeNull() + ->and($result->polyline)->toBe(Polyline::encode([...$firstLeg, ...$secondLeg])) + ->and($result->coordinates)->toHaveCount(4) + ->and($result->coordinates[3]->getLat())->toBe(103.81) + ->and($result->coordinates[3]->getLng())->toBe(1.31) + ->and($result->legs)->toBe([ + ['index' => 0, 'distance_m' => 800.0, 'duration_s' => 190.0, 'duration_in_traffic_s' => null, 'provider' => 'valhalla'], + ['index' => 1, 'distance_m' => 1050.0, 'duration_s' => 222.5, 'duration_in_traffic_s' => null, 'provider' => 'valhalla'], + ]) + ->and($result->warnings)->toBe(['no_live_traffic']); + + // A trip without leg shapes still reports distance and time + resetTrackingHttp(); + Http::fake(['*' => Http::response(['trip' => ['status' => 0, 'summary' => ['length' => 2.0, 'time' => 300.0], 'legs' => []]])]); + $bare = $provider->track($context, new TrackingOptions()); + expect($bare->polyline)->toBeNull() + ->and($bare->coordinates)->toBe([]) + ->and($bare->distanceMeters)->toBe(2000.0); + } finally { + app('config')->set('fleetops.valhalla.host', null); + app('config')->set('fleetops.valhalla.api_key', null); + } +}); diff --git a/server/tests/Unit/Support/DistanceMatrixProviderResolverTest.php b/server/tests/Unit/Support/DistanceMatrixProviderResolverTest.php index 337863c09..06b31e193 100644 --- a/server/tests/Unit/Support/DistanceMatrixProviderResolverTest.php +++ b/server/tests/Unit/Support/DistanceMatrixProviderResolverTest.php @@ -1,6 +1,7 @@ null, 'fleetops.osrm.host' => 'https://osrm.fleetbase.test', + 'fleetops.valhalla.host' => null, + 'fleetops.valhalla.api_key' => null, 'services.google_maps.api_key' => null, ]); @@ -105,6 +108,8 @@ function fleetopsDistanceProviderRouting(SQLiteConnection $connection, string $c config([ 'fleetops.distance_matrix.provider' => null, 'fleetops.osrm.host' => null, + 'fleetops.valhalla.host' => null, + 'fleetops.valhalla.api_key' => null, 'services.google_maps.api_key' => null, ]); app()->forgetInstance('log'); @@ -126,29 +131,155 @@ function fleetopsDistanceProviderRouting(SQLiteConnection $connection, string $c expect(DistanceMatrixProviderResolver::resolve('company-1'))->toBe('calculate'); }); -test('the company routing engine is used when the server can call it', function () { +test('the company routing engine is used when the server is configured for it', function () { $connection = fleetopsDistanceProviderBoot(); fleetopsDistanceProviderRouting($connection, 'company-osrm', ['router' => 'osrm', 'display_engine' => 'osrm']); - fleetopsDistanceProviderRouting($connection, 'company-google', ['display_engine' => 'google']); - fleetopsDistanceProviderRouting($connection, 'company-legacy', ['router' => 'google']); fleetopsDistanceProviderRouting($connection, 'company-valhalla', ['display_engine' => 'valhalla']); + fleetopsDistanceProviderRouting($connection, 'company-legacy', ['router' => 'valhalla']); + fleetopsDistanceProviderRouting($connection, 'company-vroom', ['display_engine' => 'vroom']); // A company that never saved routing settings uses OSRM, as the map does expect(DistanceMatrixProviderResolver::resolve('company-osrm'))->toBe('osrm') ->and(DistanceMatrixProviderResolver::resolve('company-unsaved'))->toBe('osrm') ->and(DistanceMatrixProviderResolver::resolve(null))->toBe('osrm'); - // Google needs a Maps API key on the server - expect(DistanceMatrixProviderResolver::resolve('company-google'))->toBe('calculate'); - config(['services.google_maps.api_key' => 'test-maps-key']); - expect(DistanceMatrixProviderResolver::resolve('company-google'))->toBe('google') - ->and(DistanceMatrixProviderResolver::resolve('company-legacy'))->toBe('google') + // Valhalla needs a configured server; until then the company falls through to private OSRM + expect(DistanceMatrixProviderResolver::resolve('company-valhalla'))->toBe('osrm'); + config(['fleetops.valhalla.host' => 'https://valhalla.routing.fleetbase.test']); + expect(DistanceMatrixProviderResolver::resolve('company-valhalla'))->toBe('valhalla') + ->and(DistanceMatrixProviderResolver::resolve('company-legacy'))->toBe('valhalla') + ->and(DistanceMatrixProviderResolver::resolve('company-osrm'))->toBe('osrm'); + + // Engines the server cannot call map to nothing + expect(DistanceMatrixProviderResolver::fromRoutingSettings('company-vroom'))->toBeNull() + ->and(DistanceMatrixProviderResolver::isAvailable('vroom'))->toBeFalse(); +}); + +test('google is never picked from company settings, only by the operator', function () { + $connection = fleetopsDistanceProviderBoot(); + fleetopsDistanceProviderRouting($connection, 'company-google', ['display_engine' => 'google']); + config(['services.google_maps.api_key' => 'test-maps-key', 'fleetops.osrm.host' => 'https://router.project-osrm.org']); + + expect(DistanceMatrixProviderResolver::fromRoutingSettings('company-google'))->toBeNull() + ->and(DistanceMatrixProviderResolver::isAvailable('google'))->toBeFalse() + ->and(DistanceMatrixProviderResolver::resolve('company-google'))->toBe('calculate') ->and(DistanceMatrixProviderResolver::googleMapsApiKey())->toBe('test-maps-key'); - // Engines the server cannot call fall back to the straight-line estimate - expect(DistanceMatrixProviderResolver::fromRoutingSettings('company-valhalla'))->toBeNull() - ->and(DistanceMatrixProviderResolver::resolve('company-valhalla'))->toBe('calculate') - ->and(DistanceMatrixProviderResolver::isAvailable('valhalla'))->toBeFalse(); + // With the platform's own Valhalla configured, the company gets road distance from it instead + config(['fleetops.valhalla.host' => 'https://valhalla.routing.fleetbase.test']); + expect(DistanceMatrixProviderResolver::resolve('company-google'))->toBe('valhalla'); + + config(['fleetops.distance_matrix.provider' => 'google']); + expect(DistanceMatrixProviderResolver::resolve('company-google'))->toBe('google'); +}); + +test('the platform valhalla server serves companies whose engine is not available', function () { + $connection = fleetopsDistanceProviderBoot(); + fleetopsDistanceProviderRouting($connection, 'company-osrm', ['display_engine' => 'osrm']); + config(['fleetops.osrm.host' => 'https://router.project-osrm.org']); + + expect(DistanceMatrixProviderResolver::resolve('company-osrm'))->toBe('calculate'); + + // Community servers do not count as configured + config(['fleetops.valhalla.host' => 'https://VALHALLA1.openstreetmap.de']); + expect(ValhallaClient::isConfigured())->toBeFalse() + ->and(DistanceMatrixProviderResolver::resolve('company-osrm'))->toBe('calculate'); + + config(['fleetops.valhalla.host' => 'https://valhalla.routing.fleetbase.test/']); + expect(ValhallaClient::isConfigured())->toBeTrue() + ->and(ValhallaClient::host())->toBe('https://valhalla.routing.fleetbase.test') + ->and(DistanceMatrixProviderResolver::resolve('company-osrm'))->toBe('valhalla') + ->and(DistanceMatrixProviderResolver::resolve(null))->toBe('valhalla'); +}); + +test('order distance from valhalla converts kilometres, sends the key and caches the result', function () { + $connection = fleetopsDistanceProviderBoot(); + fleetopsDistanceProviderRouting($connection, 'company-1', ['display_engine' => 'osrm']); + config([ + 'fleetops.osrm.host' => 'https://router.project-osrm.org', + 'fleetops.valhalla.host' => 'https://valhalla.routing.fleetbase.test', + 'fleetops.valhalla.api_key' => 'test-valhalla-key', + ]); + $redis = $GLOBALS['fleetopsDistanceProviderRedis']; + + $requests = []; + Http::fake(function ($request) use (&$requests) { + $requests[] = $request; + + return Http::response(['trip' => ['status' => 0, 'summary' => ['length' => 31.52, 'time' => 1610.0], 'legs' => []]]); + }); + + // Sunshine -> Melton, Victoria + $matrix = Utils::getDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58], ['company_uuid' => 'company-1']); + + expect($matrix->distance)->toBe(31520.0) + ->and($matrix->time)->toBe(1610.0) + ->and($requests)->toHaveCount(1) + ->and((string) $requests[0]->url())->toBe('https://valhalla.routing.fleetbase.test/route') + ->and($requests[0]->header('X-Api-Key'))->toBe(['test-valhalla-key']) + ->and($requests[0]->data())->toBe([ + 'locations' => [['lat' => -37.78, 'lon' => 144.83], ['lat' => -37.68, 'lon' => 144.58]], + 'costing' => 'auto', + 'units' => 'kilometers', + 'directions_type' => 'none', + 'shape_format' => 'polyline6', + ]) + ->and($redis->sets)->toHaveCount(1) + ->and($redis->sets[0][0])->toStartWith('valhalla:') + ->and(array_slice($redis->sets[0], 2))->toBe(['EX', Utils::DISTANCE_MATRIX_CACHE_TTL]); + + // The cached result answers the same pair without another request + $cached = Utils::getDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58], ['company_uuid' => 'company-1']); + expect($cached->distance)->toBe(31520.0) + ->and($requests)->toHaveCount(1); +}); + +test('valhalla failures fall back to the straight-line estimate', function () { + fleetopsDistanceProviderBoot(); + config(['fleetops.valhalla.host' => 'https://valhalla.routing.fleetbase.test']); + $log = $GLOBALS['fleetopsDistanceProviderLog']; + $redis = $GLOBALS['fleetopsDistanceProviderRedis']; + $estimate = Utils::calculateDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58]); + + $responses = [ + Http::response(['error_code' => 442, 'error' => 'No path could be found for input'], 400), + Http::response(['trip' => ['status' => 442]]), + Http::response(['trip' => ['status' => 0, 'summary' => ['length' => 0, 'time' => 0]]]), + ]; + $headers = []; + Http::fake(function ($request) use (&$responses, &$headers) { + $headers[] = $request->header('X-Api-Key'); + + return array_shift($responses); + }); + + foreach (range(1, 3) as $attempt) { + $matrix = Utils::getDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58], ['provider' => 'valhalla']); + expect($matrix->distance)->toBe($estimate->distance) + ->and($matrix->time)->toBe($estimate->time); + } + + expect($log->warnings)->toHaveCount(3) + ->and($log->warnings[0][1]['provider'])->toBe('valhalla') + ->and($log->warnings[0][1]['error'])->toBe('Valhalla request failed with status 400: No path could be found for input') + ->and($log->warnings[1][1]['error'])->toBe('Valhalla returned no route.') + ->and($log->warnings[2][1]['error'])->toBe('no route returned') + // No key configured, so none is sent + ->and($headers[0])->toBe([]) + ->and($redis->sets)->toBe([]); +}); + +test('an unsupported provider name falls back to the straight-line estimate', function () { + fleetopsDistanceProviderBoot(); + $log = $GLOBALS['fleetopsDistanceProviderLog']; + Http::fake(['*' => Http::response([], 500)]); + + config(['fleetops.distance_matrix.provider' => 'valhala']); + $estimate = Utils::calculateDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58]); + $matrix = Utils::getDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58]); + + expect($matrix->distance)->toBe($estimate->distance) + ->and($log->warnings[0][1]['error'])->toBe('Unsupported distance matrix provider: valhala'); }); test('company OSRM selection never sends server traffic to the public demo server', function () { From 62282b89f888c5b012444f1148dda599c2175e18 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 24 Oct 2026 11:51:28 +0800 Subject: [PATCH 13/22] feat(routing): admin provider policy with system defaults and organization exceptions Admin -> Fleet-Ops -> Routing now controls which routing providers (Google, Valhalla, OSRM, VROOM) organizations may use, the platform's default routing and optimization engines, and per-organization exceptions. Google is off by default. The policy is enforced for order distance, tracking intelligence, organization settings, the orchestrator and the console's route engine and optimization services. Valhalla reads the extension's admin and organization settings before the environment. Rename the admin menu to Fleet-Ops. Co-Authored-By: Claude --- addon/components/admin/routing-settings.hbs | 92 +++++++- addon/components/admin/routing-settings.js | 101 ++++++++- addon/controllers/settings/routing.js | 5 +- addon/extension.js | 2 +- addon/services/route-engine.js | 19 +- addon/services/route-optimization.js | 26 ++- addon/utils/routing-providers.js | 21 ++ app/utils/routing-providers.js | 1 + server/config/fleetops.php | 31 ++- .../Internal/v1/OrchestrationController.php | 6 +- .../Internal/v1/SettingController.php | 96 +++++++-- .../Support/OrchestratorSettings.php | 8 +- .../DistanceMatrixProviderResolver.php | 34 +-- .../Support/Routing/RoutingProviderPolicy.php | 174 ++++++++++++++++ server/src/Support/Routing/ValhallaClient.php | 71 +++++-- server/src/Support/Utils.php | 22 +- .../GoogleRoutesTrackingProvider.php | 10 +- .../Providers/ValhallaTrackingProvider.php | 4 +- .../src/Tracking/TrackingProviderManager.php | 7 + server/src/routes.php | 2 + .../tests/SettingControllerContractsTest.php | 102 ++++++++- server/tests/TrackingIntelligenceTest.php | 29 ++- .../DistanceMatrixProviderResolverTest.php | 196 +++++++++++++++++- .../Unit/Support/UtilsAdditionalTest.php | 5 + .../Support/UtilsGeoMatrixAndVendorTest.php | 3 + .../components/admin/routing-settings-test.js | 93 +++++++++ tests/unit/services/route-engine-test.js | 47 ++++- .../unit/services/route-optimization-test.js | 60 +++++- tests/unit/utils/routing-providers-test.js | 17 ++ 29 files changed, 1168 insertions(+), 116 deletions(-) create mode 100644 addon/utils/routing-providers.js create mode 100644 app/utils/routing-providers.js create mode 100644 server/src/Support/Routing/RoutingProviderPolicy.php create mode 100644 tests/integration/components/admin/routing-settings-test.js create mode 100644 tests/unit/utils/routing-providers-test.js diff --git a/addon/components/admin/routing-settings.hbs b/addon/components/admin/routing-settings.hbs index 7ee7af45f..125183d28 100644 --- a/addon/components/admin/routing-settings.hbs +++ b/addon/components/admin/routing-settings.hbs @@ -1,8 +1,96 @@
- Configure system-wide routing engine defaults here. Organizations can optionally override these values from their own routing settings. + Configure system-wide routing engine defaults here. Organizations can optionally override these values from their own routing settings, but only with providers that are switched on + below. + +

+ Choose which providers organizations may use for map routing, route optimization, order distance and tracking. A provider switched off here is never called, even if an + organization selected it; they fall back to the defaults below. Changes apply on every server within 30 seconds. +

+
+ {{#each this.providerAccessRows as |provider|}} +
+
+
{{provider.label}}
+ {{#if provider.billed}} +
Billed per request to the platform's Google Maps key.
+ {{/if}} +
+ +
+ {{/each}} +
+
+ + + + + + + + +

+ Switch a provider on for specific organizations only, for example Google for a customer who pays for it. +

+
+ {{#each this.providerExceptionRows as |row|}} +
+
{{row.exception.company_name}}
+
+ + {{provider.label}} + +
+
+ {{else}} +
No exceptions. Every organization follows Provider Access.
+ {{/each}} +
+ + + {{company.name}} + + +
+ - + + @@ -91,34 +108,10 @@ - - - - - -
- - {{provider.label}} - -
-
+

+ Live ETAs, remaining distance and route lines use the routing service selected above, the same one that measures each order's distance and time. These settings only tune + tracking behaviour. +

- + \ No newline at end of file diff --git a/server/config/fleetops.php b/server/config/fleetops.php index 589f4b9f6..14886f933 100644 --- a/server/config/fleetops.php +++ b/server/config/fleetops.php @@ -42,19 +42,28 @@ /* |-------------------------------------------------------------------------- - | Routing Providers + | Routing |-------------------------------------------------------------------------- | - | Starting values for Admin -> Fleet-Ops -> Routing, which overrides them - | once saved. Google is billed per request, so organizations cannot use it - | for routing, order distance or tracking unless it is switched on here or - | in the admin panel (globally or for named organizations). + | Starting values for Admin -> Fleet-Ops -> Routing. Once an administrator + | saves that page these are no longer read. + | + | The routing engine answers every route, distance and time question: map + | route lines, an order's stored distance and time, and live ETAs while it + | is in progress. Google is billed per request, so organizations cannot use + | it unless it is switched on (globally or for named organizations). + | ROUTING_SERVER_ROUTING=straight_line measures orders and ETAs with the + | straight-line estimate instead of calling any engine. + | + | DISTANCE_MATRIX_PROVIDER is still honoured as a starting value for + | installs that set it before these existed. | */ 'routing' => [ - 'google_enabled' => env('ROUTING_GOOGLE_ENABLED', false), - 'display_engine' => env('ROUTING_DISPLAY_ENGINE', 'osrm'), - 'optimization_engine' => env('ROUTING_OPTIMIZATION_ENGINE', 'osrm'), + 'google_enabled' => env('ROUTING_GOOGLE_ENABLED', env('DISTANCE_MATRIX_PROVIDER') === 'google'), + 'display_engine' => env('ROUTING_DISPLAY_ENGINE', in_array(env('DISTANCE_MATRIX_PROVIDER'), ['osrm', 'valhalla', 'google'], true) ? env('DISTANCE_MATRIX_PROVIDER') : 'osrm'), + 'optimization_engine' => env('ROUTING_OPTIMIZATION_ENGINE'), + 'server_routing' => env('ROUTING_SERVER_ROUTING', env('DISTANCE_MATRIX_PROVIDER') === 'calculate' ? 'straight_line' : 'road'), ], /* @@ -97,17 +106,12 @@ /* |-------------------------------------------------------------------------- - | Distance Matrix Calculator + | Service Quote Distance |-------------------------------------------------------------------------- | - | Options: "calculate", "valhalla", "osrm", "google" - | - | Leave DISTANCE_MATRIX_PROVIDER unset to measure order distance and time - | with a self-hosted engine: the company's routing engine when the server - | is configured for it, otherwise VALHALLA_HOST, then a private OSRM_HOST, - | otherwise the straight-line "calculate" estimate. Google is only used - | when this is set to "google", because it is billed per request. Setting - | it applies one provider to every company. + | Provider for the multi-stop distance used when pricing service quotes: + | "calculate" (default), "osrm" or "google". Order distance and time do + | not use this; they follow the routing engine (see Routing above). | */ 'distance_matrix' => [ @@ -119,18 +123,12 @@ | Order Tracking Intelligence |-------------------------------------------------------------------------- | - | Provider-neutral tracking data for active orders. The provider registry - | can be extended by third-party packages without editing FleetOps core. - | - | Built-in providers: "google_routes", "valhalla", "osrm", "calculated" - | - | Providers switched off under Routing Providers are skipped, whatever an - | organization selects, and tracking falls through to the next provider. + | Live ETAs, remaining distance and route lines for active orders. They + | come from the organization's routing engine (see Routing above) and fall + | back to the straight-line estimate; these values only tune behaviour. |-------------------------------------------------------------------------- */ 'tracking' => [ - 'provider' => env('TRACKING_PROVIDER', 'valhalla'), - 'fallbacks' => array_filter(explode(',', env('TRACKING_PROVIDER_FALLBACKS', 'osrm,calculated'))), 'traffic_enabled' => env('TRACKING_TRAFFIC_ENABLED', true), 'cache_ttl_seconds' => env('TRACKING_CACHE_TTL_SECONDS', 60), 'route_cache_ttl_seconds' => env('TRACKING_ROUTE_CACHE_TTL_SECONDS', 600), diff --git a/server/src/Http/Controllers/Internal/v1/SettingController.php b/server/src/Http/Controllers/Internal/v1/SettingController.php index c8c9a3334..eb7fccf74 100644 --- a/server/src/Http/Controllers/Internal/v1/SettingController.php +++ b/server/src/Http/Controllers/Internal/v1/SettingController.php @@ -4,12 +4,12 @@ use Fleetbase\FleetOps\Jobs\DispatchTelematicsRetentionJobs; use Fleetbase\FleetOps\Orchestration\Support\OrchestratorSettings; +use Fleetbase\FleetOps\Support\Routing\RoutingEngineResolver; use Fleetbase\FleetOps\Support\Routing\RoutingProviderPolicy; use Fleetbase\FleetOps\Support\Telematics\Retention\RetentionPolicy; use Fleetbase\FleetOps\Support\Telematics\Telemetry\Queue; use Fleetbase\FleetOps\Support\TrackingPage\TrackingPageConfig; use Fleetbase\FleetOps\Support\Utils; -use Fleetbase\FleetOps\Tracking\TrackingProviderRegistry; use Fleetbase\FleetOps\Traits\AuthorizesMethods; use Fleetbase\Http\Controllers\Controller; use Fleetbase\Models\Setting; @@ -317,21 +317,10 @@ public function getRoutingSettings() */ public function saveTrackingSettings(Request $request) { - $config = $this->trackingDefaults(); - $fallbacks = $request->input('fallbacks', data_get($config, 'fallbacks', ['osrm', 'calculated'])); - if (is_string($fallbacks)) { - $fallbacks = array_values(array_filter(array_map('trim', explode(',', $fallbacks)))); - } - - $companyUuid = $this->policyCompanyUuid(); - $provider = $request->input('provider', data_get($config, 'provider', 'valhalla')); - if (!RoutingProviderPolicy::allows($provider, $companyUuid)) { - return response()->error($this->providerDisabledMessage($provider), 422); - } + // The provider is the organization's routing engine; only tracking behaviour is saved here + $config = $this->trackingDefaults(); $this->configureCompanySetting('tracking', [ - 'provider' => $provider, - 'fallbacks' => array_values(array_filter($fallbacks, fn ($fallback) => RoutingProviderPolicy::allows($fallback, $companyUuid))), 'traffic_enabled' => $request->boolean('traffic_enabled', data_get($config, 'traffic_enabled', true)), 'cache_ttl_seconds' => (int) $request->input('cache_ttl_seconds', data_get($config, 'cache_ttl_seconds', 60)), 'route_cache_ttl_seconds' => (int) $request->input('route_cache_ttl_seconds', data_get($config, 'route_cache_ttl_seconds', 600)), @@ -355,8 +344,6 @@ public function getTrackingSettings() { $config = $this->trackingDefaults(); $trackingSettings = $this->lookupCompanySetting('tracking', [ - 'provider' => data_get($config, 'provider', 'valhalla'), - 'fallbacks' => data_get($config, 'fallbacks', ['osrm', 'calculated']), 'traffic_enabled' => data_get($config, 'traffic_enabled', true), 'cache_ttl_seconds' => data_get($config, 'cache_ttl_seconds', 60), 'route_cache_ttl_seconds' => data_get($config, 'route_cache_ttl_seconds', 600), @@ -364,8 +351,10 @@ public function getTrackingSettings() 'default_vehicle_speed_kph' => data_get($config, 'default_vehicle_speed_kph', 35), 'alerts' => data_get($config, 'alerts', $this->trackingAlertDefaults()), ]); - $trackingSettings['alerts'] = $this->normalizeTrackingAlertSettings(data_get($trackingSettings, 'alerts', [])); - $trackingSettings['providers'] = $this->trackingProviderOptions($this->policyCompanyUuid()); + // Provider choices saved before tracking followed the routing engine are no longer used + unset($trackingSettings['provider'], $trackingSettings['fallbacks']); + $trackingSettings['alerts'] = $this->normalizeTrackingAlertSettings(data_get($trackingSettings, 'alerts', [])); + $trackingSettings['routing_engine'] = RoutingEngineResolver::resolve($this->policyCompanyUuid()); return response()->json($trackingSettings); } @@ -391,29 +380,21 @@ public function getAdminRoutingProviders() */ public function saveAdminRoutingProviders(Request $request) { - $this->saveRoutingProviderPolicy($request->only(['providers', 'defaults', 'exceptions'])); + $this->saveRoutingProviderPolicy($request->only(['providers', 'defaults', 'server_routing', 'exceptions'])); return $this->getAdminRoutingProviders(); } public function getAdminTrackingSettings() { - return response()->json(array_merge($this->trackingDefaults(), [ - 'providers' => $this->trackingProviderOptions(), - ])); + return response()->json($this->trackingDefaults()); } public function saveAdminTrackingSettings(Request $request) { - $config = config('fleetops.tracking', []); - $fallbacks = $request->input('fallbacks', data_get($config, 'fallbacks', ['osrm', 'calculated'])); - if (is_string($fallbacks)) { - $fallbacks = array_values(array_filter(array_map('trim', explode(',', $fallbacks)))); - } + $config = config('fleetops.tracking', []); $this->configureSetting('fleet-ops.tracking-settings', [ - 'provider' => $request->input('provider', data_get($config, 'provider', 'valhalla')), - 'fallbacks' => $fallbacks, 'traffic_enabled' => $request->boolean('traffic_enabled', data_get($config, 'traffic_enabled', true)), 'cache_ttl_seconds' => (int) $request->input('cache_ttl_seconds', data_get($config, 'cache_ttl_seconds', 60)), 'route_cache_ttl_seconds' => (int) $request->input('route_cache_ttl_seconds', data_get($config, 'route_cache_ttl_seconds', 600)), @@ -777,7 +758,8 @@ protected function trackingDefaults(): array $config = config('fleetops.tracking', []); $systemSettings = $this->lookupSetting('fleet-ops.tracking-settings', []); - $defaults = array_merge($config, is_array($systemSettings) ? $systemSettings : []); + $defaults = array_merge($config, is_array($systemSettings) ? $systemSettings : []); + unset($defaults['provider'], $defaults['fallbacks']); $defaults['alerts'] = $this->normalizeTrackingAlertSettings(data_get($defaults, 'alerts', [])); return $defaults; @@ -821,29 +803,6 @@ protected function normalizeTrackingAlertSettings(array $alerts): array ]; } - /** - * Tracking providers as select options; given an organization, only the ones it may use. - */ - protected function trackingProviderOptions(?string $companyUuid = null): array - { - $providers = collect($this->trackingProviders()); - if ($companyUuid) { - $providers = $providers->filter(fn ($provider, $key) => RoutingProviderPolicy::allows($key, $companyUuid)); - } - - return $providers->map(function ($provider, $key) { - $label = $key === 'osrm' ? 'OSRM' : str($key)->replace('_', ' ')->title()->toString(); - - return [ - 'key' => $key, - 'name' => $label, - 'value' => $key, - 'label' => $label, - 'capabilities' => $provider->capabilities()->toArray(), - ]; - })->values()->all(); - } - /** * Retrieve customer history preferences for the current company. * @@ -1489,11 +1448,6 @@ protected function providerDisabledMessage(string $provider): string return 'The ' . $provider . ' provider has been switched off by the administrator.'; } - protected function trackingProviders(): array - { - return app(TrackingProviderRegistry::class)->all(); - } - protected function googleMapsApiKey(): string { return config('services.google_maps.api_key', env('GOOGLE_MAPS_API_KEY', '')); diff --git a/server/src/Support/Routing/DistanceMatrixProviderResolver.php b/server/src/Support/Routing/DistanceMatrixProviderResolver.php deleted file mode 100644 index 28ebfa3ea..000000000 --- a/server/src/Support/Routing/DistanceMatrixProviderResolver.php +++ /dev/null @@ -1,109 +0,0 @@ - 'osrm', - 'valhalla' => 'valhalla', - 'google' => 'google', - default => null, - }; - } - - public static function isAvailable(string $provider, ?string $companyUuid = null): bool - { - if (!RoutingProviderPolicy::allows($provider, $companyUuid)) { - return false; - } - - return match ($provider) { - 'osrm' => static::hasPrivateOsrmHost(), - 'valhalla' => ValhallaClient::isConfigured($companyUuid), - 'google' => filled(static::googleMapsApiKey()), - default => false, - }; - } - - public static function hasPrivateOsrmHost(): bool - { - $host = parse_url((string) config('fleetops.osrm.host'), PHP_URL_HOST); - - return filled($host) && strcasecmp($host, static::PUBLIC_OSRM_HOST) !== 0; - } - - public static function googleMapsApiKey(): ?string - { - $configuredKey = config('services.google_maps.api_key'); - if ($configuredKey) { - return $configuredKey; - } - - return class_exists(\PhpOption\Option::class) ? env('GOOGLE_MAPS_API_KEY') : null; - } -} diff --git a/server/src/Support/Routing/RoutingEngineResolver.php b/server/src/Support/Routing/RoutingEngineResolver.php new file mode 100644 index 000000000..a4855b08a --- /dev/null +++ b/server/src/Support/Routing/RoutingEngineResolver.php @@ -0,0 +1,110 @@ + 'google_routes', 'calculate' => 'calculated']; + + public static function resolve(?string $companyUuid = null): string + { + if (RoutingProviderPolicy::usesStraightLine()) { + return 'calculate'; + } + + foreach ([static::companyEngine($companyUuid), RoutingProviderPolicy::defaultDisplayEngine(), ...static::PLATFORM_ENGINES] as $engine) { + if ($engine && static::isAvailable($engine, $companyUuid)) { + return $engine; + } + } + + return 'calculate'; + } + + /** + * The tracking provider that serves the organization's routing engine. + */ + public static function trackingProvider(?string $companyUuid = null): string + { + $engine = static::resolve($companyUuid); + + return static::TRACKING_PROVIDERS[$engine] ?? $engine; + } + + /** + * The routing engine the organization chose, or null when it never chose one. + */ + public static function companyEngine(?string $companyUuid = null): ?string + { + try { + $settings = Setting::lookupForCompany($companyUuid, 'routing', []); + } catch (\Throwable) { + $settings = []; + } + + return data_get($settings, 'display_engine', data_get($settings, 'routing_display_engine', data_get($settings, 'router'))); + } + + public static function isAvailable(string $engine, ?string $companyUuid = null): bool + { + if (!RoutingProviderPolicy::allows($engine, $companyUuid)) { + return false; + } + + return match ($engine) { + 'osrm' => static::hasPrivateOsrmHost(), + 'valhalla' => ValhallaClient::isConfigured($companyUuid), + 'google' => filled(static::googleMapsApiKey()), + default => false, + }; + } + + public static function hasPrivateOsrmHost(): bool + { + $host = parse_url((string) config('fleetops.osrm.host'), PHP_URL_HOST); + + return filled($host) && strcasecmp($host, static::PUBLIC_OSRM_HOST) !== 0; + } + + public static function googleMapsApiKey(): ?string + { + $configuredKey = config('services.google_maps.api_key'); + if ($configuredKey) { + return $configuredKey; + } + + return class_exists(\PhpOption\Option::class) ? env('GOOGLE_MAPS_API_KEY') : null; + } +} diff --git a/server/src/Support/Routing/RoutingProviderPolicy.php b/server/src/Support/Routing/RoutingProviderPolicy.php index 4467ce959..9a49c2c74 100644 --- a/server/src/Support/Routing/RoutingProviderPolicy.php +++ b/server/src/Support/Routing/RoutingProviderPolicy.php @@ -11,11 +11,15 @@ * Managed from Admin → Fleet-Ops → Routing and stored as one system setting: * - `providers`: whether each provider is available to every organization. * Google is billed per request and is off unless switched on. - * - `defaults`: the route display and optimization engines organizations get - * until they choose their own. + * - `defaults`: the routing engine and optimization engine organizations get + * until they choose their own. The routing engine answers every route, + * distance and time question: map lines, order distance and live ETAs. + * - `server_routing`: `road` to measure orders and ETAs with the routing engine, + * or `straight_line` to stop every server-side routing call at once. * - `exceptions`: providers switched on for named organizations only. * - * It is enforced on the server for order distance, tracking intelligence and + * Environment variables only provide the starting values; once saved here the + * admin panel wins. It is enforced on the server for order distance, tracking intelligence and * organization settings, and handed to the console so disabled engines are * neither offered nor used for map routing. The straight-line `calculate` and * `calculated` providers are always allowed so every caller has a fallback. @@ -46,6 +50,8 @@ class RoutingProviderPolicy public const ALWAYS_ALLOWED = ['calculate', 'calculated']; + public const SERVER_ROUTING_MODES = ['road', 'straight_line']; + public static function settings(): array { $settings = Cache::remember(static::CACHE_KEY, static::CACHE_TTL, fn () => static::normalize(static::stored())); @@ -85,13 +91,18 @@ public static function normalize(array $settings): array ->values() ->all(); + $displayEngine = (string) (data_get($settings, 'defaults.display_engine') ?: config('fleetops.routing.display_engine') ?: 'osrm'); + $serverRouting = (string) data_get($settings, 'server_routing', config('fleetops.routing.server_routing', 'road')); + return [ 'providers' => $providers, 'defaults' => [ - 'display_engine' => (string) data_get($settings, 'defaults.display_engine', config('fleetops.routing.display_engine', 'osrm')), - 'optimization_engine' => (string) data_get($settings, 'defaults.optimization_engine', config('fleetops.routing.optimization_engine', 'osrm')), + 'display_engine' => $displayEngine, + // Optimization follows the routing engine unless set on its own + 'optimization_engine' => (string) (data_get($settings, 'defaults.optimization_engine') ?: config('fleetops.routing.optimization_engine') ?: $displayEngine), ], - 'exceptions' => $exceptions, + 'server_routing' => in_array($serverRouting, static::SERVER_ROUTING_MODES, true) ? $serverRouting : 'road', + 'exceptions' => $exceptions, ]; } @@ -136,6 +147,15 @@ public static function allowedProviders(?string $companyUuid = null): array return array_values(array_filter(array_keys(static::PROVIDERS), fn ($provider) => static::allows($provider, $companyUuid))); } + /** + * Whether the administrator switched server-side routing off, so order distance + * and ETAs use the straight-line estimate without calling any routing engine. + */ + public static function usesStraightLine(): bool + { + return static::settings()['server_routing'] === 'straight_line'; + } + public static function defaultDisplayEngine(): string { return static::settings()['defaults']['display_engine']; diff --git a/server/src/Support/Utils.php b/server/src/Support/Utils.php index af8e2e456..3d60aff13 100644 --- a/server/src/Support/Utils.php +++ b/server/src/Support/Utils.php @@ -3,7 +3,7 @@ namespace Fleetbase\FleetOps\Support; use Fleetbase\FleetOps\Flow\Activity; -use Fleetbase\FleetOps\Support\Routing\DistanceMatrixProviderResolver; +use Fleetbase\FleetOps\Support\Routing\RoutingEngineResolver; use Fleetbase\FleetOps\Support\Routing\RoutingProviderPolicy; use Fleetbase\FleetOps\Support\Routing\ValhallaClient; use Fleetbase\LaravelMysqlSpatial\Types\Point; @@ -801,7 +801,7 @@ public static function rawPointToPoint(string $data): Point * This function attempts to find the driving distance and time from the origin to the destination * using various map APIs. It first attempts to resolve the origin and destination from various input types * into geographical coordinates. The provider is `$options['provider']` when given, otherwise the one - * DistanceMatrixProviderResolver picks for `$options['company_uuid']` (or the session company). When a road + * RoutingEngineResolver picks for `$options['company_uuid']` (or the session company). When a road * provider fails or returns no route the straight-line estimate is returned instead, so the result is never * zero for distinct points. The result is a DistanceMatrix with the distance in meters and the time in seconds. * @@ -820,7 +820,7 @@ public static function getDrivingDistanceAndTime($origin, $destination, $options $destinations = $destination->getLat() . ',' . $destination->getLng(); $companyUuid = data_get($options, 'company_uuid', session('company')); - $provider = data_get($options, 'provider') ?: DistanceMatrixProviderResolver::resolve($companyUuid); + $provider = data_get($options, 'provider') ?: RoutingEngineResolver::resolve($companyUuid); $estimate = static::calculateDrivingDistanceAndTime($origins, $destinations); // Nothing to route between identical points, and `calculate` is the estimate itself @@ -974,7 +974,7 @@ public static function getDistanceMatrixFromGoogle(string $origins, string $dest 'origins' => $origins, 'destinations' => $destinations, 'mode' => data_get($options, 'mode', 'driving'), - 'key' => DistanceMatrixProviderResolver::googleMapsApiKey(), + 'key' => RoutingEngineResolver::googleMapsApiKey(), ] )->json(); diff --git a/server/src/Tracking/TrackingOptions.php b/server/src/Tracking/TrackingOptions.php index 66cca97ac..5a4fc2c0a 100644 --- a/server/src/Tracking/TrackingOptions.php +++ b/server/src/Tracking/TrackingOptions.php @@ -2,6 +2,7 @@ namespace Fleetbase\FleetOps\Tracking; +use Fleetbase\FleetOps\Support\Routing\RoutingEngineResolver; use Fleetbase\Models\Setting; use Illuminate\Support\Arr; @@ -27,21 +28,24 @@ public static function fromArray(array $options = [], ?string $companyUuid = nul $systemSettings = []; } + $companyUuid = $companyUuid ?? session('company'); $config = array_merge(config('fleetops.tracking', []), is_array($systemSettings) ? $systemSettings : []); $companySettings = []; try { - $companySettings = Setting::lookupForCompany($companyUuid ?? session('company'), 'tracking', []); + $companySettings = Setting::lookupForCompany($companyUuid, 'tracking', []); } catch (\Throwable) { $companySettings = []; } - $fallbacks = Arr::get($options, 'fallbacks', Arr::get($companySettings, 'fallbacks', Arr::get($config, 'fallbacks', []))); + // An explicitly requested provider (API callers) is still subject to the routing policy + $fallbacks = Arr::get($options, 'fallbacks', []); if (is_string($fallbacks)) { $fallbacks = array_values(array_filter(array_map('trim', explode(',', $fallbacks)))); } return new self( - provider: Arr::get($options, 'provider', Arr::get($companySettings, 'provider', Arr::get($config, 'provider'))), + // Tracking uses the organization's routing engine, the same one that measured the order + provider: Arr::get($options, 'provider') ?: RoutingEngineResolver::trackingProvider($companyUuid), fallbacks: Arr::wrap($fallbacks), trafficEnabled: (bool) Arr::get($options, 'traffic_enabled', Arr::get($companySettings, 'traffic_enabled', Arr::get($config, 'traffic_enabled', true))), cacheTtlSeconds: (int) Arr::get($options, 'cache_ttl_seconds', Arr::get($companySettings, 'cache_ttl_seconds', Arr::get($config, 'cache_ttl_seconds', 60))), diff --git a/server/src/Tracking/TrackingProviderManager.php b/server/src/Tracking/TrackingProviderManager.php index 1ddbdee79..9a137b619 100644 --- a/server/src/Tracking/TrackingProviderManager.php +++ b/server/src/Tracking/TrackingProviderManager.php @@ -4,7 +4,6 @@ use Fleetbase\FleetOps\Support\Routing\RoutingProviderPolicy; use Fleetbase\FleetOps\Tracking\Contracts\TrackingProviderInterface; -use Illuminate\Support\Arr; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; @@ -63,10 +62,8 @@ public function track(TrackingContext $context, TrackingOptions $options): Track protected function providerOrder(TrackingOptions $options): array { - $provider = $options->provider ?: Arr::get(config('fleetops.tracking', []), 'provider', 'google_routes'); - $fallbacks = $options->fallbacks ?: Arr::get(config('fleetops.tracking', []), 'fallbacks', ['osrm', 'calculated']); - - return collect([$provider, ...$fallbacks]) + // The straight-line estimate always closes the chain so every order gets an answer + return collect([$options->provider, ...$options->fallbacks, 'calculated']) ->filter() ->map(fn ($key) => TrackingProviderRegistry::normalizeKey($key)) ->unique() diff --git a/server/tests/Feature/Http/Internal/SettingControllerHelpersTest.php b/server/tests/Feature/Http/Internal/SettingControllerHelpersTest.php index ccd6651ea..c1a4006ed 100644 --- a/server/tests/Feature/Http/Internal/SettingControllerHelpersTest.php +++ b/server/tests/Feature/Http/Internal/SettingControllerHelpersTest.php @@ -116,7 +116,7 @@ public function __call($method, $arguments) expect($helper('googleMapsApiKey'))->toBe('maps-key-1'); }); -test('setting helpers read notification and tracking registries', function () { +test('setting helpers read the notification registries', function () { fleetopsSettingHelpersBoot(); $controller = new SettingController(); @@ -128,8 +128,7 @@ public function __call($method, $arguments) }; expect($helper('notificationNotifiables'))->toBeArray() - ->and($helper('notificationsByPackage', 'fleet-ops'))->toBeArray() - ->and($helper('trackingProviders'))->toBeArray(); + ->and($helper('notificationsByPackage', 'fleet-ops'))->toBeArray(); }); test('history policy reconciliation clamps every organization and preserves inherited and internal settings', function () { diff --git a/server/tests/SettingControllerContractsTest.php b/server/tests/SettingControllerContractsTest.php index 55dadfe02..2000376e7 100644 --- a/server/tests/SettingControllerContractsTest.php +++ b/server/tests/SettingControllerContractsTest.php @@ -17,7 +17,6 @@ class FleetOpsSettingControllerProbe extends SettingController public mixed $company = null; public array $notifiables = []; public array $notifications = []; - public array $providers = []; public string $googleMapsApiKey = ''; public array $reconciledTelematicsDefaults = []; public ?string $policyCompany = 'company-uuid'; @@ -102,11 +101,6 @@ protected function notificationsByPackage(string $package): array return $this->notifications[$package] ?? []; } - protected function trackingProviders(): array - { - return $this->providers; - } - protected function googleMapsApiKey(): string { return $this->googleMapsApiKey; @@ -152,27 +146,6 @@ protected function dispatchTelematicsPrune(): void } } -class FleetOpsTrackingProviderOptionFake -{ - public function __construct(private array $capabilities) - { - } - - public function capabilities(): object - { - return new class($this->capabilities) { - public function __construct(private array $capabilities) - { - } - - public function toArray(): array - { - return $this->capabilities; - } - }; - } -} - function fleetopsJsonPayload(mixed $response): array { return $response->getData(true); @@ -443,6 +416,7 @@ function fleetopsMapCenterFor(mixed $company, FleetOpsMapCenterRedisFake $redis) $policy = fleetopsJsonPayload($controller->getAdminRoutingProviders()); expect($policy['providers'])->toBe(['google' => false, 'valhalla' => true, 'osrm' => true, 'vroom' => true]) ->and($policy['defaults'])->toBe(['display_engine' => 'osrm', 'optimization_engine' => 'osrm']) + ->and($policy['server_routing'])->toBe('road') ->and($policy['exceptions'])->toBe([]) ->and($policy['available_providers'])->toBe([ ['key' => 'google', 'label' => 'Google'], @@ -452,34 +426,34 @@ function fleetopsMapCenterFor(mixed $company, FleetOpsMapCenterRedisFake $redis) ]); $controller->saveAdminRoutingProviders(new Request([ - 'providers' => ['google' => true], - 'defaults' => ['display_engine' => 'valhalla'], - 'exceptions' => [['company_uuid' => 'company-1', 'providers' => ['google']]], - 'ignored' => 'value', + 'providers' => ['google' => true], + 'defaults' => ['display_engine' => 'valhalla'], + 'server_routing' => 'straight_line', + 'exceptions' => [['company_uuid' => 'company-1', 'providers' => ['google']]], + 'ignored' => 'value', ])); expect($controller->savedRoutingPolicies)->toBe([[ - 'providers' => ['google' => true], - 'defaults' => ['display_engine' => 'valhalla'], - 'exceptions' => [['company_uuid' => 'company-1', 'providers' => ['google']]], + 'providers' => ['google' => true], + 'defaults' => ['display_engine' => 'valhalla'], + 'server_routing' => 'straight_line', + 'exceptions' => [['company_uuid' => 'company-1', 'providers' => ['google']]], ]]); }); -test('setting controller normalizes tracking settings and provider options', function () { +test('setting controller saves tracking behaviour while the provider follows the routing engine', function () { config()->set('fleetops.tracking', [ - 'provider' => 'osrm', - 'fallbacks' => ['calculated'], 'traffic_enabled' => false, 'cache_ttl_seconds' => 30, 'route_cache_ttl_seconds' => 120, 'default_vehicle_speed_kph' => 25, ]); + config()->set('fleetops.osrm.host', null); + config()->set('fleetops.valhalla.host', null); + Cache::forget(RoutingProviderPolicy::CACHE_KEY); - $controller = new FleetOpsSettingControllerProbe(); - $controller->providers = [ - 'osrm' => new FleetOpsTrackingProviderOptionFake(['routes']), - 'google_routes' => new FleetOpsTrackingProviderOptionFake(['traffic', 'eta']), - ]; + $controller = new FleetOpsSettingControllerProbe(); + // Saved before tracking followed the routing engine $controller->settings['fleet-ops.tracking-settings'] = [ 'provider' => 'google_routes', 'fallbacks' => 'osrm, calculated', @@ -491,8 +465,8 @@ function fleetopsMapCenterFor(mixed $company, FleetOpsMapCenterRedisFake $redis) ]; $saved = fleetopsJsonPayload($controller->saveTrackingSettings(new Request([ - 'provider' => 'osrm', - 'fallbacks' => 'google_routes, calculated', + 'provider' => 'google_routes', + 'fallbacks' => 'osrm, calculated', 'traffic_enabled' => '1', 'cache_ttl_seconds' => '75', 'route_cache_ttl_seconds' => '900', @@ -503,11 +477,10 @@ function fleetopsMapCenterFor(mixed $company, FleetOpsMapCenterRedisFake $redis) ], ]))); + // A provider in the request is ignored rather than refused expect($saved)->toMatchArray(['status' => 'ok']) + ->and($controller->configuredCompany['tracking'])->not->toHaveKeys(['provider', 'fallbacks']) ->and($controller->configuredCompany['tracking'])->toMatchArray([ - 'provider' => 'osrm', - // Google Routes is switched off, so it is dropped from the fallbacks - 'fallbacks' => ['calculated'], 'traffic_enabled' => true, 'cache_ttl_seconds' => 75, 'route_cache_ttl_seconds' => 900, @@ -532,36 +505,22 @@ function fleetopsMapCenterFor(mixed $company, FleetOpsMapCenterRedisFake $redis) ], ]; + // The response names the routing engine tracking will use; no routing server is configured here $tracking = fleetopsJsonPayload($controller->getTrackingSettings()); - expect($tracking['provider'])->toBe('google_routes') - ->and($tracking['alerts']['prolonged_stoppages']['duration_threshold_minutes'])->toBe(55) - // Organizations are only offered the providers they may use - ->and($tracking['providers'])->toBe([ - [ - 'key' => 'osrm', - 'name' => 'OSRM', - 'value' => 'osrm', - 'label' => 'OSRM', - 'capabilities' => ['routes'], - ], - ]); - - $refused = $controller->saveTrackingSettings(new Request(['provider' => 'google_routes'])); - expect($refused->getStatusCode())->toBe(422); - - // Without an organization every registered provider is listed - $controller->policyCompany = null; - expect(fleetopsJsonPayload($controller->getTrackingSettings())['providers'])->toHaveCount(2); + expect($tracking)->not->toHaveKeys(['provider', 'fallbacks', 'providers']) + ->and($tracking['routing_engine'])->toBe('calculate') + ->and($tracking['alerts']['prolonged_stoppages']['duration_threshold_minutes'])->toBe(55); $adminSaved = fleetopsJsonPayload($controller->saveAdminTrackingSettings(new Request([ - 'provider' => 'calculated', - 'fallbacks' => 'osrm', + 'provider' => 'calculated', + 'fallbacks' => 'osrm', + 'cache_ttl_seconds' => '20', ]))); - expect($controller->configured['fleet-ops.tracking-settings']['provider'])->toBe('calculated') - ->and($controller->configured['fleet-ops.tracking-settings']['fallbacks'])->toBe(['osrm']) - ->and($adminSaved['providers'])->toHaveCount(2) - ->and(fleetopsJsonPayload($controller->getAdminTrackingSettings())['providers'])->toHaveCount(2); + expect($controller->configured['fleet-ops.tracking-settings'])->not->toHaveKeys(['provider', 'fallbacks']) + ->and($controller->configured['fleet-ops.tracking-settings']['cache_ttl_seconds'])->toBe(20) + ->and($adminSaved)->not->toHaveKeys(['provider', 'fallbacks', 'providers']) + ->and($adminSaved['cache_ttl_seconds'])->toBe(20); }); test('setting controller handles map scheduling orchestrator and card field settings', function () { diff --git a/server/tests/Unit/Support/DistanceMatrixProviderResolverTest.php b/server/tests/Unit/Support/RoutingEngineResolverTest.php similarity index 79% rename from server/tests/Unit/Support/DistanceMatrixProviderResolverTest.php rename to server/tests/Unit/Support/RoutingEngineResolverTest.php index b00f61be8..7059e3a07 100644 --- a/server/tests/Unit/Support/DistanceMatrixProviderResolverTest.php +++ b/server/tests/Unit/Support/RoutingEngineResolverTest.php @@ -1,6 +1,6 @@ null, - 'fleetops.osrm.host' => 'https://osrm.fleetbase.test', - 'fleetops.valhalla.host' => null, - 'fleetops.valhalla.api_key' => null, - 'fleetops.routing.google_enabled' => false, - 'services.google_maps.api_key' => null, + 'fleetops.distance_matrix.provider' => null, + 'fleetops.osrm.host' => 'https://osrm.fleetbase.test', + 'fleetops.valhalla.host' => null, + 'fleetops.valhalla.api_key' => null, + 'fleetops.routing.google_enabled' => false, + 'fleetops.routing.display_engine' => 'osrm', + 'fleetops.routing.optimization_engine' => null, + 'fleetops.routing.server_routing' => 'road', + 'services.google_maps.api_key' => null, ]); Cache::swap(new Illuminate\Cache\Repository(new Illuminate\Cache\ArrayStore())); @@ -117,12 +120,15 @@ function fleetopsDistanceProviderPolicy(array $policy): void afterEach(function () { config([ - 'fleetops.distance_matrix.provider' => null, - 'fleetops.osrm.host' => null, - 'fleetops.valhalla.host' => null, - 'fleetops.valhalla.api_key' => null, - 'fleetops.routing.google_enabled' => false, - 'services.google_maps.api_key' => null, + 'fleetops.distance_matrix.provider' => null, + 'fleetops.osrm.host' => null, + 'fleetops.valhalla.host' => null, + 'fleetops.valhalla.api_key' => null, + 'fleetops.routing.google_enabled' => false, + 'fleetops.routing.display_engine' => 'osrm', + 'fleetops.routing.optimization_engine' => null, + 'fleetops.routing.server_routing' => 'road', + 'services.google_maps.api_key' => null, ]); Cache::flush(); app()->forgetInstance('log'); @@ -132,22 +138,53 @@ function fleetopsDistanceProviderPolicy(array $policy): void session(['company' => null]); }); -test('an explicit DISTANCE_MATRIX_PROVIDER wins over the company routing engine', function () { +test('environment values only seed the admin policy, which wins once saved', function () { $connection = fleetopsDistanceProviderBoot(); - fleetopsDistanceProviderRouting($connection, 'company-1', ['display_engine' => 'osrm']); + config([ + 'fleetops.routing.display_engine' => 'valhalla', + 'fleetops.routing.optimization_engine' => null, + 'fleetops.routing.server_routing' => 'straight_line', + ]); + Cache::flush(); + + // Unsaved: the environment provides the defaults, and optimization follows the routing engine + expect(RoutingProviderPolicy::settings()['defaults'])->toBe(['display_engine' => 'valhalla', 'optimization_engine' => 'valhalla']) + ->and(RoutingProviderPolicy::usesStraightLine())->toBeTrue() + ->and(RoutingEngineResolver::resolve('company-1'))->toBe('calculate'); - config(['fleetops.distance_matrix.provider' => 'osrm']); - expect(DistanceMatrixProviderResolver::resolve('company-1'))->toBe('osrm'); + // Saved: the admin choice wins over the environment + fleetopsDistanceProviderPolicy(['server_routing' => 'road', 'defaults' => ['display_engine' => 'osrm']]); + expect(RoutingProviderPolicy::usesStraightLine())->toBeFalse() + ->and(RoutingEngineResolver::resolve('company-1'))->toBe('osrm'); - // Even an explicit choice is subject to the admin routing policy + // The old DISTANCE_MATRIX_PROVIDER no longer overrides anything for orders config(['fleetops.distance_matrix.provider' => 'google']); - expect(DistanceMatrixProviderResolver::resolve('company-1'))->toBe('calculate'); - fleetopsDistanceProviderPolicy(['providers' => ['google' => true]]); - expect(DistanceMatrixProviderResolver::resolve('company-1'))->toBe('google'); + expect(RoutingEngineResolver::resolve('company-1'))->toBe('osrm'); + + // An unknown mode is treated as road routing + expect(RoutingProviderPolicy::normalize(['server_routing' => 'teleport'])['server_routing'])->toBe('road'); +}); + +test('the administrator can switch server-side routing to straight-line in one place', function () { + $connection = fleetopsDistanceProviderBoot(); + fleetopsDistanceProviderRouting($connection, 'company-1', ['display_engine' => 'osrm']); + $requests = 0; + Http::fake(function () use (&$requests) { + $requests++; + + return Http::response(['code' => 'Ok', 'routes' => [['distance' => 31520.0, 'duration' => 1610.0]]]); + }); + + expect(RoutingEngineResolver::resolve('company-1'))->toBe('osrm') + ->and(RoutingEngineResolver::trackingProvider('company-1'))->toBe('osrm'); - // `calculate` is the operator's kill switch back to straight-line estimates - config(['fleetops.distance_matrix.provider' => 'calculate']); - expect(DistanceMatrixProviderResolver::resolve('company-1'))->toBe('calculate'); + fleetopsDistanceProviderPolicy(['server_routing' => 'straight_line']); + $estimate = Utils::calculateDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58]); + + expect(RoutingEngineResolver::resolve('company-1'))->toBe('calculate') + ->and(RoutingEngineResolver::trackingProvider('company-1'))->toBe('calculated') + ->and(Utils::getDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58], ['company_uuid' => 'company-1'])->distance)->toBe($estimate->distance) + ->and($requests)->toBe(0); }); test('the company routing engine is used when the server is configured for it', function () { @@ -158,20 +195,22 @@ function fleetopsDistanceProviderPolicy(array $policy): void fleetopsDistanceProviderRouting($connection, 'company-vroom', ['display_engine' => 'vroom']); // A company that never saved routing settings uses OSRM, as the map does - expect(DistanceMatrixProviderResolver::resolve('company-osrm'))->toBe('osrm') - ->and(DistanceMatrixProviderResolver::resolve('company-unsaved'))->toBe('osrm') - ->and(DistanceMatrixProviderResolver::resolve(null))->toBe('osrm'); + expect(RoutingEngineResolver::resolve('company-osrm'))->toBe('osrm') + ->and(RoutingEngineResolver::resolve('company-unsaved'))->toBe('osrm') + ->and(RoutingEngineResolver::resolve(null))->toBe('osrm'); // Valhalla needs a configured server; until then the company falls through to private OSRM - expect(DistanceMatrixProviderResolver::resolve('company-valhalla'))->toBe('osrm'); + expect(RoutingEngineResolver::resolve('company-valhalla'))->toBe('osrm'); config(['fleetops.valhalla.host' => 'https://valhalla.routing.fleetbase.test']); - expect(DistanceMatrixProviderResolver::resolve('company-valhalla'))->toBe('valhalla') - ->and(DistanceMatrixProviderResolver::resolve('company-legacy'))->toBe('valhalla') - ->and(DistanceMatrixProviderResolver::resolve('company-osrm'))->toBe('osrm'); - - // Engines the server cannot call map to nothing - expect(DistanceMatrixProviderResolver::fromRoutingSettings('company-vroom'))->toBeNull() - ->and(DistanceMatrixProviderResolver::isAvailable('vroom'))->toBeFalse(); + expect(RoutingEngineResolver::resolve('company-valhalla'))->toBe('valhalla') + ->and(RoutingEngineResolver::resolve('company-legacy'))->toBe('valhalla') + ->and(RoutingEngineResolver::resolve('company-osrm'))->toBe('osrm'); + + // Engines the server cannot call are skipped for the platform default + expect(RoutingEngineResolver::companyEngine('company-vroom'))->toBe('vroom') + ->and(RoutingEngineResolver::isAvailable('vroom'))->toBeFalse() + ->and(RoutingEngineResolver::resolve('company-vroom'))->toBe('osrm') + ->and(RoutingEngineResolver::companyEngine('company-unsaved'))->toBeNull(); }); test('google needs the administrator to allow it, globally or for the organization', function () { @@ -180,23 +219,23 @@ function fleetopsDistanceProviderPolicy(array $policy): void fleetopsDistanceProviderRouting($connection, 'company-other', ['display_engine' => 'google']); config(['services.google_maps.api_key' => 'test-maps-key', 'fleetops.osrm.host' => 'https://router.project-osrm.org']); - expect(DistanceMatrixProviderResolver::fromRoutingSettings('company-google'))->toBe('google') - ->and(DistanceMatrixProviderResolver::isAvailable('google', 'company-google'))->toBeFalse() - ->and(DistanceMatrixProviderResolver::resolve('company-google'))->toBe('calculate') - ->and(DistanceMatrixProviderResolver::googleMapsApiKey())->toBe('test-maps-key'); + expect(RoutingEngineResolver::companyEngine('company-google'))->toBe('google') + ->and(RoutingEngineResolver::isAvailable('google', 'company-google'))->toBeFalse() + ->and(RoutingEngineResolver::resolve('company-google'))->toBe('calculate') + ->and(RoutingEngineResolver::googleMapsApiKey())->toBe('test-maps-key'); // With the platform's own Valhalla configured, the organization gets road distance from it instead config(['fleetops.valhalla.host' => 'https://valhalla.routing.fleetbase.test']); - expect(DistanceMatrixProviderResolver::resolve('company-google'))->toBe('valhalla'); + expect(RoutingEngineResolver::resolve('company-google'))->toBe('valhalla'); // An exception lets one organization use Google while the others stay on Valhalla fleetopsDistanceProviderPolicy(['exceptions' => [['company_uuid' => 'company-google', 'company_name' => 'Acme', 'providers' => ['google']]]]); - expect(DistanceMatrixProviderResolver::resolve('company-google'))->toBe('google') - ->and(DistanceMatrixProviderResolver::resolve('company-other'))->toBe('valhalla'); + expect(RoutingEngineResolver::resolve('company-google'))->toBe('google') + ->and(RoutingEngineResolver::resolve('company-other'))->toBe('valhalla'); // Without a Maps key Google cannot be used even when allowed config(['services.google_maps.api_key' => null]); - expect(DistanceMatrixProviderResolver::resolve('company-google'))->toBe('valhalla'); + expect(RoutingEngineResolver::resolve('company-google'))->toBe('valhalla'); }); test('the platform valhalla server serves companies whose engine is not available', function () { @@ -204,18 +243,18 @@ function fleetopsDistanceProviderPolicy(array $policy): void fleetopsDistanceProviderRouting($connection, 'company-osrm', ['display_engine' => 'osrm']); config(['fleetops.osrm.host' => 'https://router.project-osrm.org']); - expect(DistanceMatrixProviderResolver::resolve('company-osrm'))->toBe('calculate'); + expect(RoutingEngineResolver::resolve('company-osrm'))->toBe('calculate'); // Community servers do not count as configured config(['fleetops.valhalla.host' => 'https://VALHALLA1.openstreetmap.de']); expect(ValhallaClient::isConfigured())->toBeFalse() - ->and(DistanceMatrixProviderResolver::resolve('company-osrm'))->toBe('calculate'); + ->and(RoutingEngineResolver::resolve('company-osrm'))->toBe('calculate'); config(['fleetops.valhalla.host' => 'https://valhalla.routing.fleetbase.test/']); expect(ValhallaClient::isConfigured())->toBeTrue() ->and(ValhallaClient::connection())->toBe(['host' => 'https://valhalla.routing.fleetbase.test', 'api_key' => null]) - ->and(DistanceMatrixProviderResolver::resolve('company-osrm'))->toBe('valhalla') - ->and(DistanceMatrixProviderResolver::resolve(null))->toBe('valhalla'); + ->and(RoutingEngineResolver::resolve('company-osrm'))->toBe('valhalla') + ->and(RoutingEngineResolver::resolve(null))->toBe('valhalla'); }); test('order distance from valhalla converts kilometres, sends the key and caches the result', function () { @@ -300,9 +339,8 @@ function fleetopsDistanceProviderPolicy(array $policy): void $log = $GLOBALS['fleetopsDistanceProviderLog']; Http::fake(['*' => Http::response([], 500)]); - config(['fleetops.distance_matrix.provider' => 'valhala']); $estimate = Utils::calculateDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58]); - $matrix = Utils::getDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58]); + $matrix = Utils::getDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58], ['provider' => 'valhala']); expect($matrix->distance)->toBe($estimate->distance) ->and($log->warnings[0][1]['error'])->toBe('Unsupported distance matrix provider: valhala'); @@ -314,21 +352,20 @@ function fleetopsDistanceProviderPolicy(array $policy): void foreach (['https://router.project-osrm.org', 'https://ROUTER.project-osrm.org/', null, 'not a url'] as $host) { config(['fleetops.osrm.host' => $host]); - expect(DistanceMatrixProviderResolver::hasPrivateOsrmHost())->toBeFalse() - ->and(DistanceMatrixProviderResolver::resolve('company-1'))->toBe('calculate'); + expect(RoutingEngineResolver::hasPrivateOsrmHost())->toBeFalse() + ->and(RoutingEngineResolver::resolve('company-1'))->toBe('calculate'); } - // The operator can still opt into the demo server explicitly - config(['fleetops.distance_matrix.provider' => 'osrm']); - expect(DistanceMatrixProviderResolver::resolve('company-1'))->toBe('osrm'); + // Only an explicit per-call provider (the estimations command's --provider) can reach the demo server + expect(RoutingEngineResolver::trackingProvider('company-1'))->toBe('calculated'); }); test('an unreadable settings store falls back to the default routing engine', function () { $connection = fleetopsDistanceProviderBoot(); $connection->getSchemaBuilder()->drop('settings'); - expect(DistanceMatrixProviderResolver::fromRoutingSettings('company-1'))->toBe('osrm') - ->and(DistanceMatrixProviderResolver::resolve('company-1'))->toBe('osrm'); + expect(RoutingEngineResolver::companyEngine('company-1'))->toBeNull() + ->and(RoutingEngineResolver::resolve('company-1'))->toBe('osrm'); }); test('order distance follows the company routing engine and caches the road result', function () { @@ -435,9 +472,10 @@ function fleetopsDistanceProviderPolicy(array $policy): void // Defaults: everything self-hosted is available, Google is not expect(RoutingProviderPolicy::settings())->toBe([ - 'providers' => ['google' => false, 'valhalla' => true, 'osrm' => true, 'vroom' => true], - 'defaults' => ['display_engine' => 'osrm', 'optimization_engine' => 'osrm'], - 'exceptions' => [], + 'providers' => ['google' => false, 'valhalla' => true, 'osrm' => true, 'vroom' => true], + 'defaults' => ['display_engine' => 'osrm', 'optimization_engine' => 'osrm'], + 'server_routing' => 'road', + 'exceptions' => [], ]) ->and(RoutingProviderPolicy::allowedProviders('company-1'))->toBe(['valhalla', 'osrm', 'vroom']) // Feature keys map to their provider, straight-line and third-party providers are always allowed @@ -461,9 +499,10 @@ function fleetopsDistanceProviderPolicy(array $policy): void ]); expect($saved)->toBe([ - 'providers' => ['google' => false, 'valhalla' => false, 'osrm' => true, 'vroom' => true], - 'defaults' => ['display_engine' => 'valhalla', 'optimization_engine' => 'vroom'], - 'exceptions' => [['company_uuid' => 'company-1', 'company_name' => 'Acme', 'providers' => ['google', 'valhalla']]], + 'providers' => ['google' => false, 'valhalla' => false, 'osrm' => true, 'vroom' => true], + 'defaults' => ['display_engine' => 'valhalla', 'optimization_engine' => 'vroom'], + 'server_routing' => 'road', + 'exceptions' => [['company_uuid' => 'company-1', 'company_name' => 'Acme', 'providers' => ['google', 'valhalla']]], ]) ->and(json_decode($connection->table('settings')->where('key', RoutingProviderPolicy::SETTING_KEY)->value('value'), true))->toBe($saved) // Saving clears the cache, so the change applies at once @@ -501,7 +540,7 @@ function fleetopsDistanceProviderPolicy(array $policy): void fleetopsDistanceProviderPolicy(['providers' => ['valhalla' => false, 'osrm' => false]]); $estimate = Utils::calculateDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58]); - expect(DistanceMatrixProviderResolver::resolve('company-1'))->toBe('calculate') + expect(RoutingEngineResolver::resolve('company-1'))->toBe('calculate') ->and(Utils::getDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58], ['company_uuid' => 'company-1'])->distance)->toBe($estimate->distance); // An explicit request (the estimations command's --provider) is refused too diff --git a/server/tests/Unit/Tracking/TrackingOptionsCompanySettingsTest.php b/server/tests/Unit/Tracking/TrackingOptionsCompanySettingsTest.php index a064509c8..c1c2ba7cd 100644 --- a/server/tests/Unit/Tracking/TrackingOptionsCompanySettingsTest.php +++ b/server/tests/Unit/Tracking/TrackingOptionsCompanySettingsTest.php @@ -13,9 +13,10 @@ use Illuminate\Database\SQLiteConnection; /** - * Covers company tracking settings resolution for TrackingOptions: the - * order's company is used when there is no company session (queued jobs, - * console commands), and the session company remains the fallback. + * Covers company resolution for TrackingOptions: the order's company is used + * when there is no company session (queued jobs, console commands), and the + * session company remains the fallback. The provider is the company's routing + * engine; the company's tracking settings only tune behaviour. */ function fleetopsTrackingOptionsBoot(): SQLiteConnection { @@ -31,11 +32,17 @@ function fleetopsTrackingOptionsBoot(): SQLiteConnection $blueprint->timestamps(); }); + // Company A routes with its own Valhalla server; company B has no routing engine the server can call. + // A provider saved in tracking settings before tracking followed the routing engine is ignored. $connection->table('settings')->insert([ + ['key' => 'company.company-a.routing', 'value' => json_encode(['display_engine' => 'valhalla'])], + ['key' => 'company.company-a.valhalla', 'value' => json_encode(['api_host' => 'https://company-a.valhalla.test'])], ['key' => 'company.company-a.tracking', 'value' => json_encode(['provider' => 'osrm', 'cache_ttl_seconds' => 15])], - ['key' => 'company.company-b.tracking', 'value' => json_encode(['provider' => 'calculated', 'cache_ttl_seconds' => 45])], + ['key' => 'company.company-b.tracking', 'value' => json_encode(['provider' => 'google_routes', 'cache_ttl_seconds' => 45])], ]); + config(['fleetops.osrm.host' => null, 'fleetops.valhalla.host' => null]); + Illuminate\Support\Facades\Cache::swap(new Illuminate\Cache\Repository(new Illuminate\Cache\ArrayStore())); session(['company' => null]); return $connection; @@ -47,10 +54,11 @@ function fleetopsTrackingOptionsBoot(): SQLiteConnection $options = TrackingOptions::fromArray([], 'company-a'); - expect($options->provider)->toBe('osrm') + expect($options->provider)->toBe('valhalla') + ->and($options->fallbacks)->toBe([]) ->and($options->cacheTtlSeconds)->toBe(15); - // Explicit options still win over company settings + // An explicitly requested provider (API callers) still wins; the routing policy guards it later expect(TrackingOptions::fromArray(['provider' => 'google'], 'company-a')->provider)->toBe('google'); })->with([null, 'company-b']); @@ -65,6 +73,13 @@ function fleetopsTrackingOptionsBoot(): SQLiteConnection ->and($options->cacheTtlSeconds)->toBe(45); }); +test('tracking uses the straight-line estimate when the administrator switches server-side routing off', function () { + fleetopsTrackingOptionsBoot(); + Fleetbase\FleetOps\Support\Routing\RoutingProviderPolicy::save(['server_routing' => 'straight_line']); + + expect(TrackingOptions::fromArray([], 'company-a')->provider)->toBe('calculated'); +}); + test('order tracker builds options from the order company', function () { fleetopsTrackingOptionsBoot(); @@ -91,7 +106,7 @@ public function track(Order $order, array|TrackingOptions $options = []): array app()->forgetInstance(TrackingIntelligenceService::class); expect($service->received)->toBeInstanceOf(TrackingOptions::class) - ->and($service->received->provider)->toBe('osrm'); + ->and($service->received->provider)->toBe('valhalla'); }); test('tracking remains available with configured defaults when the settings database is unavailable', function () { @@ -99,7 +114,7 @@ public function track(Order $order, array|TrackingOptions $options = []): array $connection->getSchemaBuilder()->drop('settings'); $original = config('fleetops.tracking', []); try { - config(['fleetops.tracking' => ['provider' => 'calculated', 'cache_ttl_seconds' => 17]]); + config(['fleetops.tracking' => ['cache_ttl_seconds' => 17]]); $options = TrackingOptions::fromArray([], 'uncached-unavailable-company'); expect($options->provider)->toBe('calculated')->and($options->cacheTtlSeconds)->toBe(17); expect(TrackingOptions::fromArray(['provider' => 'osrm'], 'uncached-unavailable-company')->provider)->toBe('osrm'); diff --git a/tests/integration/components/admin/routing-settings-test.js b/tests/integration/components/admin/routing-settings-test.js index a4d680e9d..64b786d02 100644 --- a/tests/integration/components/admin/routing-settings-test.js +++ b/tests/integration/components/admin/routing-settings-test.js @@ -12,6 +12,7 @@ class FetchStubService extends Service { return Promise.resolve({ providers: { google: false, valhalla: true, osrm: true, vroom: true }, defaults: { display_engine: 'valhalla', optimization_engine: 'vroom' }, + server_routing: 'road', exceptions: [{ company_uuid: 'company-1', company_name: 'Acme Logistics', providers: ['google'] }], available_providers: [ { key: 'google', label: 'Google' }, @@ -22,7 +23,7 @@ class FetchStubService extends Service { }); } - return Promise.resolve({ provider: 'valhalla', fallbacks: ['calculated'], providers: [{ key: 'valhalla', label: 'Valhalla' }] }); + return Promise.resolve({ traffic_enabled: false, cache_ttl_seconds: 45 }); } post(url, body) { @@ -83,11 +84,15 @@ module('Integration | Component | admin/routing-settings', function (hooks) { { providers: { google: true, valhalla: true, osrm: true, vroom: true }, defaults: { display_engine: 'valhalla', optimization_engine: 'vroom' }, + server_routing: 'road', exceptions: [], }, ]); + // Tracking only saves behaviour; its provider is the routing engine assert.strictEqual(fetch.posts[1][0], 'fleet-ops/settings/admin-tracking-settings'); - assert.strictEqual(fetch.posts[1][1].provider, 'valhalla'); + assert.false('provider' in fetch.posts[1][1]); + assert.false(fetch.posts[1][1].traffic_enabled); + assert.strictEqual(fetch.posts[1][1].cache_ttl_seconds, 45); assert.deepEqual(this.owner.lookup('service:notifications').successes, ['Routing settings saved.']); }); }); diff --git a/translations/en-us.yaml b/translations/en-us.yaml index 8a51aa3cb..52839e7ff 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -2848,7 +2848,7 @@ settings: configure-routing: Configure Routing select-routing-service: Select Routing Service routing-service: Routing Service - routing-service-help-text: Select the service which is responsible for calculating and plotting routes on the map. + routing-service-help-text: Select the service that plots routes on the map and measures each order's distance, time and live ETA. select-routing-distance-unit: Select Routing Distance Unit routing-distance-unit: Routing Distance Unit routing-distance-unit-help-text: The unit used for calculating distance and routes. From d272ae7e12bc6122fdd7fce0b9e205d96b27a3ad Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 24 Oct 2026 13:56:52 +0800 Subject: [PATCH 15/22] fix(routing): Valhalla is the default engine; OSRM is never a fallback or the demo server Default the routing engine to Valhalla and drop OSRM from the platform fallback chain, so organizations without a configured engine get the straight-line estimate rather than the public OSRM demo server. Order distance and the OSRM tracking provider refuse the demo server even when OSRM is requested explicitly. The console's route engine and routing-control fallbacks prefer Valhalla, and leaflet-routing-control honours the provider policy. Co-Authored-By: Claude --- addon/components/admin/routing-settings.js | 4 +- addon/components/customer/orders.js | 2 +- addon/controllers/settings/routing.js | 8 +-- addon/services/leaflet-routing-control.js | 13 +++-- addon/services/route-engine.js | 6 +-- server/config/fleetops.php | 2 +- .../Support/Routing/RoutingEngineResolver.php | 10 ++-- .../Support/Routing/RoutingProviderPolicy.php | 6 +-- server/src/Support/Utils.php | 5 ++ .../Providers/OsrmTrackingProvider.php | 7 ++- .../tests/SettingControllerContractsTest.php | 10 ++-- server/tests/TrackingIntelligenceTest.php | 8 ++- .../Support/RoutingEngineResolverTest.php | 29 ++++++++++- .../Support/UtilsGeoMatrixAndVendorTest.php | 3 ++ .../services/leaflet-routing-control-test.js | 52 ++++++++++++++++++- 15 files changed, 134 insertions(+), 31 deletions(-) diff --git a/addon/components/admin/routing-settings.js b/addon/components/admin/routing-settings.js index 56b0ea7e3..c4c3024f3 100644 --- a/addon/components/admin/routing-settings.js +++ b/addon/components/admin/routing-settings.js @@ -16,8 +16,8 @@ export default class AdminRoutingSettingsComponent extends Component { { key: 'osrm', label: 'OSRM' }, { key: 'vroom', label: 'VROOM' }, ]; - @tracked defaultDisplayEngine = 'osrm'; - @tracked defaultOptimizationEngine = 'osrm'; + @tracked defaultDisplayEngine = 'valhalla'; + @tracked defaultOptimizationEngine = 'valhalla'; @tracked providerExceptions = []; @tracked serverRouting = 'road'; serverRoutingOptions = [ diff --git a/addon/components/customer/orders.js b/addon/components/customer/orders.js index 1de736c0b..1d9c4a316 100644 --- a/addon/components/customer/orders.js +++ b/addon/components/customer/orders.js @@ -255,7 +255,7 @@ export default class CustomerOrdersComponent extends Component { debug(`Leaflet Map Error: ${error.message}`); } - const routingService = this.currentUser.getOption('routing', { router: 'osrm' }).router; + const routingService = this.currentUser.getOption('routing', { router: 'valhalla' }).router; const { router, formatter } = this.leafletRoutingControl.get(routingService); this.routeControl = new RoutingControl({ diff --git a/addon/controllers/settings/routing.js b/addon/controllers/settings/routing.js index 5ac4b11e1..a3ccd33b3 100644 --- a/addon/controllers/settings/routing.js +++ b/addon/controllers/settings/routing.js @@ -10,8 +10,8 @@ export default class SettingsRoutingController extends Controller { @service currentUser; @service routeEngine; @service routeOptimization; - @tracked displayEngine = 'osrm'; - @tracked optimizationEngine = 'osrm'; + @tracked displayEngine = 'valhalla'; + @tracked optimizationEngine = 'valhalla'; @tracked routingUnit = 'km'; @tracked trackingTrafficEnabled = true; @tracked trackingCacheTtlSeconds = 60; @@ -77,8 +77,8 @@ export default class SettingsRoutingController extends Controller { @task *getSettings() { try { const { router, display_engine, optimization_engine, unit } = yield this.fetch.get('fleet-ops/settings/routing-settings'); - this.displayEngine = display_engine ?? router ?? 'osrm'; - this.optimizationEngine = optimization_engine ?? display_engine ?? router ?? 'osrm'; + this.displayEngine = display_engine ?? router ?? 'valhalla'; + this.optimizationEngine = optimization_engine ?? display_engine ?? router ?? 'valhalla'; this.routingUnit = unit; const trackingSettings = yield this.fetch.get('fleet-ops/settings/tracking-settings'); this.trackingTrafficEnabled = trackingSettings.traffic_enabled ?? true; diff --git a/addon/services/leaflet-routing-control.js b/addon/services/leaflet-routing-control.js index f6df907f9..958366667 100644 --- a/addon/services/leaflet-routing-control.js +++ b/addon/services/leaflet-routing-control.js @@ -1,6 +1,7 @@ import Service, { inject as service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; import { underscore } from '@ember/string'; +import isRoutingProviderAllowed from '../utils/routing-providers'; export class RoutingControlRegistry { @tracked routers = {}; @@ -51,17 +52,21 @@ export default class LeafletRoutingControlService extends Service { get(name) { name = name ?? this.getRouter(); + // A switched-off engine is never used, even when named explicitly + if (!isRoutingProviderAllowed(name, this.currentUser.getOption('routing', {})?.allowed_providers)) { + name = this.getRouter(); + } let router = this.registry.routers[underscore(name)]; if (!router) { - // Fallback to OSRM default router - router = this.registry.routers.osrm; + // Fall back to Valhalla (the platform's routing engine), then OSRM + router = this.registry.routers.valhalla ?? this.registry.routers.osrm; } return router; } - getRouter(fallback = 'osrm') { - const routingSettings = this.currentUser.getOption('routing', { router: fallback ?? 'osrm' }); + getRouter(fallback = 'valhalla') { + const routingSettings = this.currentUser.getOption('routing', { router: fallback ?? 'valhalla' }); return routingSettings.router; } diff --git a/addon/services/route-engine.js b/addon/services/route-engine.js index 2bf44719f..9dc865559 100644 --- a/addon/services/route-engine.js +++ b/addon/services/route-engine.js @@ -65,18 +65,18 @@ export default class RouteEngineService extends Service { // A switched-off engine is never used for map routing, even when named explicitly const requested = name ?? this.getDisplayEngine(); const key = underscore(this.isAllowed(requested) ? requested : this.getDisplayEngine()); - const registration = this.registry.engines[key] ?? this.registry.engines.osrm; + const registration = this.registry.engines[key] ?? this.registry.engines.valhalla ?? this.registry.engines.osrm; return registration?.engine ?? null; } - getDisplayEngine(fallback = 'osrm') { + getDisplayEngine(fallback = 'valhalla') { const routingSettings = this.currentUser.getOption('routing', {}); return routingSettings.routing_display_engine ?? routingSettings.router ?? fallback; } getOptimizationEngine(fallback = null) { const routingSettings = this.currentUser.getOption('routing', {}); - return routingSettings.routing_optimization_engine ?? routingSettings.router ?? this.getDisplayEngine(fallback ?? 'osrm'); + return routingSettings.routing_optimization_engine ?? routingSettings.router ?? this.getDisplayEngine(fallback ?? 'valhalla'); } async compute(name, waypoints, options = {}) { diff --git a/server/config/fleetops.php b/server/config/fleetops.php index 14886f933..36446b851 100644 --- a/server/config/fleetops.php +++ b/server/config/fleetops.php @@ -61,7 +61,7 @@ */ 'routing' => [ 'google_enabled' => env('ROUTING_GOOGLE_ENABLED', env('DISTANCE_MATRIX_PROVIDER') === 'google'), - 'display_engine' => env('ROUTING_DISPLAY_ENGINE', in_array(env('DISTANCE_MATRIX_PROVIDER'), ['osrm', 'valhalla', 'google'], true) ? env('DISTANCE_MATRIX_PROVIDER') : 'osrm'), + 'display_engine' => env('ROUTING_DISPLAY_ENGINE', in_array(env('DISTANCE_MATRIX_PROVIDER'), ['osrm', 'valhalla', 'google'], true) ? env('DISTANCE_MATRIX_PROVIDER') : 'valhalla'), 'optimization_engine' => env('ROUTING_OPTIMIZATION_ENGINE'), 'server_routing' => env('ROUTING_SERVER_ROUTING', env('DISTANCE_MATRIX_PROVIDER') === 'calculate' ? 'straight_line' : 'road'), ], diff --git a/server/src/Support/Routing/RoutingEngineResolver.php b/server/src/Support/Routing/RoutingEngineResolver.php index a4855b08a..f8bf50364 100644 --- a/server/src/Support/Routing/RoutingEngineResolver.php +++ b/server/src/Support/Routing/RoutingEngineResolver.php @@ -15,10 +15,12 @@ * 1. Straight-line when the administrator switched server-side routing off. * 2. The organization's routing engine (Fleet-Ops Settings → Routing). * 3. The platform default routing engine. - * 4. The platform's own engine: the configured Valhalla server, then a private OSRM host. + * 4. The platform's own Valhalla server. * 5. `calculate`, the straight-line estimate. * An engine counts only when the server can call it: Valhalla or OSRM on a - * private server, or Google with a Maps key. + * private server, or Google with a Maps key. OSRM is used only when an + * organization chose it and a private OSRM host is configured; it is never a + * fallback, and the public OSRM demo server is never called. */ class RoutingEngineResolver { @@ -29,9 +31,9 @@ class RoutingEngineResolver public const PUBLIC_OSRM_HOST = 'router.project-osrm.org'; /** - * Self-hosted engines, in the order the platform prefers them. + * The platform's own engine, tried when the organization's engine is unavailable. */ - public const PLATFORM_ENGINES = ['valhalla', 'osrm']; + public const PLATFORM_ENGINES = ['valhalla']; /** * Tracking provider keys for routing engines whose tracking provider is named differently. diff --git a/server/src/Support/Routing/RoutingProviderPolicy.php b/server/src/Support/Routing/RoutingProviderPolicy.php index 9a49c2c74..ba0972968 100644 --- a/server/src/Support/Routing/RoutingProviderPolicy.php +++ b/server/src/Support/Routing/RoutingProviderPolicy.php @@ -91,7 +91,7 @@ public static function normalize(array $settings): array ->values() ->all(); - $displayEngine = (string) (data_get($settings, 'defaults.display_engine') ?: config('fleetops.routing.display_engine') ?: 'osrm'); + $displayEngine = (string) (data_get($settings, 'defaults.display_engine') ?: config('fleetops.routing.display_engine') ?: 'valhalla'); $serverRouting = (string) data_get($settings, 'server_routing', config('fleetops.routing.server_routing', 'road')); return [ @@ -168,7 +168,7 @@ public static function defaultOptimizationEngine(): string /** * The engine the organization may use: its own choice when allowed, otherwise - * the platform default, otherwise OSRM. + * the platform default, otherwise Valhalla. */ public static function engineFor(?string $engine, string $default, ?string $companyUuid = null): string { @@ -178,7 +178,7 @@ public static function engineFor(?string $engine, string $default, ?string $comp } } - return 'osrm'; + return 'valhalla'; } protected static function stored(): array diff --git a/server/src/Support/Utils.php b/server/src/Support/Utils.php index 3d60aff13..a6fca2b61 100644 --- a/server/src/Support/Utils.php +++ b/server/src/Support/Utils.php @@ -834,6 +834,11 @@ public static function getDrivingDistanceAndTime($origin, $destination, $options throw new \RuntimeException('The ' . $provider . ' provider is switched off for this organization.'); } + // The public OSRM demo server's usage policy does not allow production traffic + if ($provider === 'osrm' && !RoutingEngineResolver::hasPrivateOsrmHost()) { + throw new \RuntimeException('No private OSRM host is configured.'); + } + $distanceMatrix = match ($provider) { 'valhalla' => static::getDistanceMatrixFromValhalla($origins, $destinations, $companyUuid), 'osrm' => static::getDistanceMatrixFromOSRM($origins, $destinations), diff --git a/server/src/Tracking/Providers/OsrmTrackingProvider.php b/server/src/Tracking/Providers/OsrmTrackingProvider.php index 8a431da4c..097e2f6f3 100644 --- a/server/src/Tracking/Providers/OsrmTrackingProvider.php +++ b/server/src/Tracking/Providers/OsrmTrackingProvider.php @@ -3,6 +3,7 @@ namespace Fleetbase\FleetOps\Tracking\Providers; use Fleetbase\FleetOps\Support\OSRM; +use Fleetbase\FleetOps\Support\Routing\RoutingEngineResolver; use Fleetbase\FleetOps\Tracking\Contracts\TrackingProviderInterface; use Fleetbase\FleetOps\Tracking\TrackingContext; use Fleetbase\FleetOps\Tracking\TrackingOptions; @@ -22,9 +23,13 @@ public function capabilities(): TrackingProviderCapabilities return new TrackingProviderCapabilities(perLegEta: true, routeGeometry: true); } + /** + * Only a private OSRM host is used; the public demo server's usage policy + * does not allow production traffic. + */ public function canTrack(TrackingContext $context): bool { - return $context->canRoute(); + return $context->canRoute() && RoutingEngineResolver::hasPrivateOsrmHost(); } public function track(TrackingContext $context, TrackingOptions $options): TrackingProviderResult diff --git a/server/tests/SettingControllerContractsTest.php b/server/tests/SettingControllerContractsTest.php index 2000376e7..f25825347 100644 --- a/server/tests/SettingControllerContractsTest.php +++ b/server/tests/SettingControllerContractsTest.php @@ -395,16 +395,16 @@ function fleetopsMapCenterFor(mixed $company, FleetOpsMapCenterRedisFake $redis) $controller->lookupCompanySettings['routing'] = ['display_engine' => 'google', 'optimization_engine' => 'google', 'unit' => 'mi']; expect(fleetopsJsonPayload($controller->getRoutingSettings()))->toMatchArray([ 'allowed_providers' => ['valhalla', 'osrm', 'vroom'], - 'display_engine' => 'osrm', - 'optimization_engine' => 'osrm', + 'display_engine' => 'valhalla', + 'optimization_engine' => 'valhalla', 'unit' => 'mi', ]); // An organization that never chose an engine gets the platform defaults $controller->lookupCompanySettings = []; expect(fleetopsJsonPayload($controller->getRoutingSettings()))->toMatchArray([ - 'display_engine' => 'osrm', - 'optimization_engine' => 'osrm', + 'display_engine' => 'valhalla', + 'optimization_engine' => 'valhalla', 'unit' => 'km', ]); }); @@ -415,7 +415,7 @@ function fleetopsMapCenterFor(mixed $company, FleetOpsMapCenterRedisFake $redis) $policy = fleetopsJsonPayload($controller->getAdminRoutingProviders()); expect($policy['providers'])->toBe(['google' => false, 'valhalla' => true, 'osrm' => true, 'vroom' => true]) - ->and($policy['defaults'])->toBe(['display_engine' => 'osrm', 'optimization_engine' => 'osrm']) + ->and($policy['defaults'])->toBe(['display_engine' => 'valhalla', 'optimization_engine' => 'valhalla']) ->and($policy['server_routing'])->toBe('road') ->and($policy['exceptions'])->toBe([]) ->and($policy['available_providers'])->toBe([ diff --git a/server/tests/TrackingIntelligenceTest.php b/server/tests/TrackingIntelligenceTest.php index 53172f541..ba98e3e0f 100644 --- a/server/tests/TrackingIntelligenceTest.php +++ b/server/tests/TrackingIntelligenceTest.php @@ -304,6 +304,13 @@ function googleRoutesTrackingContext(): TrackingContext ], ]; + // The public demo server never counts; only a private OSRM host can track + app('config')->set('fleetops.osrm.host', 'https://router.project-osrm.org'); + expect($provider->canTrack(osrmTrackingContext()))->toBeFalse(); + app('config')->set('fleetops.osrm.host', 'https://osrm.fleetbase.test'); + expect($provider->canTrack(osrmTrackingContext()))->toBeTrue(); + app('config')->set('fleetops.osrm.host', null); + $result = $provider->track(osrmTrackingContext(), new TrackingOptions()); expect($provider->key())->toBe('osrm') @@ -313,7 +320,6 @@ function googleRoutesTrackingContext(): TrackingContext 'map_matching' => false, 'route_geometry' => true, ]) - ->and($provider->canTrack(osrmTrackingContext()))->toBeTrue() ->and($result->provider)->toBe('osrm') ->and($result->distanceMeters)->toBe(1234.5) ->and($result->durationSeconds)->toBe(321.0) diff --git a/server/tests/Unit/Support/RoutingEngineResolverTest.php b/server/tests/Unit/Support/RoutingEngineResolverTest.php index 7059e3a07..b31ed1473 100644 --- a/server/tests/Unit/Support/RoutingEngineResolverTest.php +++ b/server/tests/Unit/Support/RoutingEngineResolverTest.php @@ -516,7 +516,7 @@ function fleetopsDistanceProviderPolicy(array $policy): void // Engine fallback: own choice, then the platform default, then OSRM expect(RoutingProviderPolicy::engineFor('google', 'osrm', 'company-1'))->toBe('google') ->and(RoutingProviderPolicy::engineFor('google', 'vroom', 'company-2'))->toBe('vroom') - ->and(RoutingProviderPolicy::engineFor(null, 'valhalla', 'company-2'))->toBe('osrm') + ->and(RoutingProviderPolicy::engineFor(null, 'valhalla', 'company-2'))->toBe('valhalla') ->and(RoutingProviderPolicy::engineFor('mapbox', 'osrm', 'company-2'))->toBe('mapbox'); // A broken settings store falls back to the defaults @@ -621,3 +621,30 @@ public function callHelper(string $method, ...$arguments) expect($saved['providers']['google'])->toBeTrue() ->and(RoutingProviderPolicy::allows('google_routes', 'company-9'))->toBeTrue(); }); + +test('osrm is never a fallback and never reaches the public demo server', function () { + $connection = fleetopsDistanceProviderBoot(); + fleetopsDistanceProviderRouting($connection, 'company-osrm', ['display_engine' => 'osrm']); + config(['fleetops.routing.display_engine' => 'valhalla']); + Cache::flush(); + $log = $GLOBALS['fleetopsDistanceProviderLog']; + $requests = 0; + Http::fake(function () use (&$requests) { + $requests++; + + return Http::response(['code' => 'Ok', 'routes' => [['distance' => 31520.0, 'duration' => 1610.0]]]); + }); + + // A private OSRM host serves only organizations that chose OSRM; Valhalla is the default and the only fallback + expect(RoutingEngineResolver::resolve('company-osrm'))->toBe('osrm') + ->and(RoutingEngineResolver::resolve('company-unsaved'))->toBe('calculate'); + + // An explicit OSRM request with only the demo server configured falls back to the estimate + config(['fleetops.osrm.host' => 'https://router.project-osrm.org']); + $estimate = Utils::calculateDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58]); + $matrix = Utils::getDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58], ['provider' => 'osrm']); + + expect($matrix->distance)->toBe($estimate->distance) + ->and($requests)->toBe(0) + ->and($log->warnings[0][1]['error'])->toBe('No private OSRM host is configured.'); +}); diff --git a/server/tests/Unit/Support/UtilsGeoMatrixAndVendorTest.php b/server/tests/Unit/Support/UtilsGeoMatrixAndVendorTest.php index d05fe9286..999db824b 100644 --- a/server/tests/Unit/Support/UtilsGeoMatrixAndVendorTest.php +++ b/server/tests/Unit/Support/UtilsGeoMatrixAndVendorTest.php @@ -220,6 +220,8 @@ function fleetopsUtilsPointWkb(float $lat, float $lng): string test('osrm distance matrices serve cached results and fetch live routes', function () { fleetopsUtilsGeoBoot(); $redis = $GLOBALS['fleetopsUtilsRedisFake']; + // Order distance never calls the public OSRM demo server, only a private host + config(['fleetops.osrm.host' => 'https://osrm.fleetbase.test']); // Cached matrix short-circuits any HTTP work $origins = '1.3,103.8'; @@ -243,6 +245,7 @@ function fleetopsUtilsPointWkb(float $lat, float $lng): string expect($live->distance)->toBe(4321.0) ->and($live->time)->toBe(765.0) ->and(count($redis->store))->toBe(2); + config(['fleetops.osrm.host' => null]); }); test('integrated vendor ids match prefixes and provider rows', function () { diff --git a/tests/unit/services/leaflet-routing-control-test.js b/tests/unit/services/leaflet-routing-control-test.js index f9bf94be6..b7e79aa0e 100644 --- a/tests/unit/services/leaflet-routing-control-test.js +++ b/tests/unit/services/leaflet-routing-control-test.js @@ -1,12 +1,62 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import Service from '@ember/service'; module('Unit | Service | leaflet-routing-control', function (hooks) { setupTest(hooks); - // TODO: Replace this with your real tests. + hooks.beforeEach(function () { + this.routing = {}; + const routing = () => this.routing; + const registry = { routers: {} }; + + class UniverseStubService extends Service { + application = { + hasRegistration() { + return false; + }, + register() {}, + resolveRegistration() { + return registry; + }, + }; + + getApplicationInstance() { + return this.application; + } + } + + class CurrentUserStubService extends Service { + getOption(key, defaultValue) { + return Object.keys(routing()).length ? routing() : defaultValue; + } + } + + this.owner.register('service:universe', UniverseStubService); + this.owner.register('service:current-user', CurrentUserStubService); + }); + test('it exists', function (assert) { let service = this.owner.lookup('service:leaflet-routing-control'); assert.ok(service); }); + + test('it defaults to valhalla and never uses a switched-off engine', function (assert) { + const service = this.owner.lookup('service:leaflet-routing-control'); + const osrm = { name: 'OSRM' }; + const valhalla = { name: 'Valhalla' }; + const google = { name: 'Google' }; + service.register('osrm', osrm); + service.register('valhalla', valhalla); + service.register('google', google); + + // Without saved routing settings the platform default is Valhalla + assert.strictEqual(service.getRouter(), 'valhalla'); + assert.strictEqual(service.get(), valhalla); + + this.routing = { router: 'osrm', allowed_providers: ['osrm', 'valhalla'] }; + assert.strictEqual(service.get('google'), osrm, 'a switched-off engine falls back to the organization router'); + assert.strictEqual(service.get('valhalla'), valhalla); + assert.strictEqual(service.get('mapbox'), valhalla, 'an unregistered engine falls back to Valhalla'); + }); }); From 14df669284c21e9ad72de5fc9e3f1f5b5804d89b Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 24 Oct 2026 17:21:13 +0800 Subject: [PATCH 16/22] fix(routing): never call an organization routing host on a private address Fleet-Ops reads the organization `valhalla` and `vroom` settings itself (ValhallaClient for order distance and ETAs, VroomOrchestrationEngine for allocation) and calls their api_host from the server. A user who can edit routing settings could point it at loopback, private network or cloud metadata addresses (SSRF), and values saved before the extensions validated hosts still reach these calls. - Support\Routing\HostPolicy: the same policy as the extensions' HostPolicy (fleetbase/valhalla#10, fleetbase/vroom#9). Only http(s) URLs without credentials whose host is, and resolves only to, public addresses (explicit IPv4/IPv6 blocklist). - ValhallaClient::connection() drops an organization level whose host fails the policy, with its key, inside the 30 s connection cache. - VroomOrchestrationEngine ignores the whole organization override (host, key, endpoint mode) when its host fails the policy. - Self-hosted installs opt out with VALHALLA_ALLOW_PRIVATE_HOSTS (fleetops.valhalla.allow_private_hosts) and VROOM_ALLOW_PRIVATE_HOSTS (vroom.allow_private_hosts). System settings are not checked. --- server/config/fleetops.php | 2 + .../Engines/VroomOrchestrationEngine.php | 23 ++- server/src/Support/Routing/HostPolicy.php | 157 ++++++++++++++++++ server/src/Support/Routing/ValhallaClient.php | 21 ++- .../VroomEngineTransportAndSettingsTest.php | 53 ++++++ .../Unit/Support/Routing/HostPolicyTest.php | 75 +++++++++ .../Support/RoutingEngineResolverTest.php | 31 ++++ .../TrackingOptionsCompanySettingsTest.php | 10 ++ 8 files changed, 369 insertions(+), 3 deletions(-) create mode 100644 server/src/Support/Routing/HostPolicy.php create mode 100644 server/tests/Unit/Support/Routing/HostPolicyTest.php diff --git a/server/config/fleetops.php b/server/config/fleetops.php index 36446b851..1db36f700 100644 --- a/server/config/fleetops.php +++ b/server/config/fleetops.php @@ -83,6 +83,8 @@ 'api_key' => env('VALHALLA_API_KEY'), 'costing' => env('VALHALLA_COSTING', 'auto'), 'timeout' => env('VALHALLA_TIMEOUT', 5), + // Let organization hosts point at private network addresses (self-hosted installs only). + 'allow_private_hosts' => env('VALHALLA_ALLOW_PRIVATE_HOSTS', false), ], /* diff --git a/server/src/Orchestration/Engines/VroomOrchestrationEngine.php b/server/src/Orchestration/Engines/VroomOrchestrationEngine.php index 1caa2e888..0200f25be 100644 --- a/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +++ b/server/src/Orchestration/Engines/VroomOrchestrationEngine.php @@ -4,6 +4,7 @@ use Fleetbase\FleetOps\Orchestration\Contracts\OrchestrationEngineInterface; use Fleetbase\FleetOps\Orchestration\Support\OrchestrationPayloadBuilder; +use Fleetbase\FleetOps\Support\Routing\HostPolicy; use Fleetbase\Models\Setting; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Http; @@ -558,8 +559,9 @@ protected function resolveVroomEndpointMode(): string protected function resolveVroomSetting(string $key, $default = null) { try { - $organizationValue = data_get(Setting::lookupForCompany($this->companyUuid ?? session('company'), 'vroom', []), $key); - if ($this->hasConfiguredValue($organizationValue)) { + $organizationSettings = Setting::lookupForCompany($this->companyUuid ?? session('company'), 'vroom', []); + $organizationValue = data_get($organizationSettings, $key); + if ($this->hasConfiguredValue($organizationValue) && $this->organizationHostAllowed($organizationSettings)) { return $organizationValue; } @@ -574,6 +576,23 @@ protected function resolveVroomSetting(string $key, $default = null) return $default; } + /** + * An organization override whose host the server must not call (a private, + * loopback or metadata address) is ignored as a whole (host, key and endpoint + * mode), so the system server is used instead. + */ + protected function organizationHostAllowed($organizationSettings): bool + { + $host = data_get($organizationSettings, 'api_host'); + if (!$this->hasConfiguredValue($host)) { + return true; + } + + $allowPrivateHosts = filter_var(config('vroom.allow_private_hosts', env('VROOM_ALLOW_PRIVATE_HOSTS', false)), FILTER_VALIDATE_BOOLEAN); + + return HostPolicy::allows((string) $host, $allowPrivateHosts); + } + protected function hasConfiguredValue($value): bool { if (is_string($value)) { diff --git a/server/src/Support/Routing/HostPolicy.php b/server/src/Support/Routing/HostPolicy.php new file mode 100644 index 000000000..9b5c51a1c --- /dev/null +++ b/server/src/Support/Routing/HostPolicy.php @@ -0,0 +1,157 @@ + list of IP addresses. + */ + protected static ?\Closure $hostResolver = null; + + /** + * Why the URL may not be used as an organization host, or null when it may. + */ + public static function violation(?string $url, bool $allowPrivateHosts = false): ?string + { + $parts = parse_url(trim((string) $url)); + if (!is_array($parts) || !in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true) || empty($parts['host'])) { + return 'The routing host must be an http or https URL.'; + } + + if (isset($parts['user']) || isset($parts['pass'])) { + return 'The routing host must not contain credentials.'; + } + + if ($allowPrivateHosts) { + return null; + } + + $host = trim($parts['host'], '[]'); + $addresses = filter_var($host, FILTER_VALIDATE_IP) ? [$host] : static::resolve($host); + if (empty($addresses)) { + return 'The routing host could not be resolved.'; + } + + foreach ($addresses as $address) { + if (!static::isPublicAddress($address)) { + return 'The routing host must not point to a private, loopback or reserved address.'; + } + } + + return null; + } + + public static function allows(?string $url, bool $allowPrivateHosts = false): bool + { + return static::violation($url, $allowPrivateHosts) === null; + } + + public static function isPublicAddress(string $address): bool + { + $packed = @inet_pton($address); + if ($packed === false) { + return false; + } + + foreach (static::BLOCKED_RANGES as $range) { + if (static::inRange($packed, $range)) { + return false; + } + } + + return true; + } + + public static function resolveHostsUsing(?\Closure $resolver): void + { + static::$hostResolver = $resolver; + } + + /** + * Every IPv4 and IPv6 address the hostname resolves to. + * + * @return string[] + */ + protected static function resolve(string $host): array + { + if (static::$hostResolver) { + return (array) call_user_func(static::$hostResolver, $host); + } + + $ipv4 = @gethostbynamel($host) ?: []; + $ipv6 = array_column(@dns_get_record($host, DNS_AAAA) ?: [], 'ipv6'); + + return array_values(array_unique(array_filter([...$ipv4, ...$ipv6]))); + } + + protected static function inRange(string $packed, string $range): bool + { + [$subnet, $bits] = explode('/', $range); + $subnet = inet_pton($subnet); + if (strlen($subnet) !== strlen($packed)) { + return false; + } + + $bits = (int) $bits; + $bytes = intdiv($bits, 8); + if (substr($packed, 0, $bytes) !== substr($subnet, 0, $bytes)) { + return false; + } + + $remainder = $bits % 8; + if ($remainder === 0) { + return true; + } + + $mask = chr((0xFF << (8 - $remainder)) & 0xFF); + + return (ord($packed[$bytes]) & ord($mask)) === (ord($subnet[$bytes]) & ord($mask)); + } +} diff --git a/server/src/Support/Routing/ValhallaClient.php b/server/src/Support/Routing/ValhallaClient.php index bac1305b6..6dcb68c96 100644 --- a/server/src/Support/Routing/ValhallaClient.php +++ b/server/src/Support/Routing/ValhallaClient.php @@ -14,6 +14,8 @@ * same way: the organization's own `valhalla` setting, then the system setting * from Admin → Fleet-Ops → Routing, then `fleetops.valhalla.*` (VALHALLA_HOST / * VALHALLA_BASE_URI / VALHALLA_API_KEY). The key is sent as the `X-Api-Key` header. + * An organization host must pass the HostPolicy (public addresses only, unless + * VALHALLA_ALLOW_PRIVATE_HOSTS is set), or the organization override is ignored. */ class ValhallaClient { @@ -35,7 +37,7 @@ public static function connection(?string $companyUuid = null): ?array { $settings = Cache::remember('fleetops:valhalla-connection:' . ($companyUuid ?? 'system'), static::CACHE_TTL, function () use ($companyUuid) { return [ - static::lookup(fn () => Setting::lookupForCompany($companyUuid, 'valhalla', [])), + static::organizationLevel(static::lookup(fn () => Setting::lookupForCompany($companyUuid, 'valhalla', []))), static::lookup(fn () => Setting::lookup('valhalla', [])), ]; }); @@ -102,6 +104,23 @@ public static function route(array $points, array $options = [], ?string $compan return $trip; } + /** + * An organization override whose host the server must not call (a private, + * loopback or metadata address) is dropped with its key, so the system + * server is used instead. + */ + protected static function organizationLevel(array $settings): array + { + $host = trim((string) data_get($settings, 'api_host')); + if ($host === '') { + return $settings; + } + + $allowPrivateHosts = filter_var(config('fleetops.valhalla.allow_private_hosts', false), FILTER_VALIDATE_BOOLEAN); + + return HostPolicy::allows($host, $allowPrivateHosts) ? $settings : []; + } + protected static function lookup(callable $lookup): array { try { diff --git a/server/tests/Unit/Orchestration/VroomEngineTransportAndSettingsTest.php b/server/tests/Unit/Orchestration/VroomEngineTransportAndSettingsTest.php index 40fefa5d8..2b6522dd6 100644 --- a/server/tests/Unit/Orchestration/VroomEngineTransportAndSettingsTest.php +++ b/server/tests/Unit/Orchestration/VroomEngineTransportAndSettingsTest.php @@ -13,6 +13,7 @@ use Fleetbase\FleetOps\Models\Place; use Fleetbase\FleetOps\Models\Vehicle; use Fleetbase\FleetOps\Orchestration\Engines\VroomOrchestrationEngine; +use Fleetbase\FleetOps\Support\Routing\HostPolicy; use Fleetbase\LaravelMysqlSpatial\Types\Point; use Illuminate\Database\ConnectionResolver; use Illuminate\Database\Eloquent\Model as EloquentModel; @@ -192,6 +193,58 @@ function fleetopsVroomVehicle(string $publicId, Point $location): Vehicle ->toThrow(RuntimeException::class, 'VROOM returned an error'); }); +beforeEach(function () { + // Example hosts resolve to a public address unless a test says otherwise. + HostPolicy::resolveHostsUsing(fn (string $host) => str_starts_with($host, 'internal.') ? ['10.0.0.12'] : ['93.184.216.34']); +}); + +afterEach(function () { + HostPolicy::resolveHostsUsing(null); + config(['vroom.allow_private_hosts' => null]); +}); + +test('connection settings ignore an organization host the server must not call', function (string $host) { + $connection = fleetopsVroomBoot(); + $connection->table('settings')->insert([ + ['key' => 'company.company-1.vroom', 'value' => json_encode(['api_host' => $host, 'endpoint_mode' => 'binary', 'api_key' => 'org-key'])], + ['key' => 'vroom', 'value' => json_encode(['api_host' => 'https://vroom.routing.fleetbase.test', 'endpoint_mode' => 'saas', 'api_key' => 'sys-key'])], + ]); + + $engine = new VroomOrchestrationEngine(); + $call = function (string $method) use ($engine) { + $reflection = new ReflectionMethod(VroomOrchestrationEngine::class, $method); + $reflection->setAccessible(true); + + return $reflection->invoke($engine); + }; + + expect($call('resolveVroomBaseUri'))->toBe('https://vroom.routing.fleetbase.test') + ->and($call('resolveVroomEndpointMode'))->toBe('saas') + ->and($call('resolveVroomApiKey'))->toBe('sys-key'); + + // Self-hosted installs can opt in to private organization hosts + config(['vroom.allow_private_hosts' => true]); + $expected = str_starts_with($host, 'file:') ? 'https://vroom.routing.fleetbase.test' : $host; + expect($call('resolveVroomBaseUri'))->toBe($expected); +})->with([ + 'cloud metadata' => 'http://169.254.169.254/latest/meta-data', + 'resolves to private' => 'https://internal.vroom.test', + 'file scheme' => 'file:///etc/passwd', +]); + +test('an organization key without its own host is used with the system host', function () { + $connection = fleetopsVroomBoot(); + $connection->table('settings')->insert([ + ['key' => 'company.company-1.vroom', 'value' => json_encode(['api_key' => 'org-key'])], + ['key' => 'vroom', 'value' => json_encode(['api_host' => 'https://vroom.routing.fleetbase.test', 'api_key' => 'sys-key'])], + ]); + + $reflection = new ReflectionMethod(VroomOrchestrationEngine::class, 'resolveVroomApiKey'); + $reflection->setAccessible(true); + + expect($reflection->invoke(new VroomOrchestrationEngine()))->toBe('org-key'); +}); + test('connection settings resolve organization then system values', function () { $connection = fleetopsVroomBoot(); $connection->table('settings')->insert([ diff --git a/server/tests/Unit/Support/Routing/HostPolicyTest.php b/server/tests/Unit/Support/Routing/HostPolicyTest.php new file mode 100644 index 000000000..e407422c3 --- /dev/null +++ b/server/tests/Unit/Support/Routing/HostPolicyTest.php @@ -0,0 +1,75 @@ + [ + 'routing.example.com' => ['93.184.216.34', '2606:2800:220:1:248:1893:25c8:1946'], + 'internal.example.com' => ['10.0.0.12'], + 'mixed.example.com' => ['93.184.216.34', '169.254.169.254'], + 'mapped.example.com' => ['::ffff:127.0.0.1'], + 'garbage.example.com' => ['not-an-ip'], + ][$host] ?? []); +}); + +afterEach(function () { + HostPolicy::resolveHostsUsing(null); +}); + +test('public http and https hosts are allowed', function (string $url) { + expect(HostPolicy::violation($url))->toBeNull() + ->and(HostPolicy::allows($url))->toBeTrue(); +})->with([ + 'public hostname' => 'https://routing.example.com/valhalla', + 'http with port' => 'http://routing.example.com:8002', + 'public IPv4 literal' => 'https://93.184.216.34', + 'public IPv6 literal' => 'https://[2606:4700:4700::1111]/route', +]); + +test('hosts the server must not call are rejected', function (?string $url) { + expect(HostPolicy::violation($url))->toBeString() + ->and(HostPolicy::allows($url))->toBeFalse(); +})->with([ + 'loopback' => 'http://127.0.0.1:8002', + 'localhost IPv6' => 'http://[::1]:8002', + 'cloud metadata' => 'http://169.254.169.254/latest/meta-data', + 'private 10/8' => 'http://10.1.2.3', + 'private 172.16/12' => 'http://172.20.0.5', + 'private 192.168/16' => 'http://192.168.1.1', + 'carrier-grade NAT' => 'http://100.64.0.1', + 'unspecified' => 'http://0.0.0.0', + 'multicast' => 'http://224.0.0.1', + 'unique local IPv6' => 'http://[fd00::1]', + 'link-local IPv6' => 'http://[fe80::1]', + 'IPv4-mapped loopback' => 'http://[::ffff:127.0.0.1]', + 'NAT64 loopback' => 'http://[64:ff9b::7f00:1]', + 'resolves to private' => 'https://internal.example.com', + 'one address is private' => 'https://mixed.example.com', + 'resolves to mapped' => 'https://mapped.example.com', + 'resolves to garbage' => 'https://garbage.example.com', + 'does not resolve' => 'https://nowhere.example.com', + 'file scheme' => 'file:///etc/passwd', + 'gopher scheme' => 'gopher://routing.example.com', + 'no scheme' => 'routing.example.com', + 'credentials' => 'https://user:pass@routing.example.com', + 'not a URL' => 'http://', + 'nothing' => null, +]); + +test('self-hosted installs can allow private hosts, but not other schemes or credentials', function () { + expect(HostPolicy::violation('http://valhalla:8002', true))->toBeNull() + ->and(HostPolicy::violation('http://10.0.0.12:8002', true))->toBeNull() + ->and(HostPolicy::violation('file:///etc/passwd', true))->toBeString() + ->and(HostPolicy::violation('http://user:pass@10.0.0.12', true))->toBeString(); +}); + +test('hostnames are resolved through DNS when no resolver is set', function () { + HostPolicy::resolveHostsUsing(null); + + expect(HostPolicy::allows('http://localhost:8002'))->toBeFalse() + ->and(HostPolicy::allows('http://2130706433'))->toBeFalse(); +}); diff --git a/server/tests/Unit/Support/RoutingEngineResolverTest.php b/server/tests/Unit/Support/RoutingEngineResolverTest.php index b31ed1473..dbf5b5842 100644 --- a/server/tests/Unit/Support/RoutingEngineResolverTest.php +++ b/server/tests/Unit/Support/RoutingEngineResolverTest.php @@ -1,5 +1,6 @@ str_starts_with($host, 'internal.') ? ['10.0.0.12'] : ['93.184.216.34']); +}); + afterEach(function () { + HostPolicy::resolveHostsUsing(null); + config(['fleetops.valhalla.allow_private_hosts' => false]); config([ 'fleetops.distance_matrix.provider' => null, 'fleetops.osrm.host' => null, @@ -587,6 +595,29 @@ function fleetopsDistanceProviderPolicy(array $policy): void ->and(fn () => ValhallaClient::route([]))->toThrow(RuntimeException::class, 'No Valhalla server is configured.'); }); +test('valhalla ignores an organization host the server must not call and uses the system server', function (string $host) { + $connection = fleetopsDistanceProviderBoot(); + $connection->table('settings')->insert(['key' => 'valhalla', 'value' => json_encode(['api_host' => 'https://valhalla.routing.fleetbase.test', 'api_key' => 'system-key'])]); + $connection->table('settings')->insert(['key' => 'company.company-1.valhalla', 'value' => json_encode(['api_host' => $host, 'api_key' => 'org-key'])]); + Cache::flush(); + + expect(ValhallaClient::connection('company-1'))->toBe(['host' => 'https://valhalla.routing.fleetbase.test', 'api_key' => 'system-key']); +})->with([ + 'cloud metadata' => 'http://169.254.169.254/latest/meta-data', + 'loopback' => 'http://127.0.0.1:8002', + 'resolves to private' => 'https://internal.valhalla.test', + 'file scheme' => 'file:///etc/passwd', +]); + +test('valhalla allows private organization hosts when the install opts in', function () { + $connection = fleetopsDistanceProviderBoot(); + $connection->table('settings')->insert(['key' => 'company.company-1.valhalla', 'value' => json_encode(['api_host' => 'http://10.0.0.12:8002', 'api_key' => 'org-key'])]); + config(['fleetops.valhalla.allow_private_hosts' => true]); + Cache::flush(); + + expect(ValhallaClient::connection('company-1'))->toBe(['host' => 'http://10.0.0.12:8002', 'api_key' => 'org-key']); +}); + test('the orchestrator falls back to the built-in engine when vroom is switched off', function () { $connection = fleetopsDistanceProviderBoot(); $connection->table('settings')->insert(['key' => 'company.company-1.' . Fleetbase\FleetOps\Orchestration\Support\OrchestratorSettings::KEY, 'value' => json_encode(['allocation_engine' => 'vroom'])]); diff --git a/server/tests/Unit/Tracking/TrackingOptionsCompanySettingsTest.php b/server/tests/Unit/Tracking/TrackingOptionsCompanySettingsTest.php index c1c2ba7cd..81d8dc923 100644 --- a/server/tests/Unit/Tracking/TrackingOptionsCompanySettingsTest.php +++ b/server/tests/Unit/Tracking/TrackingOptionsCompanySettingsTest.php @@ -6,6 +6,7 @@ use Fleetbase\FleetOps\Models\Order; use Fleetbase\FleetOps\Support\OrderTracker; +use Fleetbase\FleetOps\Support\Routing\HostPolicy; use Fleetbase\FleetOps\Tracking\TrackingIntelligenceService; use Fleetbase\FleetOps\Tracking\TrackingOptions; use Illuminate\Database\ConnectionResolver; @@ -18,6 +19,15 @@ * session company remains the fallback. The provider is the company's routing * engine; the company's tracking settings only tune behaviour. */ +beforeEach(function () { + // The example organization Valhalla host resolves to a public address. + HostPolicy::resolveHostsUsing(fn () => ['93.184.216.34']); +}); + +afterEach(function () { + HostPolicy::resolveHostsUsing(null); +}); + function fleetopsTrackingOptionsBoot(): SQLiteConnection { $connection = new SQLiteConnection(new PDO('sqlite::memory:')); From e9abc9b76d648c5998cdb8b239943f8967172a8a Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 24 Oct 2026 20:06:52 +0800 Subject: [PATCH 17/22] feat(navigator): App Review access, so app store reviewers can sign in to Navigator without SMS An organization turns it on in Settings > Navigator App > App Review. Reviewers sign in with +1 202 555 0144 (from the 555-01xx range reserved for fiction, never texted) and the organization's six-digit code, through core-api's App Review access: FleetOps registers the organization as the navigator type. The first sign-in adds an App Reviewer driver to that organization, which ad-hoc offers, nearest-driver assignment and the orchestrator leave out. The NAVIGATOR_* env review accounts still work. Requires core-api ^1.6.74. --- RELEASE.md | 5 +- .../navigator-app-review-settings.hbs | 75 +++++++++ .../navigator-app-review-settings.js | 99 ++++++++++++ addon/templates/settings/navigator-app.hbs | 1 + .../navigator-app-review-settings.js | 1 + composer.json | 2 +- docs/NAVIGATOR_APP_REVIEW.md | 67 ++++++++ .../Console/Commands/DispatchAdhocOrders.php | 4 + .../Controllers/Api/v1/DriverController.php | 52 ++++++ server/src/Models/Order.php | 4 +- .../Engines/DriverAssignmentEngine.php | 4 +- .../src/Providers/FleetOpsServiceProvider.php | 13 ++ server/src/Support/NavigatorReview.php | 128 +++++++++++++++ .../Api/DriverControllerAuthFlowsTest.php | 153 ++++++++++++++++++ .../Support/ProfileAccountManagerTest.php | 38 +++++ translations/en-us.yaml | 23 +++ 16 files changed, 664 insertions(+), 5 deletions(-) create mode 100644 addon/components/navigator-app-review-settings.hbs create mode 100644 addon/components/navigator-app-review-settings.js create mode 100644 app/components/navigator-app-review-settings.js create mode 100644 docs/NAVIGATOR_APP_REVIEW.md create mode 100644 server/src/Support/NavigatorReview.php diff --git a/RELEASE.md b/RELEASE.md index 123c1667e..40699eaa7 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,4 +1,4 @@ -> v0.6.75 ~ "Safer integrations and tenant-isolated profile accounts" +> v0.6.75 ~ "Safer integrations, tenant-isolated profile accounts and App Review access for Navigator" --- ## Highlights @@ -10,10 +10,11 @@ - **Fix: `scheduled_at` keeps its UTC offset.** `2026-10-21T11:00:00-07:00` is now stored as 18:00 UTC instead of 11:00, and `PUT /v1/orders/{id}/schedule` stores the UTC instant of a date and time in the company timezone. Rows written before this keep their old values. (#412) - **Fix: service quotes.** The `currency` filter works again, stored quotes expire after 48 hours by default (`fleetops.service_quotes.ttl_minutes`), and `preview=1` prices a request without storing quote rows. (#412) - **Fix: tracking numbers with a space can be looked up again.** Since v0.6.72, a number such as `AL 7796486490AE`, generated for a company whose name has a space in its first three letters, got "not found". Single inner spaces are accepted now. New numbers only use letters and digits: the company prefix skips spaces and punctuation ("Al Noor" gives `ALN`), and a region such as "New York" becomes `NEWYORK`. (#413, #416) +- **Feature: App Review access for Navigator.** Apple and Google reviewers can't receive the texted sign-in code. An organization can turn on **Settings → Navigator App → App Review**: reviewers sign in to its Navigator with the test number +1 202 555 0144 (a range kept for fiction, never texted) and a six-digit code the owner creates and enters in App Store Connect and Google Play Console. The code is stored hashed, shown once, and only works for that organization. The first sign-in adds an **App Reviewer** driver, which ad-hoc offers, nearest-driver assignment and the orchestrator always leave out. Built on core-api's App Review access; the `NAVIGATOR_*` review env settings still work on self-hosted installs. See `docs/NAVIGATOR_APP_REVIEW.md`. (#PR) --- ## Upgrading -- No migrations or commands. +- **Requires fleetbase/core-api v1.6.74** (`composer.json` now requires `^1.6.74`), which adds App Review access and its `review_accesses` table: run `php artisan migrate`. --- ## Need help? diff --git a/addon/components/navigator-app-review-settings.hbs b/addon/components/navigator-app-review-settings.hbs new file mode 100644 index 000000000..4802c6a7f --- /dev/null +++ b/addon/components/navigator-app-review-settings.hbs @@ -0,0 +1,75 @@ +{{! App Review access for the Navigator app: the test number and code Apple and Google reviewers sign in with. }} + +
+
+

{{t "navigator-app-review-settings.lede"}}

+ {{#if this.status}} + + {{/if}} +
+ + {{#if this.load.isRunning}} +
+ {{else if this.status}} + + {{this.status.phone_display}} + + + {{#if this.code}} + {{this.code}} +

{{t "navigator-app-review-settings.code-new"}}

+ {{else if this.status.has_code}} +
••••••
+

{{t "navigator-app-review-settings.code-hidden" date=this.codeCreatedAt}}

+ {{else}} +

{{t "navigator-app-review-settings.code-none"}}

+ {{/if}} +
+ +
    +
  • {{t "navigator-app-review-settings.where"}}
  • +
  • {{t "navigator-app-review-settings.driver"}}
  • +
+ +
+ {{#if this.enabled}} +
+ {{/if}} +
+
\ No newline at end of file diff --git a/addon/components/navigator-app-review-settings.js b/addon/components/navigator-app-review-settings.js new file mode 100644 index 000000000..77baddf90 --- /dev/null +++ b/addon/components/navigator-app-review-settings.js @@ -0,0 +1,99 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { task } from 'ember-concurrency'; + +/** + * App Review access for the organization's Navigator app: lets Apple and Google reviewers sign + * in with a fixed test number (never texted) and a code the owner gives the app store. Core + * (`int/v1/review-access/navigator`) keeps the code hashed, so it is shown only in the + * response that created it. + */ +export default class NavigatorAppReviewSettingsComponent extends Component { + @service fetch; + @service intl; + @service currentUser; + @service notifications; + @service modalsManager; + + @tracked status = null; + @tracked code = null; + + permission = 'fleet-ops update navigator-settings'; + + constructor() { + super(...arguments); + this.load.perform(); + } + + get endpoint() { + return `review-access/navigator/${this.currentUser.companyId}`; + } + + get enabled() { + return this.status?.enabled === true; + } + + get codeCreatedAt() { + const at = this.status?.code_generated_at; + return at ? new Date(at).toLocaleString() : null; + } + + @task *load() { + if (!this.currentUser.companyId) return; + + try { + const { review_access } = yield this.fetch.get(this.endpoint, {}, { namespace: 'int/v1' }); + this.status = review_access; + } catch (error) { + this.notifications.serverError(error); + } + } + + @task *save(payload, message) { + try { + const { review_access } = yield this.fetch.put(this.endpoint, payload, { namespace: 'int/v1' }); + const { code, ...status } = review_access; + this.status = status; + if (code) this.code = code; + if (!status.enabled) this.code = null; + this.notifications.success(message); + } catch (error) { + this.notifications.serverError(error); + } + } + + @action turnOn() { + this.save.perform({ enabled: true }, this.intl.t('navigator-app-review-settings.turned-on')); + } + + @action turnOff() { + this.modalsManager.confirm({ + title: this.intl.t('navigator-app-review-settings.turn-off-title'), + body: this.intl.t('navigator-app-review-settings.turn-off-body'), + acceptButtonText: this.intl.t('navigator-app-review-settings.turn-off'), + acceptButtonType: 'danger', + confirm: async (modal) => { + modal.startLoading(); + await this.save.perform({ enabled: false }, this.intl.t('navigator-app-review-settings.turned-off')); + modal.done(); + }, + }); + } + + @action regenerate() { + this.modalsManager.confirm({ + title: this.intl.t('navigator-app-review-settings.regenerate-title'), + body: this.intl.t('navigator-app-review-settings.regenerate-body'), + acceptButtonText: this.intl.t('navigator-app-review-settings.regenerate'), + acceptButtonType: 'danger', + acceptButtonIcon: 'rotate', + confirm: async (modal) => { + modal.startLoading(); + await this.save.perform({ regenerate_code: true }, this.intl.t('navigator-app-review-settings.regenerated')); + modal.done(); + }, + }); + } +} diff --git a/addon/templates/settings/navigator-app.hbs b/addon/templates/settings/navigator-app.hbs index c1f486519..037b9f569 100644 --- a/addon/templates/settings/navigator-app.hbs +++ b/addon/templates/settings/navigator-app.hbs @@ -5,6 +5,7 @@
+
diff --git a/app/components/navigator-app-review-settings.js b/app/components/navigator-app-review-settings.js new file mode 100644 index 000000000..ae7f4e063 --- /dev/null +++ b/app/components/navigator-app-review-settings.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/navigator-app-review-settings'; diff --git a/composer.json b/composer.json index e59cb943f..924dc7d61 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "barryvdh/laravel-dompdf": "^3.1", "brick/geo": "0.7.2", "cknow/laravel-money": "^7.1", - "fleetbase/core-api": "^1.6.69", + "fleetbase/core-api": "^1.6.74", "geocoder-php/google-maps-places-provider": "^1.4", "giggsey/libphonenumber-for-php": "^8.13", "league/geotools": "^1.1.0", diff --git a/docs/NAVIGATOR_APP_REVIEW.md b/docs/NAVIGATOR_APP_REVIEW.md new file mode 100644 index 000000000..b7c3107e4 --- /dev/null +++ b/docs/NAVIGATOR_APP_REVIEW.md @@ -0,0 +1,67 @@ +# Navigator App Review access + +Apple and Google test every app before it goes on sale. Navigator signs drivers in with a +code sent by text message, and app store reviewers can't receive one. Apple often rejects an +app its reviewers can't sign in to. + +App Review access gives reviewers a test phone number and a sign-in code that you enter in +App Store Connect and Google Play Console. Nobody is sent a text message. + +## Turn it on + +1. Open **Fleet-Ops → Settings → Navigator App** and find **App Review**. +2. Choose **Turn on**. A six-digit sign-in code is created. +3. Copy the code straight away. It is only shown once. +4. Enter the phone number and the code: + - **App Store Connect:** your app's version page, under **App Review Information → + Sign-in required**. Enter the phone number as the user name and the code as the + password. + - **Google Play Console:** **App content → App access**, as instructions with the phone + number and the code. + +The phone number is **+1 202 555 0144**. It belongs to a range kept for films and books, so it +can't reach a real person, and it is never sent a message. Every organization uses the same +number; the code decides which organization it signs in to, and one organization's code +doesn't work for another. + +## What reviewers see + +The reviewer signs in like a driver: they enter the phone number, Navigator asks for a code, +and they enter yours. The first time, a driver called **App Reviewer** is added to your +drivers list. It is never offered or assigned orders automatically (ad-hoc offers, nearest +driver and the orchestrator all leave it out). Assign an order to it by hand if you want the +reviewer to see one. + +## Create a new code or turn it off + +- **Create new code** replaces the code. The old one stops working at once. +- **Turn off** stops reviewers signing in. Turning it back on restores the same code, unless + you created a new one. + +Changing it needs the **update navigator-settings** permission; administrators always can. + +## Self-hosted installs + +The instance-wide review accounts still work alongside each organization's setting: + +``` +NAVIGATOR_BYPASS_VERIFICATION_CODE= +NAVIGATOR_REVIEW_ACCOUNTS=reviewer@example.com,+12025550100 +``` + +## For developers + +The mechanism is core-api's App Review access (`Fleetbase\Support\AppReview`, core-api +1.6.74 or later). FleetOps registers the organization (`Company`) as the `navigator` type in +`FleetOpsServiceProvider` (`Fleetbase\FleetOps\Support\NavigatorReview`). The Console uses +core's endpoints, scoped to the signed-in organization: + +``` +GET /int/v1/review-access/navigator/{company uuid or public id} +PUT /int/v1/review-access/navigator/{company uuid or public id} {enabled?, regenerate_code?} +``` + +The response is `{"review_access": {type, enabled, phone, phone_display, has_code, +code_generated_at, enabled_at}}`, plus `code` only in the response that created it. +`POST /v1/drivers/login-with-sms` and `POST /v1/drivers/verify-code` (the API credential's +organization) accept the review number and the organization's code. diff --git a/server/src/Console/Commands/DispatchAdhocOrders.php b/server/src/Console/Commands/DispatchAdhocOrders.php index fbdd79ac8..31e91f342 100644 --- a/server/src/Console/Commands/DispatchAdhocOrders.php +++ b/server/src/Console/Commands/DispatchAdhocOrders.php @@ -5,6 +5,7 @@ use Fleetbase\FleetOps\Models\Driver; use Fleetbase\FleetOps\Models\Order; use Fleetbase\FleetOps\Notifications\OrderPing; +use Fleetbase\FleetOps\Support\NavigatorReview; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\LaravelMysqlSpatial\Types\Point; use Illuminate\Console\Command; @@ -134,6 +135,9 @@ public function getNearbyDriversForOrder(Order $order, Point $pickup, int $dista ->whereNull('deleted_at') ->withoutGlobalScopes(); + // An app store reviewer's driver is never offered a real order. + NavigatorReview::excludeFrom($driverQuery); + if (!$testing) { $driverQuery->whereNotNull('location')->whereRaw(' ST_Y(location) BETWEEN -90 AND 90 diff --git a/server/src/Http/Controllers/Api/v1/DriverController.php b/server/src/Http/Controllers/Api/v1/DriverController.php index cdb187245..fd006cb2c 100644 --- a/server/src/Http/Controllers/Api/v1/DriverController.php +++ b/server/src/Http/Controllers/Api/v1/DriverController.php @@ -25,6 +25,7 @@ use Fleetbase\FleetOps\Support\ApiCacheThrottle; use Fleetbase\FleetOps\Support\Geocoding; use Fleetbase\FleetOps\Support\GeofenceIntersectionService; +use Fleetbase\FleetOps\Support\NavigatorReview; use Fleetbase\FleetOps\Support\OSRM; use Fleetbase\FleetOps\Support\ProfileAccountManager; use Fleetbase\FleetOps\Support\TrackingPublisher; @@ -581,6 +582,16 @@ public function loginWithPhone() { $phone = static::phone(); + // An app store reviewer is sent nothing: they sign in with the code the organization + // set up for its Navigator (Settings → Navigator App), and verify-code creates the + // organization's review driver the first time. The number is refused, still unsent, + // where that access is off. + if (NavigatorReview::isReviewPhone($phone) && !static::verificationBypassListed($phone)) { + return NavigatorReview::isEnabledFor($this->navigatorReviewCompany()) + ? response()->json(['status' => 'OK', 'method' => 'sms']) + : response()->apiError(NavigatorReview::OFF_MESSAGE, 422); + } + // check if user exists $user = User::where('phone', $phone)->whereHas('driver')->whereNull('deleted_at')->first(); if (!$user) { @@ -655,6 +666,14 @@ public function verifyCode(Request $request) return $this->create($request); } + // The organization's App Review code signs the review number in as its review driver. + if (NavigatorReview::isReviewPhone($identity)) { + $reviewCompany = $this->navigatorReviewCompany(); + if (NavigatorReview::acceptsCode($reviewCompany, $identity, $code)) { + return $this->issueDriverToken(NavigatorReview::driver($reviewCompany)); + } + } + // check if user exists $user = User::whereHas('driver')->where(function ($query) use ($identity) { $query->where('phone', $identity); @@ -686,6 +705,20 @@ public function verifyCode(Request $request) ] )->first(); + return $this->issueDriverToken($driver, $user); + } + + /** + * Sign a driver in: a new access token for its account, stored on the driver and returned + * with the driver resource. + */ + protected function issueDriverToken(?Driver $driver, ?User $user = null) + { + $user ??= $driver ? User::where('uuid', $driver->user_uuid)->first() : null; + if (!$driver || !$user) { + return response()->apiError('Unable to authenticate driver.'); + } + // generate auth token try { $token = $user->createToken($driver->uuid); @@ -1007,6 +1040,16 @@ protected function sessionCompany(): ?string return session('company'); } + /** + * The organization whose Navigator the request is for: the API credential's. + */ + protected function navigatorReviewCompany(): ?Company + { + $uuid = $this->sessionCompany(); + + return $uuid ? Company::where('uuid', $uuid)->first() : null; + } + protected function applyUserInfoFromRequest(Request $request, array $userDetails): array { return User::applyUserInfoFromRequest($request, $userDetails); @@ -1178,6 +1221,15 @@ private static function getDriverCompanyFromUser(User $user): ?Company * Shared with Internal\v1\DriverController so both verify-code paths cannot * drift apart. */ + /** + * Whether an identity is a self-hosted review account listed in env config. + */ + public static function verificationBypassListed(?string $identity): bool + { + return NavigatorReview::isAvailable() + && \Fleetbase\Support\AppReview::isConfiguredAccount('fleetops.navigator.bypass_verification_code', 'fleetops.navigator.review_accounts', $identity); + } + public static function verificationBypassMatches(?string $identity, ?string $code): bool { return static::reviewAccountBypassMatches( diff --git a/server/src/Models/Order.php b/server/src/Models/Order.php index 7afd523b8..74e8162e4 100644 --- a/server/src/Models/Order.php +++ b/server/src/Models/Order.php @@ -12,6 +12,7 @@ use Fleetbase\FleetOps\Flow\Activity; use Fleetbase\FleetOps\Notifications\OrderUnassigned; use Fleetbase\FleetOps\Support\LabelPdf; +use Fleetbase\FleetOps\Support\NavigatorReview; use Fleetbase\FleetOps\Support\OrderEditPolicy; use Fleetbase\FleetOps\Support\OrderTracker; use Fleetbase\FleetOps\Support\Utils; @@ -2314,7 +2315,8 @@ public function findClosestDrivers(int $distance = 6000): Collection return collect(); } - $drivers = Driver::where(['status' => 'available', 'online' => 1]) + // An app store reviewer's driver is never assigned a real order. + $drivers = NavigatorReview::excludeFrom(Driver::where(['status' => 'available', 'online' => 1])) ->whereHas('company', function ($q) { $q->whereHas('users', function ($q) { $q->whereHas('driver', function ($q) { diff --git a/server/src/Orchestration/Engines/DriverAssignmentEngine.php b/server/src/Orchestration/Engines/DriverAssignmentEngine.php index 4bc26cc02..5ea1c9f19 100644 --- a/server/src/Orchestration/Engines/DriverAssignmentEngine.php +++ b/server/src/Orchestration/Engines/DriverAssignmentEngine.php @@ -5,6 +5,7 @@ use Fleetbase\FleetOps\Models\Driver; use Fleetbase\FleetOps\Models\Order; use Fleetbase\FleetOps\Models\Vehicle; +use Fleetbase\FleetOps\Support\NavigatorReview; use Illuminate\Support\Collection; /** @@ -242,7 +243,8 @@ protected function findBestDriver( protected function availableDriversForCompany(?string $companyUuid): Collection { - return Driver::where('company_uuid', $companyUuid) + // An app store reviewer's driver is never assigned a real order. + return NavigatorReview::excludeFrom(Driver::where('company_uuid', $companyUuid)) ->with(['scheduleItems']) ->get(); } diff --git a/server/src/Providers/FleetOpsServiceProvider.php b/server/src/Providers/FleetOpsServiceProvider.php index bb24c8932..1f55a713d 100644 --- a/server/src/Providers/FleetOpsServiceProvider.php +++ b/server/src/Providers/FleetOpsServiceProvider.php @@ -4,6 +4,7 @@ use Brick\Geo\Engine\GeometryEngineRegistry; use Brick\Geo\Engine\GEOSEngine; +use Fleetbase\FleetOps\Support\NavigatorReview; use Fleetbase\Providers\CoreServiceProvider; use Fleetbase\Support\NotificationRegistry; use Fleetbase\Support\Utils; @@ -160,6 +161,7 @@ public function boot() $this->registerNotifications(); $this->registerAiCapabilities(); $this->registerSocketChannels(); + $this->registerAppReviewTypes(); $this->registerExpansionsFrom(__DIR__ . '/../Expansions'); // Register built-in orchestration engines. @@ -317,4 +319,15 @@ protected function registerAiCapabilities(): void } }); } + + /** + * Registers the organization's Navigator app with core-api's App Review access, so its + * owner can let app store reviewers sign in to Navigator without a texted code. + */ + public function registerAppReviewTypes(): void + { + if (NavigatorReview::isAvailable()) { + NavigatorReview::register(); + } + } } diff --git a/server/src/Support/NavigatorReview.php b/server/src/Support/NavigatorReview.php new file mode 100644 index 000000000..4dca85d44 --- /dev/null +++ b/server/src/Support/NavigatorReview.php @@ -0,0 +1,128 @@ + Company::class, + 'phone' => static::PHONE, + 'name' => 'Navigator', + 'permission' => 'fleet-ops update navigator-settings', + 'view_permission' => 'fleet-ops view navigator-settings', + ]); + } + + public static function isAvailable(): bool + { + return class_exists(AppReview::class); + } + + /** + * Whether an identity is Navigator's review number, however it is formatted. + */ + public static function isReviewPhone(?string $identity): bool + { + if (blank($identity)) { + return false; + } + + return preg_replace('/\D+/', '', $identity) === preg_replace('/\D+/', '', static::PHONE); + } + + public static function isEnabledFor(?Company $company): bool + { + return static::isAvailable() && AppReview::isEnabledFor($company); + } + + /** + * Whether a code signs the review number in to this organization's Navigator. + */ + public static function acceptsCode(?Company $company, ?string $identity, mixed $code): bool + { + return static::isAvailable() && static::isReviewPhone($identity) && AppReview::acceptsCode($company, static::PHONE, $code); + } + + /** + * The organization's review driver, created the first time a reviewer signs in. Each + * organization has its own: an account of one organization is never linked to another. + */ + public static function driver(Company $company): Driver + { + $driver = Driver::withoutGlobalScopes() + ->where('company_uuid', $company->uuid) + ->whereNull('deleted_at') + ->whereHas('user', fn ($query) => $query->where('phone', static::PHONE)->whereNull('deleted_at')) + ->first(); + + if ($driver) { + return $driver; + } + + $user = ProfileAccountManager::createManagedAccount($company->uuid, 'driver', static::DRIVER_NAME, null, static::PHONE, ['status' => 'active']); + $user->forceFill(['phone_verified_at' => now()])->save(); + + return Driver::create([ + 'company_uuid' => $company->uuid, + 'user_uuid' => $user->uuid, + 'status' => 'available', + 'online' => 0, + ]); + } + + /** + * Leave the review drivers out of a driver query (an Eloquent builder, or anything that + * takes a nested where), so a reviewer is never offered or assigned a real order + * automatically. + */ + public static function excludeFrom(mixed $query): mixed + { + return $query->where(fn (Builder $drivers) => $drivers->whereDoesntHave('user', fn ($user) => $user->where('phone', static::PHONE))); + } + + public static function isReviewDriver(?Driver $driver): bool + { + if (!$driver || !$driver->user_uuid) { + return false; + } + + $phone = User::where('uuid', $driver->user_uuid)->withoutGlobalScopes()->value('phone'); + + return static::isReviewPhone($phone); + } +} diff --git a/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php b/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php index f3b6bc9b3..fab99ccae 100644 --- a/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +++ b/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php @@ -587,3 +587,156 @@ public function __call($method, $arguments) expect($failure)->toBeInstanceOf(Illuminate\Database\Eloquent\ModelNotFoundException::class); }); + +/** + * The driver auth harness with core's App Review access: the review_accesses table, a hasher + * that really checks, Navigator registered, and an existing review driver in two + * organizations (one is created on first sign-in in production; see ProfileAccountManagerTest). + */ +function fleetopsNavigatorReviewBoot(): SQLiteConnection +{ + $connection = fleetopsDriverAuthBoot(); + $connection->getSchemaBuilder()->create('review_accesses', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('subject_type'); + $table->string('subject_uuid'); + $table->boolean('enabled')->default(false); + $table->string('code')->nullable(); + $table->timestamp('code_generated_at')->nullable(); + $table->timestamp('enabled_at')->nullable(); + $table->string('updated_by_uuid')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + app()->instance('hash', new class implements Illuminate\Contracts\Hashing\Hasher { + public function info($hashedValue): array + { + return []; + } + + public function make($value, array $options = []): string + { + return password_hash((string) $value, PASSWORD_BCRYPT); + } + + public function check($value, $hashedValue, array $options = []): bool + { + return is_string($hashedValue) && password_verify((string) $value, $hashedValue); + } + + public function needsRehash($hashedValue, array $options = []): bool + { + return false; + } + + public function verifyConfiguration($value): bool + { + return true; + } + }); + Illuminate\Support\Facades\Hash::clearResolvedInstance('hash'); + app()->instance('cache', new Illuminate\Cache\Repository(new Illuminate\Cache\ArrayStore())); + Illuminate\Support\Facades\Cache::clearResolvedInstance('cache'); + app()->instance('responsecache', new class { + public function clear(): void + { + } + }); + + Fleetbase\FleetOps\Support\NavigatorReview::register(); + + $connection->table('companies')->insert(['uuid' => 'company-2', 'public_id' => 'company_two', 'name' => 'Globex']); + foreach (['company-1', 'company-2'] as $company) { + $connection->table('users')->insert(['uuid' => 'reviewer-' . $company, 'company_uuid' => $company, 'name' => 'App Reviewer', 'phone' => Fleetbase\FleetOps\Support\NavigatorReview::PHONE, 'type' => 'driver', 'status' => 'active']); + $connection->table('drivers')->insert(['uuid' => 'review-driver-' . $company, 'company_uuid' => $company, 'user_uuid' => 'reviewer-' . $company, 'status' => 'available']); + } + + return $connection; +} + +function fleetopsNavigatorReviewOn(string $company): string +{ + [, $code] = Fleetbase\Support\AppReview::update(Fleetbase\Models\Company::where('uuid', $company)->first(), true); + + return $code; +} + +test('with Navigator review access on, the review number signs in as the organization review driver and is never texted', function () { + $connection = fleetopsNavigatorReviewBoot(); + $twilio = new FleetOpsDriverAuthTwilioFake(); + app()->instance('twilio', $twilio); + Fleetbase\Twilio\Support\Laravel\Facade::clearResolvedInstances(); + $code = fleetopsNavigatorReviewOn('company-1'); + + app()->instance('request', Request::create('/x', 'POST', ['phone' => '+1 202 555 0144'])); + $sent = (new DriverController())->loginWithPhone(); + $resource = (new DriverController())->verifyCode(Request::create('/x', 'POST', ['identity' => '12025550144', 'code' => $code])); + + expect($sent->getData(true))->toBe(['status' => 'OK', 'method' => 'sms']) + ->and($twilio->messages)->toBe([]) + ->and($connection->table('verification_codes')->count())->toBe(0) + ->and($resource)->toBeInstanceOf(DriverResource::class) + ->and($resource->resource->uuid)->toBe('review-driver-company-1') + ->and($resource->token)->not->toBeEmpty() + ->and($connection->table('personal_access_tokens')->value('name'))->toBe('review-driver-company-1'); +}); + +test('with Navigator review access off, the review number is refused and not texted', function () { + $connection = fleetopsNavigatorReviewBoot(); + $twilio = new FleetOpsDriverAuthTwilioFake(); + app()->instance('twilio', $twilio); + Fleetbase\Twilio\Support\Laravel\Facade::clearResolvedInstances(); + $code = fleetopsNavigatorReviewOn('company-1'); + Fleetbase\Support\AppReview::update(Fleetbase\Models\Company::where('uuid', 'company-1')->first(), false); + + app()->instance('request', Request::create('/x', 'POST', ['phone' => '12025550144'])); + $sent = (new DriverController())->loginWithPhone(); + $refused = (new DriverController())->verifyCode(Request::create('/x', 'POST', ['identity' => '12025550144', 'code' => $code])); + + expect($sent->getStatusCode())->toBe(422) + ->and($sent->getData(true)['error'])->toBe(Fleetbase\FleetOps\Support\NavigatorReview::OFF_MESSAGE) + ->and($twilio->messages)->toBe([]) + ->and($refused->getData(true)['error'])->toBe('Invalid verification code!') + ->and($connection->table('personal_access_tokens')->count())->toBe(0); +}); + +test('a wrong code or another organization code does not sign the Navigator review number in', function () { + $connection = fleetopsNavigatorReviewBoot(); + $codeOne = fleetopsNavigatorReviewOn('company-1'); + do { + [, $codeTwo] = Fleetbase\Support\AppReview::update(Fleetbase\Models\Company::where('uuid', 'company-2')->first(), true, true); + } while ($codeTwo === $codeOne); + $wrong = $codeOne === '000000' ? '000001' : '000000'; + + $crossed = (new DriverController())->verifyCode(Request::create('/x', 'POST', ['identity' => '12025550144', 'code' => $codeTwo])); + $guessed = (new DriverController())->verifyCode(Request::create('/x', 'POST', ['identity' => '12025550144', 'code' => $wrong])); + + expect($crossed->getData(true)['error'])->toBe('Invalid verification code!') + ->and($guessed->getData(true)['error'])->toBe('Invalid verification code!') + ->and($connection->table('personal_access_tokens')->count())->toBe(0); + + // At its own organization the second code signs in as that organization's review driver. + session(['company' => 'company-2']); + $resource = (new DriverController())->verifyCode(Request::create('/x', 'POST', ['identity' => '12025550144', 'code' => $codeTwo])); + expect($resource->resource->uuid)->toBe('review-driver-company-2'); +}); + +test('the self-hosted Navigator review accounts still sign in where the organization has not turned access on', function () { + $connection = fleetopsNavigatorReviewBoot(); + config([ + 'fleetops.navigator.bypass_verification_code' => '135790', + 'fleetops.navigator.review_accounts' => [Fleetbase\FleetOps\Support\NavigatorReview::PHONE], + ]); + + try { + $resource = (new DriverController())->verifyCode(Request::create('/x', 'POST', ['identity' => '12025550144', 'code' => '135790'])); + + expect($resource)->toBeInstanceOf(DriverResource::class) + ->and(DriverController::verificationBypassListed('+12025550144'))->toBeTrue() + ->and(DriverController::verificationBypassListed('+6591234567'))->toBeFalse(); + } finally { + config(['fleetops.navigator.bypass_verification_code' => null, 'fleetops.navigator.review_accounts' => []]); + } +}); diff --git a/server/tests/Unit/Support/ProfileAccountManagerTest.php b/server/tests/Unit/Support/ProfileAccountManagerTest.php index fa1eda3c9..f8e33c906 100644 --- a/server/tests/Unit/Support/ProfileAccountManagerTest.php +++ b/server/tests/Unit/Support/ProfileAccountManagerTest.php @@ -908,3 +908,41 @@ function fleetopsProfileAccountLeaveSandbox(): void expect($live->table('users')->where('email', 'contact@example.com')->count())->toBe(0); }); + +test('each organization gets one Navigator review driver, never linked to another, and dispatch leaves it out', function () { + $connection = fleetopsProfileAccountBoot(); + $schema = $connection->getSchemaBuilder(); + $schema->table('users', fn ($table) => $table->string('phone_verified_at')->nullable()); + $schema->table('drivers', function ($table) use ($schema) { + foreach (['status', 'online', 'internal_id', '_key', 'slug', 'created_at', 'updated_at', 'deleted_at'] as $column) { + if (!$schema->hasColumn('drivers', $column)) { + $table->string($column)->nullable(); + } + } + }); + fleetopsProfileAccountRows($connection, 'users', [['uuid' => 'user-real', 'company_uuid' => 'company-1', 'phone' => '+6590003333', 'type' => 'driver']]); + fleetopsProfileAccountRows($connection, 'drivers', [['uuid' => 'driver-real', 'company_uuid' => 'company-1', 'user_uuid' => 'user-real', 'status' => 'available']]); + + $acme = Fleetbase\Models\Company::where('uuid', 'company-1')->first(); + $globex = Fleetbase\Models\Company::where('uuid', 'company-2')->first(); + + $first = Fleetbase\FleetOps\Support\NavigatorReview::driver($acme); + $again = Fleetbase\FleetOps\Support\NavigatorReview::driver($acme); + $other = Fleetbase\FleetOps\Support\NavigatorReview::driver($globex); + $user = $connection->table('users')->where('uuid', $first->user_uuid)->first(); + + expect($again->uuid)->toBe($first->uuid) + ->and($first->company_uuid)->toBe('company-1') + ->and($other->company_uuid)->toBe('company-2') + ->and($other->user_uuid)->not->toBe($first->user_uuid) + ->and($user->phone)->toBe(Fleetbase\FleetOps\Support\NavigatorReview::PHONE) + ->and($user->type)->toBe('driver') + ->and($user->status)->toBe('active') + ->and($user->phone_verified_at)->not->toBeNull() + ->and(fleetopsProfileAccountRoles($connection, $first->user_uuid, 'company-1'))->toBe(['Driver']) + ->and(Fleetbase\FleetOps\Support\NavigatorReview::isReviewDriver($first))->toBeTrue() + ->and(Fleetbase\FleetOps\Support\NavigatorReview::isReviewDriver(Driver::withoutGlobalScopes()->where('uuid', 'driver-real')->first()))->toBeFalse(); + + $offered = Fleetbase\FleetOps\Support\NavigatorReview::excludeFrom(Driver::withoutGlobalScopes())->pluck('uuid')->all(); + expect($offered)->toBe(['driver-real']); +}); diff --git a/translations/en-us.yaml b/translations/en-us.yaml index 8a51aa3cb..e24844b38 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -1725,6 +1725,29 @@ entity-field-editing-settings: enable-driver-to-edit-entity-fields: Allow driver to edit entity details enable-driver-to-edit-entity-fields-help-text: Enabling this toggle grants the driver permission to modify specific entity fields chosen below. +navigator-app-review-settings: + title: App Review + lede: Apple and Google test Navigator before it goes live, and their reviewers can't receive a text message. Turn this on to let them sign in as a test driver with a phone number and a code you give the app store. + 'on': 'On' + 'off': 'Off' + phone-label: Reviewer phone number + phone-help: A test number that is never texted. The code decides which organization it signs in to. + code-label: Sign-in code + code-new: Copy this code now. It is only shown once. + code-hidden: Code created {date}. It is only shown once, so create a new one if you've lost it. + code-none: No code yet. Turning this on creates one. + where: In App Store Connect, enter both under App Review Information, Sign-in required. In Google Play Console, enter them under App content, App access. + driver: The reviewer signs in as a test driver, App Reviewer, in your drivers list. It is never offered or assigned orders automatically; assign one by hand if you want them to see an order. + turn-on: Turn on + turn-off: Turn off + turn-off-title: Turn off App Review access? + turn-off-body: Reviewers can no longer sign in to Navigator. Turn it off once your app is approved, and turn it back on before you submit an update. + regenerate: Create new code + regenerate-title: Create a new code? + regenerate-body: The current code stops working at once. Enter the new one in App Store Connect and Google Play Console. + turned-on: App Review access is on. + turned-off: App Review access is off. + regenerated: New code created. driver-onboard-settings: driver-onboard-settings: Driver Onboard Settings enable-driver-onboard-from-app: Enable driver onboard from Navigator App From e58cb8c7b10180a6882ec19dd7476e2ea67e6f7a Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 24 Oct 2026 20:08:35 +0800 Subject: [PATCH 18/22] docs(release): link the Navigator App Review entry to its PR --- RELEASE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE.md b/RELEASE.md index 40699eaa7..d13258e83 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -10,7 +10,7 @@ - **Fix: `scheduled_at` keeps its UTC offset.** `2026-10-21T11:00:00-07:00` is now stored as 18:00 UTC instead of 11:00, and `PUT /v1/orders/{id}/schedule` stores the UTC instant of a date and time in the company timezone. Rows written before this keep their old values. (#412) - **Fix: service quotes.** The `currency` filter works again, stored quotes expire after 48 hours by default (`fleetops.service_quotes.ttl_minutes`), and `preview=1` prices a request without storing quote rows. (#412) - **Fix: tracking numbers with a space can be looked up again.** Since v0.6.72, a number such as `AL 7796486490AE`, generated for a company whose name has a space in its first three letters, got "not found". Single inner spaces are accepted now. New numbers only use letters and digits: the company prefix skips spaces and punctuation ("Al Noor" gives `ALN`), and a region such as "New York" becomes `NEWYORK`. (#413, #416) -- **Feature: App Review access for Navigator.** Apple and Google reviewers can't receive the texted sign-in code. An organization can turn on **Settings → Navigator App → App Review**: reviewers sign in to its Navigator with the test number +1 202 555 0144 (a range kept for fiction, never texted) and a six-digit code the owner creates and enters in App Store Connect and Google Play Console. The code is stored hashed, shown once, and only works for that organization. The first sign-in adds an **App Reviewer** driver, which ad-hoc offers, nearest-driver assignment and the orchestrator always leave out. Built on core-api's App Review access; the `NAVIGATOR_*` review env settings still work on self-hosted installs. See `docs/NAVIGATOR_APP_REVIEW.md`. (#PR) +- **Feature: App Review access for Navigator.** Apple and Google reviewers can't receive the texted sign-in code. An organization can turn on **Settings → Navigator App → App Review**: reviewers sign in to its Navigator with the test number +1 202 555 0144 (a range kept for fiction, never texted) and a six-digit code the owner creates and enters in App Store Connect and Google Play Console. The code is stored hashed, shown once, and only works for that organization. The first sign-in adds an **App Reviewer** driver, which ad-hoc offers, nearest-driver assignment and the orchestrator always leave out. Built on core-api's App Review access; the `NAVIGATOR_*` review env settings still work on self-hosted installs. See `docs/NAVIGATOR_APP_REVIEW.md`. (#418) --- ## Upgrading From e9535b647a0e9c23d50bc146d20cd28c6c56c17f Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 24 Oct 2026 20:35:50 +0800 Subject: [PATCH 19/22] test(navigator): the App Review card turns access on, shows the code once and turns it off --- .../navigator-app-review-settings-test.js | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 tests/integration/components/navigator-app-review-settings-test.js diff --git a/tests/integration/components/navigator-app-review-settings-test.js b/tests/integration/components/navigator-app-review-settings-test.js new file mode 100644 index 000000000..61ae3e630 --- /dev/null +++ b/tests/integration/components/navigator-app-review-settings-test.js @@ -0,0 +1,90 @@ +import Service from '@ember/service'; +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render, click } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +const status = (overrides = {}) => ({ + type: 'navigator', + enabled: false, + phone: '+12025550144', + phone_display: '+1 202 555 0144', + has_code: false, + code_generated_at: null, + enabled_at: null, + ...overrides, +}); + +class FetchStubService extends Service { + requests = []; + current = status(); + get(url, query, options) { + this.requests.push(['GET', url, options.namespace]); + return Promise.resolve({ review_access: this.current }); + } + put(url, body) { + this.requests.push(['PUT', url, body]); + if (body.enabled === true) { + this.current = status({ enabled: true, has_code: true, code_generated_at: '2026-10-24T09:00:00+00:00' }); + return Promise.resolve({ review_access: { ...this.current, code: '482913' } }); + } + this.current = { ...this.current, enabled: false }; + return Promise.resolve({ review_access: this.current }); + } +} + +class CurrentUserStubService extends Service { + companyId = 'company-uuid-1'; +} + +class ModalsManagerStubService extends Service { + confirm(options) { + return options.confirm({ startLoading() {}, stopLoading() {}, done() {} }); + } +} + +class AbilitiesStubService extends Service { + can() { + return true; + } + cannot() { + return false; + } +} + +class NotificationsStubService extends Service { + success() {} + serverError() {} +} + +module('Integration | Component | navigator-app-review-settings', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.owner.register('service:fetch', FetchStubService); + this.owner.register('service:current-user', CurrentUserStubService); + this.owner.register('service:modals-manager', ModalsManagerStubService); + this.owner.register('service:abilities', AbilitiesStubService); + this.owner.register('service:notifications', NotificationsStubService); + }); + + test('it turns App Review access on for the organization, shows the code once and turns it off', async function (assert) { + const fetch = this.owner.lookup('service:fetch'); + + await render(hbs``); + + assert.deepEqual(fetch.requests[0], ['GET', 'review-access/navigator/company-uuid-1', 'int/v1']); + assert.dom('[data-test-navigator-app-review-status]').hasText('Off'); + assert.dom('[data-test-navigator-app-review-phone]').includesText('+1 202 555 0144'); + + await click('[data-test-navigator-app-review-turn-on]'); + assert.deepEqual(fetch.requests[1], ['PUT', 'review-access/navigator/company-uuid-1', { enabled: true }]); + assert.dom('[data-test-navigator-app-review-status]').hasText('On'); + assert.dom('[data-test-navigator-app-review-code]').includesText('482913'); + + await click('[data-test-navigator-app-review-turn-off]'); + assert.deepEqual(fetch.requests[2], ['PUT', 'review-access/navigator/company-uuid-1', { enabled: false }]); + assert.dom('[data-test-navigator-app-review-status]').hasText('Off'); + assert.dom('[data-test-navigator-app-review-code]').doesNotExist(); + }); +}); From a0bd27adb89dae18e86b6dbe43205a2f37ae0d3c Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 25 Oct 2026 10:45:17 +0800 Subject: [PATCH 20/22] docs(release): add the routing engine (#406) and private routing host guard (#417) to v0.6.75 --- RELEASE.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/RELEASE.md b/RELEASE.md index d13258e83..1c5a1c2c4 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,8 +1,11 @@ -> v0.6.75 ~ "Safer integrations, tenant-isolated profile accounts and App Review access for Navigator" +> v0.6.75 ~ "Road distance and ETAs from one routing engine, tenant-isolated profile accounts and App Review access for Navigator" --- ## Highlights +- **Feature: one routing engine for order distance, ETAs and the map.** An order's stored distance and time, its live ETAs and remaining distance, and the route drawn on the map now all come from the organization's routing engine. Before this, order distance defaulted to a straight-line estimate at a flat ~50 km/h; a reported ~30 km run was stored as 25.0 km / 30 min and is now 32.1 km / 32 min. The engine is chosen in order: the organization's routing engine, then the admin default, then the platform's Valhalla server, then the straight-line estimate. An engine only counts if it is allowed and configured. A failed road lookup falls back to the straight-line estimate and is logged, instead of storing 0. Road results are cached for 24 hours. (#406) +- **Feature: routing provider controls in Admin → Fleet-Ops → Routing.** Administrators choose which providers organizations may use (Google, Valhalla, OSRM, VROOM), with per-organization exceptions. They also set the default routing and optimization engines, and whether server-side routing uses roads or only the straight-line estimate. Saved admin settings take precedence over environment variables. **Google is off until an administrator enables it**, for map routing, optimization, order distance and ETAs. The public OSRM demo server is never called by the server. The admin menu entry "Fleet-Ops Config" is now "Fleet-Ops". (#406) +- **Security: an organization's routing host can't point at a private address.** Order distance, ETAs and VROOM allocation ignore an organization's Valhalla or VROOM override whose host is loopback, a private or link-local network, cloud metadata or another non-public range, and use the system server instead. This also covers values saved before the Valhalla and VROOM extensions started rejecting them. (#417) - **Security: a profile never links to another organization's login account.** Creating a driver, customer or contact with an email or phone that belongs to another organization's account linked that account and exposed its name, email and phone. The new organization could then reset its password or deactivate it. Only accounts that already belong to the organization, or belong to no organization, are reused now. The refusal does not say whose account it is. `POST /v1/drivers` also ignores a `company` that is not the API credential's own. Logins already shared by the old behaviour can only be managed by their holder. (#414) - **Fix: orders can be edited with a partial payload.** `PUT /v1/orders/{id}` accepts a `payload` with only the parts that change, such as only entities or only a new dropoff. (#411) - **Fix: addresses sent with coordinates keep their street.** A place given as a street address plus `latitude`/`longitude` was reverse-geocoded and stored as a different nearby address. The street and the coordinates are now kept as sent, without a geocoding call. (#411) @@ -15,6 +18,15 @@ --- ## Upgrading - **Requires fleetbase/core-api v1.6.74** (`composer.json` now requires `^1.6.74`), which adds App Review access and its `review_accesses` table: run `php artisan migrate`. +- **Routing defaults changed (#406).** Review **Admin → Fleet-Ops → Routing** after upgrading: + - The default routing engine is now **Valhalla** (it was OSRM). Without a Valhalla server (`VALHALLA_HOST`, or the Valhalla extension's settings), order distance and ETAs use the straight-line estimate. Self-hosters running their own OSRM can make it the default in the panel or with `ROUTING_DISPLAY_ENGINE=osrm`. + - **Google is off.** Turn it on in the panel or set `ROUTING_GOOGLE_ENABLED=true`. `DISTANCE_MATRIX_PROVIDER=google` also enables it until the panel is saved. + - Tracking follows the routing engine. `TRACKING_PROVIDER` and `TRACKING_PROVIDER_FALLBACKS` are no longer read, and saved tracking provider and fallback choices are ignored. + - `DISTANCE_MATRIX_PROVIDER` only seeds the admin defaults now; it no longer overrides order distance. + - New starting values, read until the panel is first saved: `ROUTING_GOOGLE_ENABLED`, `ROUTING_DISPLAY_ENGINE`, `ROUTING_OPTIMIZATION_ENGINE`, `ROUTING_SERVER_ROUTING`, plus `VALHALLA_HOST`, `VALHALLA_API_KEY`, `VALHALLA_COSTING` and `VALHALLA_TIMEOUT`. + - Existing orders: started, unfinished orders from the last 2 days are re-measured by `fleetops:update-estimations`; unstarted orders pick up road distance at their next ready or edit; completed and cancelled orders keep their stored values. No migration. +- **Private routing hosts (#417).** On self-hosted installs, an organization override pointing at a private host (for example `http://valhalla:8002`) now falls back to the system server. Set `VALHALLA_ALLOW_PRIVATE_HOSTS=true` or `VROOM_ALLOW_PRIVATE_HOSTS=true` to allow it. System-level private hosts are unaffected. +- **Fleetbase Cloud.** After deploying, set the Valhalla host to `https://valhalla.routing.fleetbase.io` with Cloud's gateway key, Google and OSRM off, and Valhalla as the default engine with road routing. Enter the gateway key only after fleetbase/valhalla#9 and fleetbase/vroom#8 ship, because the extensions' settings endpoints return the key to any signed-in user until then. --- ## Need help? From 89948e3d7f119d5b9df651e11b0578f7762a502d Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 27 Oct 2026 11:31:49 +0800 Subject: [PATCH 21/22] feat(routing): OSRM server in Admin -> Fleet-Ops -> Routing and per organization; admin page spacer - OsrmConnection resolves the OSRM server like the Valhalla one: the organization's own (Fleet-Ops Settings -> Routing; must pass HostPolicy unless OSRM_ALLOW_PRIVATE_HOSTS), then the system setting (Admin -> Fleet-Ops -> Routing), then OSRM_HOST, then the public demo server, which only ever draws map routes. - OSRM::forCompany() scopes calls to an organization; order distance, the OSRM tracking provider and RoutingEngineResolver::hasPrivateOsrmHost() use the organization's server. - SettingController: admin-osrm-settings (admin; private hosts allowed, http(s) without credentials) and osrm-settings (routing-settings permissions; public hosts only), both 422 with the reason when refused. routing-settings returns osrm_host and osrm_host_source. - Console: an OSRM Server panel in the admin routing page; an organization 'Use my own OSRM server' panel when it routes or optimizes with OSRM; the osrm service, the OSRM route control (resolved per request) and the customer order preview call the resolved server; a 200px spacer at the bottom of the admin routing page. - Tests: OSRM resolution, scoping, order distance and the four endpoints; the admin routing integration test and the osrm service unit test. Co-Authored-By: Claude --- RELEASE.md | 4 +- addon/components/admin/routing-settings.hbs | 7 ++ addon/components/admin/routing-settings.js | 18 +++ .../components/customer/create-order-form.js | 4 +- addon/controllers/settings/routing.js | 37 ++++-- addon/instance-initializers/register-osrm.js | 16 +-- addon/services/osrm.js | 20 ++- addon/templates/settings/routing.hbs | 12 ++ server/config/fleetops.php | 6 +- .../Internal/v1/SettingController.php | 97 +++++++++++++++ server/src/Support/OSRM.php | 24 +++- server/src/Support/Routing/OsrmConnection.php | 117 ++++++++++++++++++ .../Support/Routing/RoutingEngineResolver.php | 12 +- server/src/Support/Utils.php | 4 +- .../Providers/OsrmTrackingProvider.php | 6 +- server/src/routes.php | 4 + .../tests/SettingControllerContractsTest.php | 60 +++++++++ .../Support/RoutingEngineResolverTest.php | 100 ++++++++++++++- .../components/admin/routing-settings-test.js | 14 ++- tests/unit/services/osrm-test.js | 26 +++- 20 files changed, 551 insertions(+), 37 deletions(-) create mode 100644 server/src/Support/Routing/OsrmConnection.php diff --git a/RELEASE.md b/RELEASE.md index 1c5a1c2c4..a32ec2d8d 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -5,6 +5,7 @@ - **Feature: one routing engine for order distance, ETAs and the map.** An order's stored distance and time, its live ETAs and remaining distance, and the route drawn on the map now all come from the organization's routing engine. Before this, order distance defaulted to a straight-line estimate at a flat ~50 km/h; a reported ~30 km run was stored as 25.0 km / 30 min and is now 32.1 km / 32 min. The engine is chosen in order: the organization's routing engine, then the admin default, then the platform's Valhalla server, then the straight-line estimate. An engine only counts if it is allowed and configured. A failed road lookup falls back to the straight-line estimate and is logged, instead of storing 0. Road results are cached for 24 hours. (#406) - **Feature: routing provider controls in Admin → Fleet-Ops → Routing.** Administrators choose which providers organizations may use (Google, Valhalla, OSRM, VROOM), with per-organization exceptions. They also set the default routing and optimization engines, and whether server-side routing uses roads or only the straight-line estimate. Saved admin settings take precedence over environment variables. **Google is off until an administrator enables it**, for map routing, optimization, order distance and ETAs. The public OSRM demo server is never called by the server. The admin menu entry "Fleet-Ops Config" is now "Fleet-Ops". (#406) +- **Feature: the OSRM server is a setting, for the platform and per organization.** Administrators set it in Admin → Fleet-Ops → Routing → OSRM Server; `OSRM_HOST` is now only the starting value. Organizations that route or optimize with OSRM can use their own server under Fleet-Ops Settings → Routing → OSRM Server, like the Valhalla and VROOM overrides. An organization host must be a public address (`OSRM_ALLOW_PRIVATE_HOSTS=true` lifts that on self-hosted installs). Order distance, ETAs, tracking and the console's OSRM routes and route control all use the resolved server. The public demo server still only draws map routes. - **Security: an organization's routing host can't point at a private address.** Order distance, ETAs and VROOM allocation ignore an organization's Valhalla or VROOM override whose host is loopback, a private or link-local network, cloud metadata or another non-public range, and use the system server instead. This also covers values saved before the Valhalla and VROOM extensions started rejecting them. (#417) - **Security: a profile never links to another organization's login account.** Creating a driver, customer or contact with an email or phone that belongs to another organization's account linked that account and exposed its name, email and phone. The new organization could then reset its password or deactivate it. Only accounts that already belong to the organization, or belong to no organization, are reused now. The refusal does not say whose account it is. `POST /v1/drivers` also ignores a `company` that is not the API credential's own. Logins already shared by the old behaviour can only be managed by their holder. (#414) - **Fix: orders can be edited with a partial payload.** `PUT /v1/orders/{id}` accepts a `payload` with only the parts that change, such as only entities or only a new dropoff. (#411) @@ -25,8 +26,9 @@ - `DISTANCE_MATRIX_PROVIDER` only seeds the admin defaults now; it no longer overrides order distance. - New starting values, read until the panel is first saved: `ROUTING_GOOGLE_ENABLED`, `ROUTING_DISPLAY_ENGINE`, `ROUTING_OPTIMIZATION_ENGINE`, `ROUTING_SERVER_ROUTING`, plus `VALHALLA_HOST`, `VALHALLA_API_KEY`, `VALHALLA_COSTING` and `VALHALLA_TIMEOUT`. - Existing orders: started, unfinished orders from the last 2 days are re-measured by `fleetops:update-estimations`; unstarted orders pick up road distance at their next ready or edit; completed and cancelled orders keep their stored values. No migration. +- **OSRM server.** Nothing to do: `OSRM_HOST` keeps working until a host is saved in Admin → Fleet-Ops → Routing. Organization OSRM servers are new and empty. - **Private routing hosts (#417).** On self-hosted installs, an organization override pointing at a private host (for example `http://valhalla:8002`) now falls back to the system server. Set `VALHALLA_ALLOW_PRIVATE_HOSTS=true` or `VROOM_ALLOW_PRIVATE_HOSTS=true` to allow it. System-level private hosts are unaffected. -- **Fleetbase Cloud.** After deploying, set the Valhalla host to `https://valhalla.routing.fleetbase.io` with Cloud's gateway key, Google and OSRM off, and Valhalla as the default engine with road routing. Enter the gateway key only after fleetbase/valhalla#9 and fleetbase/vroom#8 ship, because the extensions' settings endpoints return the key to any signed-in user until then. +- **Fleetbase Cloud.** After deploying, set the Valhalla host to `https://valhalla.routing.fleetbase.io` with Cloud's gateway key, Google and OSRM off, and Valhalla as the default engine with road routing. Enter the gateway key only after fleetbase/valhalla v0.0.5 and fleetbase/vroom v0.0.5 ship (valhalla#9, vroom#8), because the extensions' settings endpoints return the key to any signed-in user until then. --- ## Need help? diff --git a/addon/components/admin/routing-settings.hbs b/addon/components/admin/routing-settings.hbs index 5a1f5ba65..82ad05c3c 100644 --- a/addon/components/admin/routing-settings.hbs +++ b/addon/components/admin/routing-settings.hbs @@ -66,6 +66,12 @@ + + + + + +

Switch a provider on for specific organizations only, for example Google for a customer who pays for it. @@ -149,6 +155,7 @@ + diff --git a/addon/components/admin/routing-settings.js b/addon/components/admin/routing-settings.js index c4c3024f3..6c31b37da 100644 --- a/addon/components/admin/routing-settings.js +++ b/addon/components/admin/routing-settings.js @@ -30,6 +30,8 @@ export default class AdminRoutingSettingsComponent extends Component { @tracked trackingStaleLocationThresholdSeconds = 300; @tracked trackingDefaultVehicleSpeedKph = 35; @tracked showTrackingAdvancedSettings = false; + @tracked osrmHost = ''; + @tracked osrmEnvironmentHost = null; constructor() { super(...arguments); @@ -41,6 +43,9 @@ export default class AdminRoutingSettingsComponent extends Component { const policy = yield this.fetch.get('fleet-ops/settings/admin-routing-providers'); this.applyProviderPolicy(policy); + const osrm = yield this.fetch.get('fleet-ops/settings/admin-osrm-settings'); + this.applyOsrmSettings(osrm); + const settings = yield this.fetch.get('fleet-ops/settings/admin-tracking-settings'); this.trackingTrafficEnabled = settings.traffic_enabled ?? true; this.trackingCacheTtlSeconds = settings.cache_ttl_seconds ?? 60; @@ -56,6 +61,8 @@ export default class AdminRoutingSettingsComponent extends Component { try { const policy = yield this.fetch.post('fleet-ops/settings/admin-routing-providers', this.providerPolicyPayload); this.applyProviderPolicy(policy); + const osrm = yield this.fetch.post('fleet-ops/settings/admin-osrm-settings', { api_host: this.osrmHost }); + this.applyOsrmSettings(osrm); yield this.fetch.post('fleet-ops/settings/admin-tracking-settings', this.trackingSettingsPayload); this.notifications.success('Routing settings saved.'); } catch (error) { @@ -63,6 +70,17 @@ export default class AdminRoutingSettingsComponent extends Component { } } + applyOsrmSettings(settings = {}) { + this.osrmHost = settings.api_host ?? ''; + this.osrmEnvironmentHost = settings.environment_host ?? null; + } + + get osrmHelpText() { + const environment = this.osrmEnvironmentHost ? ` Empty uses OSRM_HOST (${this.osrmEnvironmentHost}).` : ''; + + return `The OSRM server organizations use unless they set their own. The public demo server (router.project-osrm.org) only draws map routes; order distance and ETAs never use it.${environment}`; + } + applyProviderPolicy(policy = {}) { this.providerAccess = { ...this.providerAccess, ...(policy.providers ?? {}) }; this.availableProviders = policy.available_providers ?? this.availableProviders; diff --git a/addon/components/customer/create-order-form.js b/addon/components/customer/create-order-form.js index 5efea77ac..ded15c7f1 100644 --- a/addon/components/customer/create-order-form.js +++ b/addon/components/customer/create-order-form.js @@ -8,7 +8,6 @@ import { later } from '@ember/runloop'; import { debug } from '@ember/debug'; import { task, timeout } from 'ember-concurrency'; import { OSRMv1, Control as RoutingControl } from '@fleetbase/leaflet-routing-machine'; -import getRoutingHost from '@fleetbase/ember-core/utils/get-routing-host'; import engineService from '@fleetbase/ember-core/decorators/engine-service'; import registerHelper from '@fleetbase/ember-core/utils/register-helper'; import WaypointLabelHelper from '../../helpers/waypoint-label'; @@ -22,6 +21,7 @@ export default class CustomerCreateOrderFormComponent extends Component { @engineService('@fleetbase/fleetops-engine') contextPanel; @service store; @service currentUser; + @service osrm; @service notifications; @service modalsManager; @service customerSession; @@ -359,7 +359,7 @@ export default class CustomerCreateOrderFormComponent extends Component { this.notifications.warning(this.intl.t('fleet-ops.operations.orders.index.new.no-route-warning')); } - const routingHost = getRoutingHost(payload, waypoints); + const routingHost = this.osrm.routingHost(payload, waypoints); const router = new OSRMv1({ serviceUrl: `${routingHost}/route/v1`, profile: 'driving', diff --git a/addon/controllers/settings/routing.js b/addon/controllers/settings/routing.js index a3ccd33b3..0355300e3 100644 --- a/addon/controllers/settings/routing.js +++ b/addon/controllers/settings/routing.js @@ -25,12 +25,24 @@ export default class SettingsRoutingController extends Controller { @tracked prolongedStoppageAlertsEnabled = true; @tracked prolongedStoppageDurationThresholdMinutes = 30; @tracked showTrackingAdvancedSettings = false; + @tracked osrmUseOwnServer = false; + @tracked osrmHost = ''; + @tracked osrmSystemHost = null; @tracked routingUnitOptions = [ { label: 'Kilometers', value: 'km' }, { label: 'Miles', value: 'mi' }, ]; saveTasks = new Map(); + /** Whether the organization routes or optimizes with OSRM, so its own OSRM server applies. */ + get usesOsrm() { + return this.displayEngine === 'osrm' || this.optimizationEngine === 'osrm'; + } + + get osrmSystemHelpText() { + return this.osrmSystemHost ? `Otherwise this organization uses ${this.osrmSystemHost}.` : 'Otherwise this organization uses the platform OSRM server.'; + } + get hasEngineOverrides() { return ['vroom', 'valhalla'].includes(this.displayEngine) || ['vroom', 'valhalla'].includes(this.optimizationEngine); } @@ -54,14 +66,13 @@ export default class SettingsRoutingController extends Controller { }); const trackingSettings = this.trackingSettingsPayload; yield this.fetch.post('fleet-ops/settings/tracking-settings', trackingSettings); + if (this.usesOsrm) { + yield this.fetch.post('fleet-ops/settings/osrm-settings', { api_host: this.osrmUseOwnServer ? this.osrmHost : null }); + } yield this.performAdditionalSaveTasks(); - // Save in local memory too - this.currentUser.setOption('routing', { - router: this.displayEngine, - routing_display_engine: this.displayEngine, - routing_optimization_engine: this.optimizationEngine, - unit: this.routingUnit, - }); + // Refresh the routing settings in memory: they carry the resolved OSRM server the map uses + const routingSettings = yield this.fetch.get('fleet-ops/settings/routing-settings'); + this.currentUser.setOption('routing', routingSettings); this.currentUser.setOption('tracking', trackingSettings); this.notifications.success('Routing and tracking settings saved.'); } catch (error) { @@ -80,6 +91,7 @@ export default class SettingsRoutingController extends Controller { this.displayEngine = display_engine ?? router ?? 'valhalla'; this.optimizationEngine = optimization_engine ?? display_engine ?? router ?? 'valhalla'; this.routingUnit = unit; + yield this.loadOsrmSettings.perform(); const trackingSettings = yield this.fetch.get('fleet-ops/settings/tracking-settings'); this.trackingTrafficEnabled = trackingSettings.traffic_enabled ?? true; this.trackingCacheTtlSeconds = trackingSettings.cache_ttl_seconds ?? 60; @@ -97,6 +109,17 @@ export default class SettingsRoutingController extends Controller { } } + @task *loadOsrmSettings() { + try { + const { api_host, system_host } = yield this.fetch.get('fleet-ops/settings/osrm-settings'); + this.osrmHost = api_host ?? ''; + this.osrmUseOwnServer = Boolean(api_host); + this.osrmSystemHost = system_host ?? null; + } catch { + // Without the permission or the endpoint, the OSRM panel keeps its defaults. + } + } + get trackingSettingsPayload() { return { traffic_enabled: this.trackingTrafficEnabled, diff --git a/addon/instance-initializers/register-osrm.js b/addon/instance-initializers/register-osrm.js index 9947f44d1..8986fdc4d 100644 --- a/addon/instance-initializers/register-osrm.js +++ b/addon/instance-initializers/register-osrm.js @@ -1,4 +1,3 @@ -import getRoutingHost from '@fleetbase/ember-core/utils/get-routing-host'; import { RoutingControl } from '../services/leaflet-routing-control'; import { OSRMv1 } from '@fleetbase/leaflet-routing-machine'; @@ -17,16 +16,19 @@ export function initialize(owner) { // Register OSRM as Routing Controler const leafletRoutingControl = owner.lookup('service:leaflet-routing-control'); - if (leafletRoutingControl) { - const routingHost = getRoutingHost(); + if (leafletRoutingControl && osrm) { + const router = new OSRMv1({ profile: 'driving' }); + // Resolved per request: the routing settings (and the organization's or the + // admin's OSRM server) load after this initializer runs. + Object.defineProperty(router.options, 'serviceUrl', { + get: () => `${osrm.routingHost()}/route/v1`, + configurable: true, + }); leafletRoutingControl.register( 'osrm', new RoutingControl({ name: 'OSRM', - router: new OSRMv1({ - serviceUrl: `${routingHost}/route/v1`, - profile: 'driving', - }), + router, }) ); } diff --git a/addon/services/osrm.js b/addon/services/osrm.js index 2b96b5cb7..28c0348b5 100644 --- a/addon/services/osrm.js +++ b/addon/services/osrm.js @@ -1,3 +1,4 @@ +import { inject as service } from '@ember/service'; import RouteOptimizationInterfaceService from './route-optimization-interface'; import { isArray } from '@ember/array'; import getRoutingHost from '@fleetbase/ember-core/utils/get-routing-host'; @@ -5,8 +6,23 @@ import polyline from '@fleetbase/ember-core/utils/polyline'; import { debug } from '@ember/debug'; export default class OsrmService extends RouteOptimizationInterfaceService { + @service currentUser; name = 'OSRM'; + /** + * The OSRM server to call from the console: the organization's own or the + * admin's (Fleet-Ops routing settings carry the resolved host), otherwise + * the console build's OSRM_HOST, with its per-country servers. + */ + routingHost(payload, waypoints) { + const routing = this.currentUser?.getOption?.('routing') ?? {}; + if (routing.osrm_host && ['organization', 'system'].includes(routing.osrm_host_source)) { + return routing.osrm_host; + } + + return getRoutingHost(payload, waypoints); + } + async computeRoute(waypoints = [], options = {}) { const normalizedWaypoints = (isArray(waypoints) ? waypoints : []) .map(([lat, lng]) => [Number(lat), Number(lng)]) @@ -16,7 +32,7 @@ export default class OsrmService extends RouteOptimizationInterfaceService { throw new Error('At least 2 waypoints are required to compute a route.'); } - const routingHost = getRoutingHost(options.payload, options.waypoints); + const routingHost = this.routingHost(options.payload, options.waypoints); const coordStr = normalizedWaypoints.map(([lat, lng]) => `${lng},${lat}`).join(';'); const response = await fetch(`${routingHost}/route/v1/driving/${coordStr}?overview=full&geometries=polyline&steps=true`); @@ -55,7 +71,7 @@ export default class OsrmService extends RouteOptimizationInterfaceService { const source = 'first'; const destination = preserveEndpoints ? 'last' : 'any'; const roundtrip = false; // don’t loop back - const routingHost = getRoutingHost(payload, waypoints); + const routingHost = this.routingHost(payload, waypoints); try { const result = await this.fetch.routing(coordinates, { source, destination, roundtrip }, { host: routingHost, ...options }); diff --git a/addon/templates/settings/routing.hbs b/addon/templates/settings/routing.hbs index 336f6f705..ae6ec4973 100644 --- a/addon/templates/settings/routing.hbs +++ b/addon/templates/settings/routing.hbs @@ -48,6 +48,18 @@ /> + {{#if this.usesOsrm}} + + + + + {{#if this.osrmUseOwnServer}} + + + + {{/if}} + + {{/if}}

Live ETAs, remaining distance and route lines use the routing service selected above, the same one that measures each order's distance and time. These settings only tune diff --git a/server/config/fleetops.php b/server/config/fleetops.php index 1db36f700..e3b5c8106 100644 --- a/server/config/fleetops.php +++ b/server/config/fleetops.php @@ -37,7 +37,11 @@ |-------------------------------------------------------------------------- */ 'osrm' => [ - 'host' => env('OSRM_HOST', 'https://router.project-osrm.org'), + // Starting value: Admin -> Fleet-Ops -> Routing and an organization's own + // OSRM server (Fleet-Ops Settings -> Routing) take precedence. + 'host' => env('OSRM_HOST', 'https://router.project-osrm.org'), + // Let organization OSRM hosts point at private network addresses (self-hosted installs only). + 'allow_private_hosts' => env('OSRM_ALLOW_PRIVATE_HOSTS', false), ], /* diff --git a/server/src/Http/Controllers/Internal/v1/SettingController.php b/server/src/Http/Controllers/Internal/v1/SettingController.php index eb7fccf74..dad37acd8 100644 --- a/server/src/Http/Controllers/Internal/v1/SettingController.php +++ b/server/src/Http/Controllers/Internal/v1/SettingController.php @@ -4,6 +4,8 @@ use Fleetbase\FleetOps\Jobs\DispatchTelematicsRetentionJobs; use Fleetbase\FleetOps\Orchestration\Support\OrchestratorSettings; +use Fleetbase\FleetOps\Support\Routing\HostPolicy; +use Fleetbase\FleetOps\Support\Routing\OsrmConnection; use Fleetbase\FleetOps\Support\Routing\RoutingEngineResolver; use Fleetbase\FleetOps\Support\Routing\RoutingProviderPolicy; use Fleetbase\FleetOps\Support\Telematics\Retention\RetentionPolicy; @@ -41,6 +43,10 @@ public function __construct() 'getAdminTrackingSettings' => 'admin', 'getAdminRoutingProviders' => 'admin', 'saveAdminRoutingProviders' => 'admin', + 'getAdminOsrmSettings' => 'admin', + 'saveAdminOsrmSettings' => 'admin', + 'getOsrmSettings' => ['view routing-settings', 'update routing-settings'], + 'saveOsrmSettings' => 'update routing-settings', 'saveAdminTrackingSettings' => 'admin', 'saveMapSettings' => 'update map-settings', 'getAdminMapSettings' => 'admin', @@ -305,9 +311,100 @@ public function getRoutingSettings() $routingSettings['unit'] = 'km'; } + // The OSRM server the console draws OSRM routes with (not a secret) + $osrm = OsrmConnection::resolve($companyUuid); + $routingSettings['osrm_host'] = $osrm['host']; + $routingSettings['osrm_host_source'] = $osrm['source']; + return response()->json($routingSettings); } + /** + * The system OSRM server (Admin -> Fleet-Ops -> Routing): the saved host, the + * OSRM_HOST starting value, and the server in use for organizations without + * their own. + * + * @return \Illuminate\Http\JsonResponse + */ + public function getAdminOsrmSettings() + { + return response()->json([ + 'api_host' => data_get($this->lookupSetting(OsrmConnection::SETTING_KEY, []), 'api_host'), + 'environment_host' => config('fleetops.osrm.host'), + 'host' => OsrmConnection::host(), + ]); + } + + /** + * Save the system OSRM server. Administrators may use private addresses; the + * host must still be an http(s) URL without credentials. Blank clears it. + * + * @return \Illuminate\Http\JsonResponse + */ + public function saveAdminOsrmSettings(Request $request) + { + $host = $this->submittedOsrmHost($request, true); + if (!is_string($host) && $host !== null) { + return $host; + } + + $this->configureSetting(OsrmConnection::SETTING_KEY, ['api_host' => $host]); + OsrmConnection::forget(); + + return $this->getAdminOsrmSettings(); + } + + /** + * The organization's own OSRM server, if any, and the server it otherwise uses. + * + * @return \Illuminate\Http\JsonResponse + */ + public function getOsrmSettings() + { + return response()->json([ + 'api_host' => data_get($this->lookupCompanySetting(OsrmConnection::SETTING_KEY, []), 'api_host'), + 'system_host' => OsrmConnection::host(), + ]); + } + + /** + * Save the organization's own OSRM server. The host must be a public address + * (unless OSRM_ALLOW_PRIVATE_HOSTS is set). Blank clears it. + * + * @return \Illuminate\Http\JsonResponse + */ + public function saveOsrmSettings(Request $request) + { + $host = $this->submittedOsrmHost($request, OsrmConnection::allowsPrivateHosts()); + if (!is_string($host) && $host !== null) { + return $host; + } + + $this->configureCompanySetting(OsrmConnection::SETTING_KEY, ['api_host' => $host]); + OsrmConnection::forget($this->policyCompanyUuid()); + + return $this->getOsrmSettings(); + } + + /** + * The submitted OSRM host without a trailing slash, null when blank, or a + * 422 response when the host policy refuses it. + */ + protected function submittedOsrmHost(Request $request, bool $allowPrivateHosts): mixed + { + $host = rtrim(trim((string) $request->input('api_host')), '/'); + if ($host === '') { + return null; + } + + $violation = HostPolicy::violation($host, $allowPrivateHosts); + if ($violation !== null) { + return response()->error($violation, 422); + } + + return $host; + } + /** * Save order tracking intelligence settings. * diff --git a/server/src/Support/OSRM.php b/server/src/Support/OSRM.php index 4ab8cb2fe..83c81959f 100644 --- a/server/src/Support/OSRM.php +++ b/server/src/Support/OSRM.php @@ -3,6 +3,7 @@ namespace Fleetbase\FleetOps\Support; use Fleetbase\FleetOps\Support\Encoding\Polyline; +use Fleetbase\FleetOps\Support\Routing\OsrmConnection; use Fleetbase\LaravelMysqlSpatial\Types\Point; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; @@ -21,6 +22,27 @@ class OSRM */ protected static $baseUrl = 'https://router.project-osrm.org'; + /** + * The organization whose OSRM server is called, when the caller knows it + * (otherwise the session company). See OsrmConnection. + */ + protected static ?string $companyUuid = null; + + /** + * Run OSRM calls against an organization's OSRM server. + */ + public static function forCompany(?string $companyUuid, callable $callback): mixed + { + $previous = static::$companyUuid; + static::$companyUuid = $companyUuid; + + try { + return $callback(); + } finally { + static::$companyUuid = $previous; + } + } + /** * Get the route between two points. * @@ -262,6 +284,6 @@ public static function decodePolyline($polyline) protected static function baseUrl(): string { - return rtrim(config('fleetops.osrm.host', static::$baseUrl), '/'); + return OsrmConnection::host(static::$companyUuid ?? session('company')); } } diff --git a/server/src/Support/Routing/OsrmConnection.php b/server/src/Support/Routing/OsrmConnection.php new file mode 100644 index 000000000..02568c85c --- /dev/null +++ b/server/src/Support/Routing/OsrmConnection.php @@ -0,0 +1,117 @@ + [ + 'organization' => $companyUuid ? static::organizationHost(static::lookup(fn () => Setting::lookupForCompany($companyUuid, static::SETTING_KEY, []))) : null, + 'system' => data_get(static::lookup(fn () => Setting::lookup(static::SETTING_KEY, [])), 'api_host'), + ]; + $levels = Cache::remember(static::cacheKey($companyUuid), static::CACHE_TTL, $load); + if (!is_array($levels)) { + $levels = $load(); + } + + foreach ([...$levels, 'environment' => config('fleetops.osrm.host')] as $source => $host) { + $host = rtrim(trim((string) $host), '/'); + if ($host !== '') { + return ['host' => $host, 'source' => $source]; + } + } + + return ['host' => 'https://' . static::PUBLIC_HOST, 'source' => 'default']; + } + + public static function host(?string $companyUuid = null): string + { + return static::resolve($companyUuid)['host']; + } + + /** + * Whether the server may send traffic to the organization's OSRM server: + * anything but the public demo server. + */ + public static function isConfigured(?string $companyUuid = null): bool + { + return static::isPrivateHost(static::host($companyUuid)); + } + + public static function isPrivateHost(?string $host): bool + { + $hostname = parse_url((string) $host, PHP_URL_HOST); + + return filled($hostname) && strcasecmp($hostname, static::PUBLIC_HOST) !== 0; + } + + /** + * Drop the cached resolution after a settings change. + */ + public static function forget(?string $companyUuid = null): void + { + Cache::forget(static::cacheKey($companyUuid)); + } + + public static function allowsPrivateHosts(): bool + { + return filter_var(config('fleetops.osrm.allow_private_hosts', false), FILTER_VALIDATE_BOOLEAN); + } + + protected static function cacheKey(?string $companyUuid): string + { + return 'fleetops:osrm-connection:' . ($companyUuid ?? 'system'); + } + + /** + * An organization host the server must not call (a private, loopback or + * metadata address) is ignored, so the system server is used instead. + */ + protected static function organizationHost(array $settings): ?string + { + $host = trim((string) data_get($settings, 'api_host')); + if ($host === '' || !HostPolicy::allows($host, static::allowsPrivateHosts())) { + return null; + } + + return $host; + } + + protected static function lookup(callable $lookup): array + { + try { + $settings = $lookup(); + } catch (\Throwable) { + $settings = []; + } + + return is_array($settings) ? $settings : []; + } +} diff --git a/server/src/Support/Routing/RoutingEngineResolver.php b/server/src/Support/Routing/RoutingEngineResolver.php index f8bf50364..67c5ac603 100644 --- a/server/src/Support/Routing/RoutingEngineResolver.php +++ b/server/src/Support/Routing/RoutingEngineResolver.php @@ -86,18 +86,20 @@ public static function isAvailable(string $engine, ?string $companyUuid = null): } return match ($engine) { - 'osrm' => static::hasPrivateOsrmHost(), + 'osrm' => static::hasPrivateOsrmHost($companyUuid), 'valhalla' => ValhallaClient::isConfigured($companyUuid), 'google' => filled(static::googleMapsApiKey()), default => false, }; } - public static function hasPrivateOsrmHost(): bool + /** + * Whether the organization's OSRM server (its own, the admin's, or OSRM_HOST) + * is a private one the server may call: never the public demo server. + */ + public static function hasPrivateOsrmHost(?string $companyUuid = null): bool { - $host = parse_url((string) config('fleetops.osrm.host'), PHP_URL_HOST); - - return filled($host) && strcasecmp($host, static::PUBLIC_OSRM_HOST) !== 0; + return OsrmConnection::isConfigured($companyUuid); } public static function googleMapsApiKey(): ?string diff --git a/server/src/Support/Utils.php b/server/src/Support/Utils.php index a6fca2b61..e4747a353 100644 --- a/server/src/Support/Utils.php +++ b/server/src/Support/Utils.php @@ -835,13 +835,13 @@ public static function getDrivingDistanceAndTime($origin, $destination, $options } // The public OSRM demo server's usage policy does not allow production traffic - if ($provider === 'osrm' && !RoutingEngineResolver::hasPrivateOsrmHost()) { + if ($provider === 'osrm' && !RoutingEngineResolver::hasPrivateOsrmHost($companyUuid)) { throw new \RuntimeException('No private OSRM host is configured.'); } $distanceMatrix = match ($provider) { 'valhalla' => static::getDistanceMatrixFromValhalla($origins, $destinations, $companyUuid), - 'osrm' => static::getDistanceMatrixFromOSRM($origins, $destinations), + 'osrm' => OSRM::forCompany($companyUuid, fn () => static::getDistanceMatrixFromOSRM($origins, $destinations)), 'google' => static::getDistanceMatrixFromGoogle($origins, $destinations), default => throw new \InvalidArgumentException('Unsupported distance matrix provider: ' . $provider), }; diff --git a/server/src/Tracking/Providers/OsrmTrackingProvider.php b/server/src/Tracking/Providers/OsrmTrackingProvider.php index 097e2f6f3..39da12df6 100644 --- a/server/src/Tracking/Providers/OsrmTrackingProvider.php +++ b/server/src/Tracking/Providers/OsrmTrackingProvider.php @@ -29,7 +29,7 @@ public function capabilities(): TrackingProviderCapabilities */ public function canTrack(TrackingContext $context): bool { - return $context->canRoute() && RoutingEngineResolver::hasPrivateOsrmHost(); + return $context->canRoute() && RoutingEngineResolver::hasPrivateOsrmHost($context->order->company_uuid); } public function track(TrackingContext $context, TrackingOptions $options): TrackingProviderResult @@ -71,11 +71,11 @@ public function track(TrackingContext $context, TrackingOptions $options): Track protected function routeResponse(TrackingContext $context): array { - return OSRM::getRouteFromPoints($context->routePoints(), [ + return OSRM::forCompany($context->order->company_uuid, fn () => OSRM::getRouteFromPoints($context->routePoints(), [ 'overview' => 'full', 'geometries' => 'polyline', 'steps' => 'false', 'annotations' => 'false', - ]); + ])); } } diff --git a/server/src/routes.php b/server/src/routes.php index 64e16665a..2159f61fa 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -834,6 +834,10 @@ function ($router) { $router->get('admin-tracking-settings', 'SettingController@getAdminTrackingSettings'); $router->post('admin-tracking-settings', 'SettingController@saveAdminTrackingSettings'); $router->get('admin-routing-providers', 'SettingController@getAdminRoutingProviders'); + $router->get('admin-osrm-settings', 'SettingController@getAdminOsrmSettings'); + $router->post('admin-osrm-settings', 'SettingController@saveAdminOsrmSettings'); + $router->get('osrm-settings', 'SettingController@getOsrmSettings'); + $router->post('osrm-settings', 'SettingController@saveOsrmSettings'); $router->post('admin-routing-providers', 'SettingController@saveAdminRoutingProviders'); $router->get('tracking-page-settings', 'SettingController@getTrackingPageSettings'); $router->post('tracking-page-settings', 'SettingController@saveTrackingPageSettings'); diff --git a/server/tests/SettingControllerContractsTest.php b/server/tests/SettingControllerContractsTest.php index f25825347..d2b7f018e 100644 --- a/server/tests/SettingControllerContractsTest.php +++ b/server/tests/SettingControllerContractsTest.php @@ -387,6 +387,8 @@ function fleetopsMapCenterFor(mixed $company, FleetOpsMapCenterRedisFake $redis) 'optimization_engine' => 'mapbox', 'routing_optimization_engine' => 'mapbox', 'unit' => 'km', + 'osrm_host' => 'https://router.project-osrm.org', + 'osrm_host_source' => 'default', ]); // Switched off again, a saved Google choice is replaced by the platform defaults @@ -1001,3 +1003,61 @@ protected function tableCount(string $table, Closure $scope): int } } }); + +test('setting controller reads and saves the system and organization OSRM servers', function () { + Cache::flush(); + Fleetbase\FleetOps\Support\Routing\HostPolicy::resolveHostsUsing(fn (string $host) => str_starts_with($host, 'internal.') ? ['10.0.0.12'] : ['93.184.216.34']); + config(['fleetops.osrm.host' => 'https://router.project-osrm.org', 'fleetops.osrm.allow_private_hosts' => false]); + $controller = new FleetOpsSettingControllerProbe(); + + try { + expect(fleetopsJsonPayload($controller->getAdminOsrmSettings()))->toBe([ + 'api_host' => null, + 'environment_host' => 'https://router.project-osrm.org', + 'host' => 'https://router.project-osrm.org', + ]); + + // Administrators may use a private address; the trailing slash is dropped + $saved = fleetopsJsonPayload($controller->saveAdminOsrmSettings(new Request(['api_host' => ' http://10.0.0.5:5000/ ']))); + expect($controller->configured['osrm'])->toBe(['api_host' => 'http://10.0.0.5:5000']) + ->and($saved['api_host'])->toBe('http://10.0.0.5:5000'); + + // ...but never a non-http URL or one carrying credentials + foreach (['file:///etc/passwd', 'https://user:pass@osrm.example.com'] as $host) { + $refused = $controller->saveAdminOsrmSettings(new Request(['api_host' => $host])); + expect($refused->getStatusCode())->toBe(422); + } + expect($controller->configured['osrm'])->toBe(['api_host' => 'http://10.0.0.5:5000']); + + // Blank clears the system server + $controller->saveAdminOsrmSettings(new Request(['api_host' => ' '])); + expect($controller->configured['osrm'])->toBe(['api_host' => null]); + + // An organization's own server must be a public address + $controller->lookupCompanySettings['osrm'] = ['api_host' => 'https://old.osrm.example.com']; + expect(fleetopsJsonPayload($controller->getOsrmSettings()))->toBe([ + 'api_host' => 'https://old.osrm.example.com', + 'system_host' => 'https://router.project-osrm.org', + ]); + + $private = $controller->saveOsrmSettings(new Request(['api_host' => 'https://internal.osrm.example.com'])); + expect($private->getStatusCode())->toBe(422) + ->and(json_encode(fleetopsJsonPayload($private)))->toContain('private, loopback or reserved address') + ->and($controller->configuredCompany)->not->toHaveKey('osrm'); + + fleetopsJsonPayload($controller->saveOsrmSettings(new Request(['api_host' => 'https://osrm.example.com/']))); + expect($controller->configuredCompany['osrm'])->toBe(['api_host' => 'https://osrm.example.com']); + + // ...unless the install allows private organization hosts + config(['fleetops.osrm.allow_private_hosts' => true]); + $controller->saveOsrmSettings(new Request(['api_host' => 'https://internal.osrm.example.com'])); + expect($controller->configuredCompany['osrm'])->toBe(['api_host' => 'https://internal.osrm.example.com']); + + $controller->saveOsrmSettings(new Request([])); + expect($controller->configuredCompany['osrm'])->toBe(['api_host' => null]); + } finally { + Fleetbase\FleetOps\Support\Routing\HostPolicy::resolveHostsUsing(null); + config(['fleetops.osrm.host' => null, 'fleetops.osrm.allow_private_hosts' => false]); + Cache::flush(); + } +}); diff --git a/server/tests/Unit/Support/RoutingEngineResolverTest.php b/server/tests/Unit/Support/RoutingEngineResolverTest.php index dbf5b5842..846c406df 100644 --- a/server/tests/Unit/Support/RoutingEngineResolverTest.php +++ b/server/tests/Unit/Support/RoutingEngineResolverTest.php @@ -1,6 +1,8 @@ false]); + config(['fleetops.valhalla.allow_private_hosts' => false, 'fleetops.osrm.allow_private_hosts' => false]); config([ 'fleetops.distance_matrix.provider' => null, 'fleetops.osrm.host' => null, @@ -679,3 +681,99 @@ public function callHelper(string $method, ...$arguments) ->and($requests)->toBe(0) ->and($log->warnings[0][1]['error'])->toBe('No private OSRM host is configured.'); }); + +test('osrm uses an organization server, then the admin server, then OSRM_HOST', function () { + $connection = fleetopsDistanceProviderBoot(); + config(['fleetops.osrm.host' => 'https://router.project-osrm.org']); + + // Only the public demo server: a source of map routes, never server traffic + expect(OsrmConnection::resolve('company-1'))->toBe(['host' => 'https://router.project-osrm.org', 'source' => 'environment']) + ->and(OsrmConnection::isConfigured('company-1'))->toBeFalse() + ->and(RoutingEngineResolver::hasPrivateOsrmHost('company-1'))->toBeFalse(); + + config(['fleetops.osrm.host' => '']); + Cache::flush(); + expect(OsrmConnection::resolve())->toBe(['host' => 'https://router.project-osrm.org', 'source' => 'default']); + + // The system server saved in Admin -> Fleet-Ops -> Routing wins over the environment + config(['fleetops.osrm.host' => 'https://env-osrm.fleetbase.test']); + $connection->table('settings')->insert(['key' => 'osrm', 'value' => json_encode(['api_host' => 'https://osrm.fleetbase.test/'])]); + // An organization with its own server uses it; one the server must not call is ignored + $connection->table('settings')->insert(['key' => 'company.company-2.osrm', 'value' => json_encode(['api_host' => 'https://own.osrm.test'])]); + $connection->table('settings')->insert(['key' => 'company.company-3.osrm', 'value' => json_encode(['api_host' => 'http://169.254.169.254/latest'])]); + Cache::flush(); + + expect(OsrmConnection::resolve('company-1'))->toBe(['host' => 'https://osrm.fleetbase.test', 'source' => 'system']) + ->and(OsrmConnection::resolve('company-2'))->toBe(['host' => 'https://own.osrm.test', 'source' => 'organization']) + ->and(OsrmConnection::resolve('company-3'))->toBe(['host' => 'https://osrm.fleetbase.test', 'source' => 'system']) + ->and(OsrmConnection::resolve(null))->toBe(['host' => 'https://osrm.fleetbase.test', 'source' => 'system']) + ->and(RoutingEngineResolver::hasPrivateOsrmHost('company-2'))->toBeTrue(); + + // Results are cached until a save forgets them + $connection->table('settings')->where('key', 'company.company-2.osrm')->update(['value' => json_encode(['api_host' => 'https://moved.osrm.test'])]); + expect(OsrmConnection::host('company-2'))->toBe('https://own.osrm.test'); + OsrmConnection::forget('company-2'); + expect(OsrmConnection::host('company-2'))->toBe('https://moved.osrm.test'); + + // Private organization hosts only when the install opts in + $connection->table('settings')->insert(['key' => 'company.company-4.osrm', 'value' => json_encode(['api_host' => 'http://10.0.0.12:5000'])]); + expect(OsrmConnection::host('company-4'))->toBe('https://osrm.fleetbase.test'); + config(['fleetops.osrm.allow_private_hosts' => true]); + OsrmConnection::forget('company-4'); + expect(OsrmConnection::host('company-4'))->toBe('http://10.0.0.12:5000') + ->and(OsrmConnection::isPrivateHost('not a url'))->toBeFalse(); + + // An unreadable settings store falls back to the environment + $connection->getSchemaBuilder()->drop('settings'); + Cache::flush(); + expect(OsrmConnection::resolve('company-2'))->toBe(['host' => 'https://env-osrm.fleetbase.test', 'source' => 'environment']); +}); + +test('osrm calls go to the organization server the caller names, then the session company', function () { + $connection = fleetopsDistanceProviderBoot(); + config(['fleetops.osrm.host' => 'https://env-osrm.fleetbase.test']); + $connection->table('settings')->insert(['key' => 'company.company-2.osrm', 'value' => json_encode(['api_host' => 'https://own.osrm.test'])]); + Cache::flush(); + $urls = []; + Http::fake(function ($request) use (&$urls) { + $urls[] = (string) $request->url(); + + return Http::response(['code' => 'Ok', 'routes' => [['distance' => 1000.0, 'duration' => 60.0]]]); + }); + + OSRM::getRouteFromCoordinatesString('144.83,-37.78;144.58,-37.68'); + $result = OSRM::forCompany('company-2', fn () => OSRM::getRouteFromCoordinatesString('144.83,-37.79;144.58,-37.68')); + session(['company' => 'company-2']); + OSRM::getRouteFromCoordinatesString('144.83,-37.80;144.58,-37.68'); + + expect($urls[0])->toStartWith('https://env-osrm.fleetbase.test/route/v1/driving/') + ->and($urls[1])->toStartWith('https://own.osrm.test/route/v1/driving/') + ->and($urls[2])->toStartWith('https://own.osrm.test/route/v1/driving/') + ->and((float) data_get($result, 'routes.0.distance'))->toBe(1000.0); + + // The scope is restored even when the call fails + session(['company' => null]); + expect(fn () => OSRM::forCompany('company-2', fn () => throw new RuntimeException('boom')))->toThrow(RuntimeException::class, 'boom'); + OSRM::getRouteFromCoordinatesString('144.83,-37.81;144.58,-37.68'); + expect($urls[3])->toStartWith('https://env-osrm.fleetbase.test/'); +}); + +test('order distance from osrm uses the organization osrm server', function () { + $connection = fleetopsDistanceProviderBoot(); + config(['fleetops.osrm.host' => 'https://router.project-osrm.org']); + $connection->table('settings')->insert(['key' => 'company.company-2.osrm', 'value' => json_encode(['api_host' => 'https://own.osrm.test'])]); + fleetopsDistanceProviderRouting($connection, 'company-2', ['display_engine' => 'osrm']); + Cache::flush(); + $urls = []; + Http::fake(function ($request) use (&$urls) { + $urls[] = (string) $request->url(); + + return Http::response(['code' => 'Ok', 'routes' => [['distance' => 31520.0, 'duration' => 1610.0]]]); + }); + + expect(RoutingEngineResolver::resolve('company-2'))->toBe('osrm'); + $matrix = Utils::getDrivingDistanceAndTime([-37.78, 144.83], [-37.68, 144.58], ['company_uuid' => 'company-2']); + + expect($matrix->distance)->toBe(31520.0) + ->and($urls[0])->toStartWith('https://own.osrm.test/route/v1/driving/'); +}); diff --git a/tests/integration/components/admin/routing-settings-test.js b/tests/integration/components/admin/routing-settings-test.js index 64b786d02..042546a90 100644 --- a/tests/integration/components/admin/routing-settings-test.js +++ b/tests/integration/components/admin/routing-settings-test.js @@ -23,6 +23,10 @@ class FetchStubService extends Service { }); } + if (url === 'fleet-ops/settings/admin-osrm-settings') { + return Promise.resolve({ api_host: 'https://osrm.example.com', environment_host: 'https://router.project-osrm.org' }); + } + return Promise.resolve({ traffic_enabled: false, cache_ttl_seconds: 45 }); } @@ -72,6 +76,7 @@ module('Integration | Component | admin/routing-settings', function (hooks) { assert.dom('[data-test-provider-access]').exists({ count: 4 }); assert.dom('[data-test-provider-access="google"]').includesText('Billed per request'); assert.dom('[data-test-provider-exception="company-1"]').includesText('Acme Logistics'); + assert.dom('[data-test-osrm-host]').hasValue('https://osrm.example.com'); // Switch Google on for everyone, then remove the now-redundant exception await click('[data-test-provider-access="google"] [role="checkbox"]'); @@ -88,11 +93,12 @@ module('Integration | Component | admin/routing-settings', function (hooks) { exceptions: [], }, ]); + assert.deepEqual(fetch.posts[1], ['fleet-ops/settings/admin-osrm-settings', { api_host: 'https://osrm.example.com' }]); // Tracking only saves behaviour; its provider is the routing engine - assert.strictEqual(fetch.posts[1][0], 'fleet-ops/settings/admin-tracking-settings'); - assert.false('provider' in fetch.posts[1][1]); - assert.false(fetch.posts[1][1].traffic_enabled); - assert.strictEqual(fetch.posts[1][1].cache_ttl_seconds, 45); + assert.strictEqual(fetch.posts[2][0], 'fleet-ops/settings/admin-tracking-settings'); + assert.false('provider' in fetch.posts[2][1]); + assert.false(fetch.posts[2][1].traffic_enabled); + assert.strictEqual(fetch.posts[2][1].cache_ttl_seconds, 45); assert.deepEqual(this.owner.lookup('service:notifications').successes, ['Routing settings saved.']); }); }); diff --git a/tests/unit/services/osrm-test.js b/tests/unit/services/osrm-test.js index 38185a624..4380749fa 100644 --- a/tests/unit/services/osrm-test.js +++ b/tests/unit/services/osrm-test.js @@ -1,12 +1,36 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import Service from '@ember/service'; +import config from 'ember-get-config'; module('Unit | Service | osrm', function (hooks) { setupTest(hooks); - // TODO: Replace this with your real tests. + function stubRouting(owner, routing) { + owner.register( + 'service:current-user', + class extends Service { + getOption(key) { + return key === 'routing' ? routing : undefined; + } + } + ); + } + test('it exists', function (assert) { let service = this.owner.lookup('service:osrm'); assert.ok(service); }); + + test('it calls the organization or admin OSRM server the routing settings resolved', function (assert) { + stubRouting(this.owner, { osrm_host: 'https://own.osrm.example.com', osrm_host_source: 'organization' }); + + assert.strictEqual(this.owner.lookup('service:osrm').routingHost(), 'https://own.osrm.example.com'); + }); + + test('it falls back to the console OSRM host when only the environment or the demo server is set', function (assert) { + stubRouting(this.owner, { osrm_host: 'https://router.project-osrm.org', osrm_host_source: 'default' }); + + assert.strictEqual(this.owner.lookup('service:osrm').routingHost(), config.osrm.host); + }); }); From c1ee6e3e1193b540e732cfb6e384e81917e28387 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 27 Oct 2026 17:12:20 +0800 Subject: [PATCH 22/22] test(navigator): cover the no-driver and blank-identity guards --- .../Api/DriverControllerAuthFlowsTest.php | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php b/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php index fab99ccae..6106e11a7 100644 --- a/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +++ b/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php @@ -740,3 +740,26 @@ function fleetopsNavigatorReviewOn(string $company): string config(['fleetops.navigator.bypass_verification_code' => null, 'fleetops.navigator.review_accounts' => []]); } }); + +test('a driver token is never issued without a driver or its account', function () { + $connection = fleetopsDriverAuthBoot(); + $controller = new DriverController(); + $issue = (new ReflectionMethod(DriverController::class, 'issueDriverToken'))->getClosure($controller); + + // No driver at all, and a driver whose login account no longer exists + $connection->table('drivers')->insert(['uuid' => 'driver-orphan', 'company_uuid' => 'company-1', 'user_uuid' => 'user-missing']); + + foreach ([$issue(null), $issue(Driver::withoutGlobalScopes()->where('uuid', 'driver-orphan')->first())] as $response) { + expect($response)->toBeInstanceOf(JsonResponse::class) + ->and($response->getData(true)['error'])->toBe('Unable to authenticate driver.'); + } +}); + +test('blank identities and drivers without an account are never the Navigator review number', function () { + fleetopsDriverAuthBoot(); + + expect(Fleetbase\FleetOps\Support\NavigatorReview::isReviewPhone(null))->toBeFalse() + ->and(Fleetbase\FleetOps\Support\NavigatorReview::isReviewPhone(''))->toBeFalse() + ->and(Fleetbase\FleetOps\Support\NavigatorReview::isReviewDriver(null))->toBeFalse() + ->and(Fleetbase\FleetOps\Support\NavigatorReview::isReviewDriver(new Driver()))->toBeFalse(); +});