From 5891d0bd53f29fe4d20b1f86de55f1db0378ec9c Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 12:37:47 +0800 Subject: [PATCH] fix(tracking): throttle the public order lookup and return a minimal resource The public Track Order page's GET int/v1/fleet-ops/lookup had no session, no rate limit, and answered with the full OrderResource: notes, meta, internal ids, files (possible POD photos and signatures), purchase rate, and live driver location, to anyone holding a tracking number. - Rate limit it per address on its own limiter (throttle:30,1,tracking-lookup), as the public inspection routes do. - Answer with PublicOrderTracking, which carries only what order-tracking-lookup renders: tracking number, status, timeline, ETA and progress, stop coordinates for the route map, and the item list. Records are keyed by public id, so no internal uuid reaches the page. The driver position is included only while the order is started and unfinished. It extends JsonResource so registered resource transformers cannot add fields. - Missing, malformed and unknown tracking numbers get one identical error, and malformed input never reaches the query. --- .../Internal/v1/OrderController.php | 49 ++-- .../Http/Resources/v1/PublicOrderTracking.php | 153 ++++++++++++ server/src/routes.php | 5 +- .../Internal/OrderControllerContractsTest.php | 30 ++- .../tests/PublicOrderTrackingResourceTest.php | 221 ++++++++++++++++++ .../tests/RouteRegistrationExecutionTest.php | 6 + 6 files changed, 439 insertions(+), 25 deletions(-) create mode 100644 server/src/Http/Resources/v1/PublicOrderTracking.php create mode 100644 server/tests/PublicOrderTrackingResourceTest.php 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 () {