diff --git a/server/src/Http/Controllers/Internal/v1/OrderController.php b/server/src/Http/Controllers/Internal/v1/OrderController.php index 4ad6b05d3..1375bc8d3 100644 --- a/server/src/Http/Controllers/Internal/v1/OrderController.php +++ b/server/src/Http/Controllers/Internal/v1/OrderController.php @@ -19,6 +19,7 @@ use Fleetbase\FleetOps\Http\Resources\v1\Index\Order as OrderIndexResource; use Fleetbase\FleetOps\Http\Resources\v1\Order as OrderResource; use Fleetbase\FleetOps\Http\Resources\v1\Proof as ProofResource; +use Fleetbase\FleetOps\Http\Resources\v1\PublicOrderTracking; use Fleetbase\FleetOps\Imports\OrdersImport; use Fleetbase\FleetOps\Jobs\FinalizeInternalOrderCreation; use Fleetbase\FleetOps\Jobs\NotifyBulkAssignedDriver; @@ -54,6 +55,12 @@ class OrderController extends FleetOpsController { use ResolvesOrderServiceStops; + + /** + * The one answer the public tracking lookup gives for any number it cannot resolve. + */ + public const TRACKING_LOOKUP_ERROR = 'No order found using tracking number provided.'; + /** * Permissions for methods AuthorizationGuard cannot map to a schema action (see FleetOpsController). * @@ -1905,27 +1912,43 @@ public function getDefaultOrderConfig() return response()->json(OrderConfig::default()); } + /** + * Look up an order for the public Track Order page. + * + * Answers with `PublicOrderTracking`, never the full order resource: the + * tracking number is the only credential here. A missing, malformed or + * unknown tracking number all get the same error, so a caller cannot tell + * which one they hit. + */ public function lookup(Request $request) { - $trackingNumber = $request->input('tracking'); - if (!$trackingNumber) { - return response()->error('No tracking number provided for lookup.'); - } - - $order = $this->findOrderByTrackingNumber($trackingNumber); + $trackingNumber = $this->normalizeTrackingLookup($request->input('tracking')); + $order = $trackingNumber ? $this->findOrderByTrackingNumber($trackingNumber) : null; if (!$order) { - return response()->error('No order found using tracking number provided.'); + return response()->error(static::TRACKING_LOOKUP_ERROR); } - // load required relations - $order->loadMissing(['trackingNumber', 'payload', 'trackingStatuses']); - - // load tracker data + $order->loadMissing(['trackingNumber', 'trackingStatuses', 'payload.pickup', 'payload.dropoff', 'payload.waypoints', 'payload.entities']); $order->tracker_data = $order->tracker()->toArray(); - $order->eta = $order->tracker()->eta(); - return new OrderResource($order); + return new PublicOrderTracking($order); + } + + /** + * The tracking number to look up, or null when the input cannot be one: + * not a string, blank, too long, or outside the characters tracking + * numbers use. + */ + protected function normalizeTrackingLookup($input): ?string + { + if (!is_string($input)) { + return null; + } + + $input = trim($input); + + return preg_match('/^[A-Za-z0-9][A-Za-z0-9._\-]{0,99}$/', $input) === 1 ? $input : null; } /** diff --git a/server/src/Http/Resources/v1/PublicOrderTracking.php b/server/src/Http/Resources/v1/PublicOrderTracking.php new file mode 100644 index 000000000..250013661 --- /dev/null +++ b/server/src/Http/Resources/v1/PublicOrderTracking.php @@ -0,0 +1,153 @@ +resource; + $trackingNumber = $order->trackingNumber; + $tracker = is_array($order->tracker_data) ? $order->tracker_data : []; + + return [ + 'uuid' => $order->public_id, + 'tracking' => data_get($trackingNumber, 'tracking_number'), + 'status' => $order->status, + 'has_driver_assigned' => (bool) $order->has_driver_assigned, + 'tracking_number' => $trackingNumber ? [ + 'uuid' => $trackingNumber->public_id, + 'tracking_number' => $trackingNumber->tracking_number, + ] : null, + 'tracking_statuses' => collect($order->trackingStatuses)->map(fn ($status) => [ + 'uuid' => $status->public_id, + 'status' => $status->status, + 'details' => $status->details, + 'created_at' => $status->created_at, + ])->values()->all(), + 'payload' => $this->payload($order->payload), + 'tracker_data' => $this->trackerData($tracker, static::isInProgress($order)), + 'created_at' => $order->created_at, + ]; + } + + /** + * Whether the order is under way: started, and not yet completed or canceled. + */ + public static function isInProgress(Order $order): bool + { + return (bool) $order->started && !in_array(strtolower((string) $order->status), static::FINISHED_STATUSES, true); + } + + /** + * The stops the map routes between and the items the page lists. + */ + protected function payload($payload): ?array + { + if (!$payload) { + return null; + } + + return [ + 'uuid' => $payload->public_id, + 'pickup' => $this->place($payload->pickup), + 'dropoff' => $this->place($payload->dropoff), + 'waypoints' => collect($payload->waypoints)->map(fn ($place) => $this->place($place))->values()->all(), + 'entities' => collect($payload->entities)->map(fn ($entity) => [ + 'uuid' => $entity->public_id, + 'name' => $entity->name, + 'description' => $entity->description, + 'tracking' => data_get($entity, 'tracking'), + 'price' => $entity->price, + 'currency' => $entity->currency, + 'photo_url' => data_get($entity, 'photo_url'), + ])->values()->all(), + ]; + } + + /** + * A stop as the map needs it: its coordinates, with no address or contact. + */ + protected function place($place): ?array + { + if (!$place) { + return null; + } + + return [ + 'uuid' => $place->public_id, + 'location' => Utils::castPoint($place->location), + ]; + } + + /** + * The tracker fields the page reads; the driver's position only while under way. + */ + protected function trackerData(array $tracker, bool $inProgress): array + { + return [ + 'driver' => [ + 'location' => $inProgress ? data_get($tracker, 'driver.location') : null, + ], + 'progress' => [ + 'percentage' => data_get($tracker, 'progress.percentage'), + 'completed_stops' => data_get($tracker, 'progress.completed_stops'), + ], + 'eta' => [ + 'active_stop_seconds' => data_get($tracker, 'eta.active_stop_seconds'), + 'completion_at' => data_get($tracker, 'eta.completion_at'), + ], + 'active_stop' => $this->stop(data_get($tracker, 'active_stop')), + 'next_stop' => $this->stop(data_get($tracker, 'next_stop')), + ]; + } + + /** + * A stop the page names under "Current/Next Destination". + */ + protected function stop($stop): ?array + { + if (!is_array($stop)) { + return null; + } + + return ['address' => data_get($stop, 'address')]; + } +} diff --git a/server/src/routes.php b/server/src/routes.php index dd757f07c..e2e3577c1 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -398,7 +398,10 @@ function ($router) { $router->group( ['prefix' => 'v1/fleet-ops', 'namespace' => 'v1'], function ($router) { - $router->get('lookup', 'OrderController@lookup'); + // The public Track Order page: no session, the tracking number is + // the credential. Rate limited per address so numbers cannot be + // enumerated, on a limiter of its own. + $router->get('lookup', 'OrderController@lookup')->middleware('throttle:30,1,tracking-lookup'); } ); diff --git a/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php b/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php index 6c815da8a..a92601055 100644 --- a/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php +++ b/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php @@ -4,6 +4,7 @@ use Fleetbase\FleetOps\Http\Controllers\Internal\v1\OrderController; use Fleetbase\FleetOps\Http\Requests\BulkDispatchRequest; use Fleetbase\FleetOps\Http\Requests\CancelOrderRequest; +use Fleetbase\FleetOps\Http\Resources\v1\PublicOrderTracking; use Fleetbase\FleetOps\Models\Driver; use Fleetbase\FleetOps\Models\Entity; use Fleetbase\FleetOps\Models\Order; @@ -68,6 +69,7 @@ class FleetOpsInternalOrderLifecycleControllerProbe extends OrderController public Collection $proofResults; public array $exportDownloads = []; public ?FleetOpsInternalOrderLifecycleOrderFake $trackingOrder = null; + public array $trackingLookups = []; public ?FleetOpsInternalOrderLifecycleOrderFake $scheduleOrder = null; public ?FleetOpsInternalOrderLifecycleDriverFake $scheduleDriver = null; @@ -218,6 +220,7 @@ protected function downloadOrderExport(array $selections, string $fileName) protected function findOrderByTrackingNumber(string $trackingNumber): ?Order { + $this->trackingLookups[] = $trackingNumber; $this->trackingOrder?->setAttribute('tracking_lookup', $trackingNumber); return $this->trackingOrder; @@ -1549,24 +1552,29 @@ function fleetopsSuppressStrNullDeprecations(): Closure ->and($export['selections'])->toBe(['order-one', 'order-two']) ->and($controller->exportDownloads[0][0])->toBe(['order-one', 'order-two']); - expect($controller->lookup(new Request())->getData(true))->toBe([ - 'error' => 'No tracking number provided for lookup.', - ]) - ->and($controller->lookup(new Request(['tracking' => 'TN-404']))->getData(true))->toBe([ - 'error' => 'No order found using tracking number provided.', - ]); + // Missing, malformed and unknown tracking numbers all get the one answer, + // and malformed input never reaches the query. + $notFound = ['error' => OrderController::TRACKING_LOOKUP_ERROR]; + foreach ([[], ['tracking' => ''], ['tracking' => ' '], ['tracking' => ['TN-1']], ['tracking' => '../etc/passwd'], ['tracking' => "TN-1' OR 1=1"], ['tracking' => str_repeat('A', 101)], ['tracking' => '-TN']] as $input) { + expect($controller->lookup(new Request($input))->getData(true))->toBe($notFound); + } + expect($controller->trackingLookups)->toBe([]); + + expect($controller->lookup(new Request(['tracking' => 'TN-404']))->getData(true))->toBe($notFound) + ->and($controller->trackingLookups)->toBe(['TN-404']); $trackedOrder = fleetopsInternalOrderLifecycleOrder('tracked-order'); $trackedOrder->trackerForTest = new FleetOpsInternalOrderLifecycleTrackerFake($trackedOrder); $controller->trackingOrder = $trackedOrder; - $lookup = $controller->lookup(new Request(['tracking' => 'TN-100'])); + $lookup = $controller->lookup(new Request(['tracking' => ' TN-100.a_b '])); - expect($lookup->resource)->toBe($trackedOrder) - ->and($trackedOrder->tracking_lookup)->toBe('TN-100') - ->and($trackedOrder->loadedMissing)->toBe([['trackingNumber', 'payload', 'trackingStatuses']]) + expect($lookup)->toBeInstanceOf(PublicOrderTracking::class) + ->and($lookup->resource)->toBe($trackedOrder) + ->and($trackedOrder->tracking_lookup)->toBe('TN-100.a_b') + ->and($trackedOrder->loadedMissing)->toBe([['trackingNumber', 'trackingStatuses', 'payload.pickup', 'payload.dropoff', 'payload.waypoints', 'payload.entities']]) ->and($trackedOrder->tracker_data)->toBe(['tracker' => 'info', 'options' => []]) - ->and($trackedOrder->eta)->toBe(['eta' => [['stop' => 'dropoff']], 'options' => []]); + ->and($trackedOrder->trackerForTest->etaOptions)->toBe([]); expect($controller->scheduleOrder(new Request(['order' => 'missing-order']))->getData(true))->toBe([ 'error' => 'No order found to schedule.', diff --git a/server/tests/PublicOrderTrackingResourceTest.php b/server/tests/PublicOrderTrackingResourceTest.php new file mode 100644 index 000000000..2f127d221 --- /dev/null +++ b/server/tests/PublicOrderTrackingResourceTest.php @@ -0,0 +1,221 @@ +setRawAttributes(array_merge([ + 'uuid' => 'order-internal-uuid', + 'public_id' => 'order_public', + 'internal_id' => 'INT-0042', + 'company_uuid' => 'company-uuid', + 'customer_uuid' => 'customer-uuid', + 'facilitator_uuid' => 'facilitator-uuid', + 'driver_assigned_uuid' => 'driver-uuid', + 'purchase_rate_uuid' => 'rate-uuid', + 'notes' => 'Gate code 1234', + 'meta' => '{"secret":"value"}', + 'status' => 'started', + 'started' => true, + 'created_at' => Carbon::parse('2026-10-01 09:00:00'), + ], $attributes), true); + + $order->setRelation('trackingNumber', (object) [ + 'uuid' => 'tracking-internal-uuid', + 'public_id' => 'track_public', + 'tracking_number' => 'FLB1234567890SG', + 'qr_code' => 'qr-data', + 'barcode' => 'barcode-data', + ]); + $order->setRelation('trackingStatuses', collect([ + (object) [ + 'uuid' => 'status-internal-uuid', + 'public_id' => 'status_public', + 'status' => 'Order Created', + 'details' => 'New order created.', + 'code' => 'created', + 'location' => new Point(1.3, 103.8), + 'created_at' => Carbon::parse('2026-10-01 09:00:00'), + ], + ])); + $order->setRelation('payload', (object) [ + 'uuid' => 'payload-internal-uuid', + 'public_id' => 'payload_public', + 'meta' => ['secret' => 'value'], + 'cod_amount' => '100', + 'pickup' => (object) [ + 'uuid' => 'pickup-internal-uuid', + 'public_id' => 'place_pickup', + 'name' => 'Sender House', + 'street1' => '1 Sender Road', + 'phone' => '+6500000000', + 'location' => new Point(1.30, 103.80), + ], + 'dropoff' => (object) [ + 'uuid' => 'dropoff-internal-uuid', + 'public_id' => 'place_dropoff', + 'name' => 'Recipient House', + 'street1' => '2 Recipient Road', + 'location' => new Point(1.35, 103.85), + ], + 'waypoints' => [], + 'entities' => collect([ + (object) [ + 'uuid' => 'entity-internal-uuid', + 'public_id' => 'entity_public', + 'internal_id' => 'ENT-INT', + 'customer_uuid' => 'customer-uuid', + 'name' => 'Parcel', + 'description' => 'A box', + 'tracking' => 'FLB9999999999SG', + 'price' => '1500', + 'currency' => 'SGD', + 'photo_url' => 'https://example.test/parcel.png', + 'meta' => ['secret' => 'value'], + ], + ]), + ]); + + $order->setAttribute('tracker_data', array_merge([ + 'provider' => 'internal', + 'driver' => [ + 'location' => ['type' => 'Point', 'coordinates' => [103.82, 1.32]], + 'location_age_seconds' => 12, + 'online' => true, + ], + 'progress' => ['percentage' => 40, 'completed_stops' => 1, 'total_stops' => 2], + 'stops' => [['uuid' => 'stop-uuid', 'address' => '1 Sender Road']], + 'active_stop' => ['uuid' => 'stop-uuid', 'address' => '2 Recipient Road', 'name' => 'Recipient House', 'latitude' => 1.35], + 'next_stop' => null, + 'route' => ['polyline' => 'abc'], + 'eta' => ['active_stop_seconds' => 600, 'completion_seconds' => 600, 'completion_at' => '2026-10-01T09:30:00.000Z'], + 'insights' => ['is_delayed' => false], + ], $tracker)); + + return $order; +} + +test('public order tracking resource carries only what the track order page renders', function () { + $data = (new PublicOrderTracking(fleetopsPublicTrackingOrder()))->toArray(new Request()); + + expect(array_keys($data))->toBe([ + 'uuid', 'tracking', 'status', 'has_driver_assigned', 'tracking_number', + 'tracking_statuses', 'payload', 'tracker_data', 'created_at', + ]) + ->and($data['uuid'])->toBe('order_public') + ->and($data['tracking'])->toBe('FLB1234567890SG') + ->and($data['status'])->toBe('started') + ->and($data['has_driver_assigned'])->toBeTrue() + ->and($data['tracking_number'])->toBe(['uuid' => 'track_public', 'tracking_number' => 'FLB1234567890SG']) + ->and($data['created_at']->toDateTimeString())->toBe('2026-10-01 09:00:00'); + + expect($data['tracking_statuses'])->toHaveCount(1) + ->and(array_keys($data['tracking_statuses'][0]))->toBe(['uuid', 'status', 'details', 'created_at']) + ->and($data['tracking_statuses'][0]['uuid'])->toBe('status_public') + ->and($data['tracking_statuses'][0]['status'])->toBe('Order Created') + ->and($data['tracking_statuses'][0]['details'])->toBe('New order created.'); + + // Stops are coordinates only: the map routes between them, nothing names them. + $payload = $data['payload']; + expect(array_keys($payload))->toBe(['uuid', 'pickup', 'dropoff', 'waypoints', 'entities']) + ->and($payload['uuid'])->toBe('payload_public') + ->and(array_keys($payload['pickup']))->toBe(['uuid', 'location']) + ->and($payload['pickup']['uuid'])->toBe('place_pickup') + ->and($payload['pickup']['location']->getLat())->toBe(1.30) + ->and($payload['dropoff']['location']->getLng())->toBe(103.85) + ->and($payload['waypoints'])->toBe([]) + ->and($payload['entities'])->toBe([[ + 'uuid' => 'entity_public', + 'name' => 'Parcel', + 'description' => 'A box', + 'tracking' => 'FLB9999999999SG', + 'price' => '1500', + 'currency' => 'SGD', + 'photo_url' => 'https://example.test/parcel.png', + ]]); + + expect($data['tracker_data'])->toBe([ + 'driver' => ['location' => ['type' => 'Point', 'coordinates' => [103.82, 1.32]]], + 'progress' => ['percentage' => 40, 'completed_stops' => 1], + 'eta' => ['active_stop_seconds' => 600, 'completion_at' => '2026-10-01T09:30:00.000Z'], + 'active_stop' => ['address' => '2 Recipient Road'], + 'next_stop' => null, + ]); + + // Nothing internal reaches the page, at any depth. + $json = json_encode($data); + foreach (['order-internal-uuid', 'INT-0042', 'company-uuid', 'customer-uuid', 'facilitator-uuid', 'driver-uuid', 'rate-uuid', 'Gate code', 'secret', 'qr-data', 'barcode-data', 'Sender House', '1 Sender Road', '+6500000000', 'ENT-INT', 'abc', 'internal'] as $leak) { + expect($json)->not->toContain($leak); + } +}); + +test('public order tracking resource drops the driver position unless the order is under way', function (array $attributes) { + $data = (new PublicOrderTracking(fleetopsPublicTrackingOrder($attributes)))->toArray(new Request()); + + expect($data['tracker_data']['driver'])->toBe(['location' => null]) + ->and($data['tracker_data']['progress']['percentage'])->toBe(40); +})->with([ + 'not started yet' => [['status' => 'dispatched', 'started' => false]], + 'completed' => [['status' => 'completed', 'started' => true]], + 'canceled' => [['status' => 'canceled', 'started' => true]], + 'cancelled' => [['status' => 'CANCELLED', 'started' => true]], +]); + +test('public order tracking resource reports in progress only for started unfinished orders', function () { + expect(PublicOrderTracking::isInProgress(fleetopsPublicTrackingOrder()))->toBeTrue() + ->and(PublicOrderTracking::isInProgress(fleetopsPublicTrackingOrder(['started' => false])))->toBeFalse() + ->and(PublicOrderTracking::isInProgress(fleetopsPublicTrackingOrder(['status' => 'completed'])))->toBeFalse(); +}); + +test('public order tracking resource tolerates missing relations and tracker data', function () { + $order = new Order(); + $order->setRawAttributes(['public_id' => 'order_bare', 'status' => 'created'], true); + $order->setRelation('trackingNumber', null); + $order->setRelation('trackingStatuses', collect()); + $order->setRelation('payload', null); + + $data = (new PublicOrderTracking($order))->toArray(new Request()); + + expect($data['uuid'])->toBe('order_bare') + ->and($data['tracking'])->toBeNull() + ->and($data['has_driver_assigned'])->toBeFalse() + ->and($data['tracking_number'])->toBeNull() + ->and($data['tracking_statuses'])->toBe([]) + ->and($data['payload'])->toBeNull() + ->and($data['tracker_data'])->toBe([ + 'driver' => ['location' => null], + 'progress' => ['percentage' => null, 'completed_stops' => null], + 'eta' => ['active_stop_seconds' => null, 'completion_at' => null], + 'active_stop' => null, + 'next_stop' => null, + ]); + + // A payload with stops removed still serializes, leaving the gaps empty. + $order->setRelation('payload', (object) [ + 'public_id' => 'payload_bare', + 'pickup' => null, + 'dropoff' => null, + 'waypoints' => collect([(object) ['public_id' => 'place_waypoint', 'location' => new Point(1.4, 103.9)]]), + 'entities' => [], + ]); + $payload = (new PublicOrderTracking($order))->toArray(new Request())['payload']; + + expect($payload['pickup'])->toBeNull() + ->and($payload['dropoff'])->toBeNull() + ->and($payload['waypoints'][0]['uuid'])->toBe('place_waypoint') + ->and($payload['waypoints'][0]['location']->getLat())->toBe(1.4) + ->and($payload['entities'])->toBe([]); +}); + +test('public order tracking resource is never wrapped', function () { + expect(PublicOrderTracking::$wrap)->toBeNull(); +}); diff --git a/server/tests/RouteRegistrationExecutionTest.php b/server/tests/RouteRegistrationExecutionTest.php index 803c50f7b..491558d9e 100644 --- a/server/tests/RouteRegistrationExecutionTest.php +++ b/server/tests/RouteRegistrationExecutionTest.php @@ -223,6 +223,12 @@ function fleetOpsRecordedRoutes(): FleetOpsRouteRecorder // Uploads through a link have a tighter limit of their own, on top of the group's. $upload = collect($recorder->routes)->firstWhere('uri', 'public/inspections/forms/{id}/files'); expect($upload['middleware'] ?? [])->toBe(['throttle:20,1,inspection-upload']); + + // The public tracking lookup has no session either, so it is rate limited + // per address, on a limiter of its own. + $lookup = collect($recorder->routes)->first(fn (array $route) => $route['action'] === 'OrderController@lookup'); + expect($lookup)->not->toBeNull() + ->and($lookup['middleware'] ?? [])->toBe(['throttle:30,1,tracking-lookup']); }); test('fleetops route file wires route groups with expected middleware and namespaces', function () {