From e3d8bc33e56d4977c10c4d6e9552269577d838fa Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 14:38:26 +0800 Subject: [PATCH 1/3] feat(tracking): structured tracking-url QR codes and Code 128 barcodes QR codes now carry a versioned tracking url naming the tracking number and the owner's public id (https:///track-order?order=&r=&v=1) instead of the owner's bare uuid, so a phone camera opens the tracking page while scanners read the parameters. The barcode is now Code 128 with the bare tracking number, matching the text printed beneath it. capture-qr and tracking-numbers/from-qr resolve scans through one parser that accepts the new url, a bare tracking number or public id, and the bare owner uuid printed on labels already in circulation. from-qr is now scoped to the session's company and resolves waypoint and place owners too. qr_code_content is published in every environment: it no longer exposes a uuid. Labels print the QR as a fixed square instead of cropping it with object-fit. --- server/resources/views/labels/default.php | 4 +- .../resources/views/labels/entity-label.php | 4 +- .../resources/views/labels/waypoint-label.php | 4 +- .../Controllers/Api/v1/OrderController.php | 5 +- .../Api/v1/TrackingNumberController.php | 38 +-- .../src/Http/Resources/v1/TrackingNumber.php | 42 +--- server/src/Models/Entity.php | 9 +- server/src/Models/TrackingNumber.php | 98 +++++++- server/src/Models/Waypoint.php | 9 +- .../src/Observers/TrackingNumberObserver.php | 24 +- server/src/Support/TrackingCode.php | 218 ++++++++++++++++++ 11 files changed, 365 insertions(+), 90 deletions(-) create mode 100644 server/src/Support/TrackingCode.php diff --git a/server/resources/views/labels/default.php b/server/resources/views/labels/default.php index b79adaf1b..2214e277a 100755 --- a/server/resources/views/labels/default.php +++ b/server/resources/views/labels/default.php @@ -53,7 +53,7 @@
- +
name); ?>
@@ -185,7 +185,7 @@
- +
tracking_number : $order->trackingNumber->tracking_number)); ?>
diff --git a/server/resources/views/labels/entity-label.php b/server/resources/views/labels/entity-label.php index ac62da5f7..6f354d225 100644 --- a/server/resources/views/labels/entity-label.php +++ b/server/resources/views/labels/entity-label.php @@ -45,7 +45,7 @@
- +
name) ?> @@ -114,7 +114,7 @@
- +
tracking_number : $entity->public_id) ?>
diff --git a/server/resources/views/labels/waypoint-label.php b/server/resources/views/labels/waypoint-label.php index 70f9a9585..fd043d703 100644 --- a/server/resources/views/labels/waypoint-label.php +++ b/server/resources/views/labels/waypoint-label.php @@ -52,7 +52,7 @@
- +
name); ?> @@ -123,7 +123,7 @@
- +
tracking_number : $order->trackingNumber->tracking_number)); ?>
diff --git a/server/src/Http/Controllers/Api/v1/OrderController.php b/server/src/Http/Controllers/Api/v1/OrderController.php index bc4054d9a..bd99b72a2 100644 --- a/server/src/Http/Controllers/Api/v1/OrderController.php +++ b/server/src/Http/Controllers/Api/v1/OrderController.php @@ -25,6 +25,7 @@ use Fleetbase\FleetOps\Models\ServiceQuote; use Fleetbase\FleetOps\Models\Waypoint; use Fleetbase\FleetOps\Support\ResolvesOrderServiceStops; +use Fleetbase\FleetOps\Support\TrackingCode; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Http\Controllers\Controller; use Fleetbase\Http\Resources\Comment as CommentResource; @@ -1444,8 +1445,8 @@ public function captureQrScan(Request $request, string $id, ?string $subjectId = return $this->apiError('Unable to capture QR code data.'); } - // validate - if ($subject && $code === $subject->uuid) { + // validate: the scan must name this subject, in any code format still in circulation + if (TrackingCode::matches($code, $subject)) { // create verification proof $proof = $this->createProof([ 'company_uuid' => session('company'), diff --git a/server/src/Http/Controllers/Api/v1/TrackingNumberController.php b/server/src/Http/Controllers/Api/v1/TrackingNumberController.php index d79658fcc..2c969163f 100644 --- a/server/src/Http/Controllers/Api/v1/TrackingNumberController.php +++ b/server/src/Http/Controllers/Api/v1/TrackingNumberController.php @@ -127,8 +127,10 @@ public function fromQR(DecodeTrackingNumberQR $request) // validate request inputs $code = $request->input('code'); - // get the model of from the code - $model = $this->findQrModel(['entities', 'orders'], ['uuid' => $code]); + // the order, waypoint, entity or place the code points at, in any format still in + // circulation: a tracking url, a bare tracking number or public id, or the bare + // owner uuid printed on older labels + $model = $this->findQrModel($code); // if no model response with error if (!$model) { @@ -179,37 +181,9 @@ protected function deletedTrackingNumberResource(TrackingNumber $trackingNumber) return new DeletedResource($trackingNumber); } - protected function findQrModel(array $tables, array $where) + protected function findQrModel(string $code) { - // Hydrate through the eloquent models so the resolved record can be - // wrapped in its typed resource — raw rows have no model class. - $modelMap = [ - 'entities' => \Fleetbase\FleetOps\Models\Entity::class, - 'orders' => \Fleetbase\FleetOps\Models\Order::class, - ]; - - foreach ($tables as $table) { - $modelClass = $modelMap[$table] ?? null; - if (!$modelClass) { - continue; - } - - $model = $modelClass::where($where)->first(); - if ($model) { - return $model; - } - } - - // No raw-row fallback. Utils::findModel() was called with the ARRAY of table names - // and, when nothing matched, passed that array to DB::table() — which stringified - // it to the literal table name "Array" and threw SQLSTATE[42S02]. Every - // unresolvable code became a 500 instead of the 400 this method's caller already - // handles. - // - // The fallback could not have helped anyway: qrModelResource() serializes through - // a typed resource keyed on the model class, and a raw stdClass row has none. The - // mapped models above are the endpoint's whole contract. - return null; + return TrackingNumber::findOwnerByCode($code, session('company')); } protected function qrModelResource($model) diff --git a/server/src/Http/Resources/v1/TrackingNumber.php b/server/src/Http/Resources/v1/TrackingNumber.php index bed9b0d5d..2f5c62ef6 100644 --- a/server/src/Http/Resources/v1/TrackingNumber.php +++ b/server/src/Http/Resources/v1/TrackingNumber.php @@ -2,6 +2,7 @@ namespace Fleetbase\FleetOps\Http\Resources\v1; +use Fleetbase\FleetOps\Support\TrackingCode; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Http\Resources\FleetbaseResource; use Fleetbase\Support\Http; @@ -30,47 +31,20 @@ public function toArray($request) 'status' => $this->last_status, 'status_code' => $this->last_status_code, 'qr_code' => $this->qr_code, - // The raw text encoded inside qr_code, exposed ONLY in debug mode. - // - // qr_code is a base64 PNG generated from owner_uuid (TrackingNumber::newBarcode), - // and the endpoints that consume a scanned code match on that uuid. A client - // scans the image to obtain it; an automated contract run cannot, so debug - // builds publish the value beside the image rather than the API growing a - // decode endpoint or handing out uuids generally. - // - // `when()` omits the key entirely when false — it is absent, not null — so a - // production response is byte-identical to before. - 'qr_code_content' => $this->when(static::exposesQrCodeContent(), fn () => $this->owner_uuid), + // A value that scans as this tracking number: the QR content current labels + // print (see TrackingCode). It names the tracking number and the subject's + // public id, both already in this resource, so it is safe to publish, and lets + // clients that print their own labels, or cannot decode a PNG, use the code. + // Labels printed before it encode the owner uuid; resolvers accept both. + 'qr_code_content' => $this->when($this->tracking_number, fn () => TrackingCode::qrContent($this->tracking_number, Utils::get($this->owner, 'public_id'))), 'barcode' => $this->barcode, - 'url' => Utils::consoleUrl('track-order', ['order' => $this->tracking_number]), + 'url' => Utils::consoleUrl(TrackingCode::PATH, [TrackingCode::TRACKING_PARAM => $this->tracking_number]), 'type' => Utils::getTypeFromClassName($this->owner_type), 'updated_at' => $this->updated_at, 'created_at' => $this->created_at, ]; } - /** - * Whether the QR code's decoded content may be published. - * - * Debug mode only, and fails closed: any failure to determine the debug state — no - * container, an application without the accessor — answers false rather than - * defaulting to exposure. - */ - protected static function exposesQrCodeContent(): bool - { - try { - $app = app(); - - if (!is_object($app) || !method_exists($app, 'hasDebugModeEnabled')) { - return false; - } - - return (bool) $app->hasDebugModeEnabled(); - } catch (\Throwable $e) { - return false; - } - } - /** * Transform the resource into an webhook payload. * diff --git a/server/src/Models/Entity.php b/server/src/Models/Entity.php index 032e2950b..224d15ec0 100644 --- a/server/src/Models/Entity.php +++ b/server/src/Models/Entity.php @@ -471,10 +471,11 @@ public static function insertGetUuid($values = [], ?Payload $payload = null) if ($result && $payload) { // create tracking number for entity $trackingNumberId = TrackingNumber::insertGetUuid([ - 'owner_uuid' => $uuid, - 'owner_type' => Utils::getModelClassName('entity'), - 'region' => $payload->getPickupRegion(), - 'location' => Utils::parsePointToWkt($payload->getPickupLocation()), + 'owner_uuid' => $uuid, + 'owner_type' => Utils::getModelClassName('entity'), + 'owner_public_id' => $values['public_id'], + 'region' => $payload->getPickupRegion(), + 'location' => Utils::parsePointToWkt($payload->getPickupLocation()), ]); // set tracking number diff --git a/server/src/Models/TrackingNumber.php b/server/src/Models/TrackingNumber.php index e5b78defd..68ae9bd15 100644 --- a/server/src/Models/TrackingNumber.php +++ b/server/src/Models/TrackingNumber.php @@ -4,6 +4,7 @@ use Fleetbase\Casts\PolymorphicType; use Fleetbase\FleetOps\Casts\Point; +use Fleetbase\FleetOps\Support\TrackingCode; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\LaravelMysqlSpatial\Eloquent\SpatialTrait; use Fleetbase\Models\Model; @@ -16,6 +17,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; +use Milon\Barcode\Facades\DNS1DFacade as DNS1D; use Milon\Barcode\Facades\DNS2DFacade as DNS2D; class TrackingNumber extends Model @@ -236,6 +238,74 @@ public static function findTrackingOrFail($id) throw (new \Illuminate\Database\Eloquent\ModelNotFoundException())->setModel(static::class, $id); } + /** + * The record a scanned code points at, within one company: the order, waypoint, + * entity or place that owns the tracking number. + * + * Accepts every format TrackingCode::parse() reads, including the bare owner uuid + * printed on labels before codes carried a url, and a bare tracking number or public + * id from a barcode or typed by hand. Without a company nothing resolves. + */ + public static function findOwnerByCode(?string $code, ?string $companyUuid): ?\Illuminate\Database\Eloquent\Model + { + if (empty($companyUuid)) { + return null; + } + + $parsed = TrackingCode::parse($code); + + if ($parsed['uuid']) { + return static::ownerOf(static::firstForCode('owner_uuid', $parsed['uuid'], $companyUuid)); + } + + $number = $parsed['tracking_number'] ?? $parsed['reference']; + if ($number) { + $owner = static::ownerOf(static::firstForCode('tracking_number', $number, $companyUuid)); + + // A url names its tracking number deliberately, so an unknown one is a miss, + // not a cue to fall back to the owner it also names, which must agree. + if ($owner || $parsed['tracking_number']) { + return $owner && (!$parsed['public_id'] || data_get($owner, 'public_id') === $parsed['public_id']) ? $owner : null; + } + } + + $publicId = $parsed['public_id'] ?? $parsed['reference']; + + return $publicId ? static::ownerOf(static::firstForOwnerPublicId($publicId, $companyUuid)) : null; + } + + protected static function firstForCode(string $column, string $value, string $companyUuid): ?self + { + return static::where('company_uuid', $companyUuid)->where($column, $value)->first(); + } + + protected static function firstForOwnerPublicId(string $publicId, string $companyUuid): ?self + { + // The public id's prefix names the owner's model, so only that table is searched. + $prefix = Str::contains($publicId, '_') ? Str::before($publicId, '_') : null; + $ownerClass = [ + 'order' => Order::class, + 'waypoint' => Waypoint::class, + 'entity' => Entity::class, + 'place' => Place::class, + ][$prefix] ?? null; + + if (!$ownerClass) { + return null; + } + + return static::where('company_uuid', $companyUuid) + // Rows inserted raw (waypoints, entities) store the class with a leading slash. + ->whereIn('owner_type', [$ownerClass, '\\' . $ownerClass]) + ->whereIn('owner_uuid', $ownerClass::withoutGlobalScopes()->select('uuid')->where('public_id', $publicId)) + ->first(); + } + + protected static function ownerOf(?self $trackingNumber): ?\Illuminate\Database\Eloquent\Model + { + return $trackingNumber?->owner; + } + public function updateOwnerStatus(?TrackingStatus $trackingStatus = null) { $trackingStatus = $trackingStatus ?? $this->load(['status'])->getRelationValue('status'); @@ -254,6 +324,10 @@ public function updateOwnerStatus(?TrackingStatus $trackingStatus = null) public static function insertGetUuid($values = [], ?Model $owner = null) { + // Read before the fillable filter below drops it. Callers that insert the owner + // with a raw query, and so have no model to pass, hand its public_id over here. + $ownerPublicId = $owner ? data_get($owner, 'public_id') : ($values['owner_public_id'] ?? null); + $instance = new static(); $fillable = $instance->getFillable(); $insertKeys = array_keys($values); @@ -276,8 +350,7 @@ public static function insertGetUuid($values = [], ?Model $owner = null) } $values['tracking_number'] = static::newTrackingNumber($values['region'] ?? 'SG'); - $values['qr_code'] = static::newBarcode((string) ($values['owner_uuid'] ?? ''), 'QRCODE'); - $values['barcode'] = static::newBarcode((string) ($values['owner_uuid'] ?? ''), 'PDF417'); + $values = array_merge($values, static::codeImages($values['tracking_number'], $ownerPublicId)); if (isset($values['meta']) && (is_object($values['meta']) || is_array($values['meta']))) { $values['meta'] = json_encode($values['meta']); @@ -339,8 +412,29 @@ protected static function newTrackingNumber(string $region): string return static::generateNumber($region); } + /** + * The base64 PNGs printed on a label: a QR code carrying the tracking url (see + * TrackingCode) and a Code 128 barcode carrying the bare tracking number. + * + * @return array{qr_code: string, barcode: string} + */ + public static function codeImages(string $trackingNumber, ?string $ownerPublicId = null): array + { + return [ + // Medium error correction: labels get scuffed, and the url fits comfortably. + 'qr_code' => static::newBarcode(TrackingCode::qrContent($trackingNumber, $ownerPublicId), 'QRCODE,M'), + 'barcode' => static::newBarcode(TrackingCode::barcodeContent($trackingNumber), 'C128'), + ]; + } + protected static function newBarcode(string $value, string $type): string { + if ($type === 'C128') { + // 2px modules, 60px tall: a typical tracking number renders ~330px wide, inside + // the label's 360px cap, so it prints at its own resolution. + return DNS1D::getBarcodePNG($value, $type, 2, 60); + } + return DNS2D::getBarcodePNG($value, $type); } diff --git a/server/src/Models/Waypoint.php b/server/src/Models/Waypoint.php index 35d611eb4..bfb895df3 100644 --- a/server/src/Models/Waypoint.php +++ b/server/src/Models/Waypoint.php @@ -221,10 +221,11 @@ public static function insertGetUuid($values = [], ?Payload $payload = null) if ($result && $payload) { // create tracking number for entity $trackingNumberId = static::createTrackingNumber([ - 'owner_uuid' => $uuid, - 'owner_type' => Utils::getModelClassName('waypoint'), - 'region' => $payload->getPickupRegion(), - 'location' => static::pickupLocationWkt($payload), + 'owner_uuid' => $uuid, + 'owner_type' => Utils::getModelClassName('waypoint'), + 'owner_public_id' => $values['public_id'], + 'region' => $payload->getPickupRegion(), + 'location' => static::pickupLocationWkt($payload), ]); // set tracking number diff --git a/server/src/Observers/TrackingNumberObserver.php b/server/src/Observers/TrackingNumberObserver.php index c90ec3088..2b249ebee 100644 --- a/server/src/Observers/TrackingNumberObserver.php +++ b/server/src/Observers/TrackingNumberObserver.php @@ -6,7 +6,6 @@ use Fleetbase\FleetOps\Models\TrackingStatus; use Fleetbase\LaravelMysqlSpatial\Types\Point; use Illuminate\Support\Str; -use Milon\Barcode\Facades\DNS2DFacade as DNS2D; class TrackingNumberObserver { @@ -17,10 +16,9 @@ class TrackingNumberObserver */ public function creating(TrackingNumber $trackingNumber) { - // generate a barcode annd qr code + // generate the tracking number, then the qr code and barcode printed from it $trackingNumber->tracking_number = $this->generateTrackingNumber($trackingNumber); - $trackingNumber->qr_code = $this->generateBarcode($trackingNumber->owner_uuid, 'QRCODE'); - $trackingNumber->barcode = $this->generateBarcode($trackingNumber->owner_uuid, 'PDF417'); + $trackingNumber->fill($this->generateCodeImages($trackingNumber->tracking_number, $this->ownerPublicId($trackingNumber))); } /** @@ -47,9 +45,23 @@ protected function generateTrackingNumber(TrackingNumber $trackingNumber): strin return TrackingNumber::generateNumber($trackingNumber->region); } - protected function generateBarcode(string $ownerUuid, string $type): string + /** + * @return array{qr_code: string, barcode: string} + */ + protected function generateCodeImages(string $trackingNumber, ?string $ownerPublicId): array { - return DNS2D::getBarcodePNG($ownerUuid, $type); + return TrackingNumber::codeImages($trackingNumber, $ownerPublicId); + } + + protected function ownerPublicId(TrackingNumber $trackingNumber): ?string + { + if (empty($trackingNumber->owner_uuid)) { + return null; + } + + $publicId = data_get($trackingNumber, 'owner.public_id'); + + return is_string($publicId) && $publicId !== '' ? $publicId : null; } protected function createTrackingStatus(array $attributes): TrackingStatus diff --git a/server/src/Support/TrackingCode.php b/server/src/Support/TrackingCode.php new file mode 100644 index 000000000..907a79c9c --- /dev/null +++ b/server/src/Support/TrackingCode.php @@ -0,0 +1,218 @@ +/track-order?order=&r=&v=1 + * + * Only the tracking number (printed on the label in plain text) and the owner's public + * id go in: no uuids, no addresses, no names, no company. The owner's type is its + * public_id prefix (order_, waypoint_, entity_, place_), so it is not repeated. Nothing + * is signed: a label can be photocopied whatever it carries, so authorization belongs + * to the endpoint that acts on a scan, not to the code. + * + * The linear barcode (Code 128) carries the bare tracking number, matching the text + * printed beneath it, because warehouse handhelds type what they scan into a field. + * + * Codes already on parcels encode the owner's bare uuid, and parse() keeps accepting + * them, alongside a bare tracking number or a bare public_id. + */ +class TrackingCode +{ + public const VERSION = 1; + + /** + * Where a phone camera lands. The public tracking page is being rebuilt; when its + * route changes, change these two and nothing else. parse() never depends on the + * host or the path, since printed labels outlive both. + */ + public const PATH = 'track-order'; + public const TRACKING_PARAM = 'order'; + + /** + * The QR content for a tracking number. + */ + public static function qrContent(string $trackingNumber, ?string $ownerPublicId = null): string + { + $query = [static::TRACKING_PARAM => $trackingNumber]; + + if (!empty($ownerPublicId)) { + $query['r'] = $ownerPublicId; + } + + $query['v'] = static::VERSION; + + return static::consoleBaseUrl() . '/' . static::PATH . '?' . http_build_query($query); + } + + /** + * The console origin, from the same config Utils::consoleUrl() reads. It does not + * call consoleUrl() because that infers http/https from the app environment: a code + * printed on a parcel should not change scheme with where it was generated, so it + * is https unless the console is explicitly configured otherwise. + */ + protected static function consoleBaseUrl(): string + { + $host = rtrim((string) config('fleetbase.console.host', ''), '/'); + + if (Str::startsWith($host, 'http')) { + return $host; + } + + $subdomain = config('fleetbase.console.subdomain'); + $scheme = config('fleetbase.console.secure', true) ? 'https://' : 'http://'; + + return $scheme . ($subdomain ? $subdomain . '.' : '') . $host; + } + + /** + * The linear barcode content for a tracking number. + */ + public static function barcodeContent(string $trackingNumber): string + { + return $trackingNumber; + } + + /** + * Break a scanned value into the identifiers it carries. + * + * - `uuid`: a legacy code, the owner's uuid. + * - `tracking_number` and `public_id`: from a URL code. + * - `reference`: a bare value that is either a tracking number (the barcode, or one + * typed by hand) or a public_id. Consumers try both. + * + * @return array{uuid: ?string, tracking_number: ?string, public_id: ?string, reference: ?string, version: ?int} + */ + public static function parse(?string $code): array + { + $parsed = [ + 'uuid' => null, + 'tracking_number' => null, + 'public_id' => null, + 'reference' => null, + 'version' => null, + ]; + + $code = trim((string) $code); + if ($code === '') { + return $parsed; + } + + if (preg_match('#^https?://#i', $code)) { + return array_merge($parsed, static::parseUrl($code)); + } + + if (Str::isUuid($code)) { + $parsed['uuid'] = $code; + + return $parsed; + } + + $parsed['reference'] = $code; + + return $parsed; + } + + /** + * Whether a scanned value identifies the given subject (an order, waypoint, entity or + * place). + */ + public static function matches(?string $code, $subject): bool + { + $subjectUuid = data_get($subject, 'uuid'); + if (!$subject || empty($subjectUuid)) { + return false; + } + + // Labels printed before v1 encode the subject's bare uuid; compare it exactly, as + // scans always have been. + if (trim((string) $code) === $subjectUuid) { + return true; + } + + $parsed = static::parse($code); + + if ($parsed['uuid']) { + return false; + } + + $publicId = data_get($subject, 'public_id'); + $trackingNumber = static::subjectTrackingNumber($subject); + + if ($parsed['reference']) { + return static::sameTrackingNumber($parsed['reference'], $trackingNumber) || static::samePublicId($parsed['reference'], $publicId); + } + + // A url code names its owner by public id, and that decides: an owner can hold more + // than one tracking number (the API issues extra ones), so the number is compared + // only when the code names no owner. + if ($parsed['public_id']) { + return static::samePublicId($parsed['public_id'], $publicId); + } + + return $parsed['tracking_number'] !== null && static::sameTrackingNumber($parsed['tracking_number'], $trackingNumber); + } + + /** + * @return array{tracking_number: ?string, public_id: ?string, version: ?int} + */ + protected static function parseUrl(string $url): array + { + $parts = parse_url($url); + $query = []; + parse_str(is_array($parts) ? ($parts['query'] ?? '') : '', $query); + + $trackingNumber = static::stringParam($query, static::TRACKING_PARAM) ?? static::stringParam($query, 'tn'); + + // A future route may carry the tracking number in the path (/track/). Only + // a path of two or more segments can, so /track-order itself is never mistaken + // for one. + if ($trackingNumber === null && is_array($parts) && isset($parts['path'])) { + $segments = array_values(array_filter(explode('/', $parts['path']), fn ($segment) => $segment !== '')); + if (count($segments) >= 2) { + $trackingNumber = rawurldecode(end($segments)); + } + } + + $version = static::stringParam($query, 'v'); + + return [ + 'tracking_number' => $trackingNumber, + 'public_id' => static::stringParam($query, 'r'), + 'version' => $version !== null && ctype_digit($version) ? (int) $version : null, + ]; + } + + protected static function stringParam(array $query, string $key): ?string + { + $value = $query[$key] ?? null; + + return is_string($value) && trim($value) !== '' ? trim($value) : null; + } + + protected static function subjectTrackingNumber($subject): ?string + { + $trackingNumber = data_get($subject, 'trackingNumber.tracking_number') ?? data_get($subject, 'tracking_number'); + + return is_string($trackingNumber) && $trackingNumber !== '' ? $trackingNumber : null; + } + + protected static function sameTrackingNumber(string $scanned, ?string $trackingNumber): bool + { + // The label prints the number upper-cased and handhelds may not preserve case. + return $trackingNumber !== null && strcasecmp($scanned, $trackingNumber) === 0; + } + + protected static function samePublicId(string $scanned, $publicId): bool + { + return is_string($publicId) && $publicId !== '' && $scanned === $publicId; + } +} From ccec8c9593e364ad64910bc2ad3ba48f1208bbb0 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 14:40:05 +0800 Subject: [PATCH 2/3] test(tracking): cover tracking-url codes, Code 128 barcodes and the scan resolver - TrackingCode: generation, parsing of every format in circulation (legacy uuid, tracking url from any host, bare tracking number or public id, hostile urls), and subject matching for capture-qr - TrackingNumber::findOwnerByCode against SQLite: company scoping, disagreeing url identifiers, unknown numbers and unmapped prefixes - insert path, observer and resource expectations moved off the owner uuid --- .../tests/ApiOrderControllerContractsTest.php | 40 ++++++ ...iTrackingNumberControllerContractsTest.php | 15 +- .../CompactResourceSerializationTest.php | 80 +++-------- .../TrackingNumberControllerHelpersTest.php | 79 ++++++++--- server/tests/ObserverContractsTest.php | 72 +++++++++- .../tests/Unit/Models/TrackingNumberTest.php | 73 +++++++++- server/tests/Unit/Models/WaypointTest.php | 7 +- .../tests/Unit/Support/TrackingCodeTest.php | 132 ++++++++++++++++++ 8 files changed, 392 insertions(+), 106 deletions(-) create mode 100644 server/tests/Unit/Support/TrackingCodeTest.php diff --git a/server/tests/ApiOrderControllerContractsTest.php b/server/tests/ApiOrderControllerContractsTest.php index cd5c8a4ff..f25dd6d80 100644 --- a/server/tests/ApiOrderControllerContractsTest.php +++ b/server/tests/ApiOrderControllerContractsTest.php @@ -1028,3 +1028,43 @@ public function isString(string $key): bool 'status' => 400, ]); }); + +test('api order controller captures QR proof from tracking url codes and barcodes', function () { + session(['company' => 'company-uuid']); + + $subject = (object) [ + 'uuid' => 'subject-uuid', + 'public_id' => 'waypoint_subject', + 'trackingNumber' => (object) ['tracking_number' => 'ACM1234567890SG'], + ]; + + // The url printed by current labels, from any host, and the Code 128 barcode. + foreach ([ + 'https://console.fleetbase.test/track-order?order=ACM1234567890SG&r=waypoint_subject&v=1', + 'https://old-host.example/track-order?order=ACM1234567890SG&r=waypoint_subject&v=1', + 'ACM1234567890SG', + ] as $code) { + $controller = new FleetOpsApiOrderCrudControllerProbe(); + $controller->order = new FleetOpsApiOrderCrudFake(); + $controller->resolvedSubject = $subject; + + expect($controller->captureQrScan(new Request(['code' => $code]), 'order-public', 'waypoint_subject')['resource'])->toBe('proof') + ->and($controller->createdProofs[0]['subject_uuid'])->toBe('subject-uuid'); + } + + // Another parcel's label is refused, whether it names another owner or, naming none, + // another tracking number. + foreach ([ + 'https://console.fleetbase.test/track-order?order=ACM1234567890SG&r=waypoint_other&v=1', + 'https://console.fleetbase.test/track-order?order=ACM0000000000SG&v=1', + ] as $code) { + $controller = new FleetOpsApiOrderCrudControllerProbe(); + $controller->order = new FleetOpsApiOrderCrudFake(); + $controller->resolvedSubject = $subject; + + expect($controller->captureQrScan(new Request(['code' => $code]), 'order-public', 'waypoint_subject'))->toBe([ + 'apiError' => 'Unable to validate QR code data.', + 'status' => 400, + ])->and($controller->createdProofs)->toBe([]); + } +}); diff --git a/server/tests/ApiTrackingNumberControllerContractsTest.php b/server/tests/ApiTrackingNumberControllerContractsTest.php index 17a21e1a3..83b3e66bc 100644 --- a/server/tests/ApiTrackingNumberControllerContractsTest.php +++ b/server/tests/ApiTrackingNumberControllerContractsTest.php @@ -68,9 +68,9 @@ protected function deletedTrackingNumberResource(TrackingNumber $trackingNumber) return ['resource' => 'deleted-tracking-number', 'trackingNumber' => $trackingNumber]; } - protected function findQrModel(array $tables, array $where) + protected function findQrModel(string $code) { - $this->qrLookups[] = [$tables, $where]; + $this->qrLookups[] = $code; return $this->qrModel; } @@ -206,15 +206,12 @@ function fleetopsDecodeTrackingNumberQrRequest(array $input): DecodeTrackingNumb $controller = new FleetOpsApiTrackingNumberControllerProbe(); $controller->qrModel = $model; - $response = $controller->fromQR(fleetopsDecodeTrackingNumberQrRequest(['code' => 'order-uuid'])); + $code = 'https://console.fleetbase.test/track-order?order=ACM1234567890SG&r=order_public&v=1'; + $response = $controller->fromQR(fleetopsDecodeTrackingNumberQrRequest(['code' => $code])); + // The raw scanned value goes to the resolver, which understands every format. expect($response)->toBe(['resource' => 'qr-model', 'model' => $model]) - ->and($controller->qrLookups)->toBe([ - [ - ['entities', 'orders'], - ['uuid' => 'order-uuid'], - ], - ]); + ->and($controller->qrLookups)->toBe([$code]); $controller = new FleetOpsApiTrackingNumberControllerProbe(); diff --git a/server/tests/CompactResourceSerializationTest.php b/server/tests/CompactResourceSerializationTest.php index 2f628407d..fb1f87925 100644 --- a/server/tests/CompactResourceSerializationTest.php +++ b/server/tests/CompactResourceSerializationTest.php @@ -2739,8 +2739,8 @@ function fleetopsCompactResourceFixture(array $attributes = [], array $loaded = 'scheduled_at' => '2026-07-30 09:00:00', ]); - // The decoded QR content is a debug-only field and must never ride along on a webhook, - // which is a separate literal payload rather than a filtered toArray(). + // The webhook is a separate literal payload rather than a filtered toArray(), and its + // shape is unchanged: the QR content is not added to it. expect(array_key_exists('qr_code_content', (new TrackingNumberResource($trackingNumber))->toWebhookPayload()))->toBeFalse(); expect((new TrackingNumberResource($trackingNumber))->toWebhookPayload())->toMatchArray([ @@ -2790,14 +2790,15 @@ function fleetopsCompactResourceFixture(array $attributes = [], array $loaded = ]); }); -test('tracking number resource publishes the qr code content only in debug mode', function () { - // qr_code is a base64 PNG generated from owner_uuid, and the endpoints that consume a - // scanned code match on that uuid. Debug builds publish the value beside the image so - // an automated client can follow the flow without decoding a PNG; production must not. +test('tracking number resource publishes a scannable qr code content beside the image', function () { + // qr_code is a base64 PNG; qr_code_content is the text current labels encode in it, + // a tracking url naming the tracking number and the subject's public id. Both are in + // the resource already, so it is published in every environment. $trackingNumber = fleetopsCompactResourceFixture([ - 'tracking_number' => 'TN-DEBUG', - 'owner_uuid' => 'owner-uuid-under-the-qr', + 'tracking_number' => 'TN-SCAN', + 'owner_uuid' => 'owner-uuid-never-published', 'owner_type' => 'Fleetbase\\FleetOps\\Models\\Order', + 'owner' => (object) ['public_id' => 'order_public'], 'region' => 'sg', 'qr_code' => 'qr-data', 'barcode' => 'barcode-data', @@ -2811,70 +2812,31 @@ function fleetopsCompactResourceFixture(array $attributes = [], array $loaded = // cover the index resource and the webhook payload. $previousContainer = Illuminate\Container\Container::getInstance(); $container = new class extends Illuminate\Container\Container { - public bool $debugMode = false; - public function environment(...$environments) { return in_array('testing', $environments, true) || $environments === [] ? 'testing' : false; } - - // Stands in for the framework's Application::hasDebugModeEnabled(). - public function hasDebugModeEnabled() - { - return $this->debugMode; - } }; $container->instance('config', new Illuminate\Config\Repository([ 'fleetbase' => ['console' => ['host' => 'console.fleetbase.test', 'subdomain' => null, 'secure' => true]], ])); Illuminate\Container\Container::setInstance($container); - // Drive the REAL accessor through the container rather than overriding it, so the - // debug check itself is exercised and not just the resource's use of it. - $container->debugMode = true; - $withDebug = new TrackingNumberResource($trackingNumber); - try { - $debugPayload = $withDebug->resolve($request); - - $container->debugMode = false; - $productionPayload = (new TrackingNumberResource($trackingNumber))->resolve($request); + $payload = (new TrackingNumberResource($trackingNumber))->resolve($request); + $withoutNumber = (new TrackingNumberResource(fleetopsCompactResourceFixture([ + 'tracking_number' => null, + 'owner_type' => 'Fleetbase\\FleetOps\\Models\\Order', + 'owner' => null, + ])))->resolve($request); } finally { Illuminate\Container\Container::setInstance($previousContainer); } - // Present and exactly the value the QR was generated from — not a public id. - expect($debugPayload['qr_code_content'])->toBe('owner-uuid-under-the-qr') - ->and($debugPayload['qr_code'])->toBe('qr-data') - // Absent, not null: `when()` drops the key entirely, so a production response is - // byte-identical to one from before the field existed. - ->and(array_key_exists('qr_code_content', $productionPayload))->toBeFalse() - ->and($productionPayload['qr_code'])->toBe('qr-data'); -}); - -test('tracking number resource fails closed when the debug state cannot be determined', function () { - // Any failure to resolve the debug state must answer false rather than defaulting to - // exposure. The real accessor is exercised here with no usable application bound. - $expose = new ReflectionMethod(TrackingNumberResource::class, 'exposesQrCodeContent'); - $expose->setAccessible(true); - - $previous = Illuminate\Container\Container::getInstance(); - - try { - // A container that is not an Application has no hasDebugModeEnabled(). - Illuminate\Container\Container::setInstance(new Illuminate\Container\Container()); - expect($expose->invoke(null))->toBeFalse(); - - // And an accessor that throws must also answer false rather than propagating — - // a resource must not be able to fail serialization over a debug check. - Illuminate\Container\Container::setInstance(new class extends Illuminate\Container\Container { - public function hasDebugModeEnabled() - { - throw new RuntimeException('debug state unavailable'); - } - }); - expect($expose->invoke(null))->toBeFalse(); - } finally { - Illuminate\Container\Container::setInstance($previous); - } + expect($payload['qr_code_content'])->toBe('https://console.fleetbase.test/track-order?order=TN-SCAN&r=order_public&v=1') + ->and($payload['qr_code_content'])->not->toContain('owner-uuid') + ->and($payload['url'])->toBe('https://console.fleetbase.test/track-order?order=TN-SCAN') + ->and($payload['qr_code'])->toBe('qr-data') + // Nothing to scan without a tracking number: the key is absent, not null. + ->and(array_key_exists('qr_code_content', $withoutNumber))->toBeFalse(); }); diff --git a/server/tests/Feature/Http/Api/TrackingNumberControllerHelpersTest.php b/server/tests/Feature/Http/Api/TrackingNumberControllerHelpersTest.php index 80baaba8a..7b94621e9 100644 --- a/server/tests/Feature/Http/Api/TrackingNumberControllerHelpersTest.php +++ b/server/tests/Feature/Http/Api/TrackingNumberControllerHelpersTest.php @@ -133,28 +133,63 @@ public function __call($method, $arguments) ->and($helper('deletedTrackingNumberResource', $found))->toBeInstanceOf(Fleetbase\FleetOps\Http\Resources\v1\DeletedResource::class) ->and($helper('jsonResponse', ['ok' => true], 200))->toBeInstanceOf(Illuminate\Http\JsonResponse::class); - // Qr model resolution finds models and wraps them in fleet-ops v1 resources - $qrModel = $helper('findQrModel', ['entities'], ['public_id' => 'entity_tnhelper1']); - expect($qrModel)->not->toBeNull() - ->and($helper('qrModelResource', $qrModel))->toBeInstanceOf(Fleetbase\FleetOps\Http\Resources\v1\Entity::class); - - // A table with no eloquent mapping resolves to null rather than a raw row. - // - // The raw-row fallback was removed: it passed the ARRAY of table names to - // DB::table(), which stringified it to the literal table "Array" and threw - // SQLSTATE[42S02], so every unresolvable QR code answered 500 instead of the 400 the - // caller already handles. It could not have helped either way — qrModelResource() - // serializes through a typed resource keyed on the model class, and a raw stdClass - // row has none. - $connection->table('tracking_statuses')->insert([ - 'uuid' => '44444444-4444-4444-8444-444444444403', - 'public_id' => 'tracking_status_tnhelper1', - 'code' => 'DISPATCHED', - 'status' => 'Dispatched', + // Qr model resolution goes through the shared resolver and wraps the owner in its + // fleet-ops v1 resource. + $connection->table('tracking_numbers')->insert([ + 'uuid' => '44444444-4444-4444-8444-444444444404', + 'public_id' => 'track_tnhelper2', + 'company_uuid' => 'company-1', + 'tracking_number' => 'FLB-HELPER-2', + 'owner_uuid' => '44444444-4444-4444-8444-444444444402', + 'owner_type' => Fleetbase\FleetOps\Models\Entity::class, + ]); + + $qrModel = $helper('findQrModel', 'https://console.fleetbase.test/track-order?order=FLB-HELPER-2&r=entity_tnhelper1&v=1'); + expect($qrModel)->toBeInstanceOf(Fleetbase\FleetOps\Models\Entity::class) + ->and($helper('qrModelResource', $qrModel))->toBeInstanceOf(Fleetbase\FleetOps\Http\Resources\v1\Entity::class) + ->and($helper('findQrModel', '{{qr_code}}'))->toBeNull(); +}); + +test('tracking number owners resolve from every code format within the company', function () { + $connection = fleetopsTrackingNumberHelpersBoot(); + $connection->table('orders')->insert(['uuid' => '55555555-5555-4555-8555-555555555501', 'public_id' => 'order_codes1', 'company_uuid' => 'company-1', 'status' => 'created']); + $connection->table('entities')->insert(['uuid' => '55555555-5555-4555-8555-555555555502', 'public_id' => 'entity_codes1', 'company_uuid' => 'company-1', 'name' => 'Parcel']); + $connection->table('orders')->insert(['uuid' => '55555555-5555-4555-8555-555555555503', 'public_id' => 'order_codes2', 'company_uuid' => 'company-2', 'status' => 'created']); + $connection->table('tracking_numbers')->insert([ + ['uuid' => 'tn-codes-1', 'public_id' => 'track_codes1', 'company_uuid' => 'company-1', 'tracking_number' => 'ACM1111111111SG', 'owner_uuid' => '55555555-5555-4555-8555-555555555501', 'owner_type' => Fleetbase\FleetOps\Models\Order::class], + // Entity::insertGetUuid() inserts raw, storing the owner class with a leading slash. + ['uuid' => 'tn-codes-2', 'public_id' => 'track_codes2', 'company_uuid' => 'company-1', 'tracking_number' => 'ACM2222222222SG', 'owner_uuid' => '55555555-5555-4555-8555-555555555502', 'owner_type' => '\\' . Fleetbase\FleetOps\Models\Entity::class], + ['uuid' => 'tn-codes-3', 'public_id' => 'track_codes3', 'company_uuid' => 'company-2', 'tracking_number' => 'OTH3333333333SG', 'owner_uuid' => '55555555-5555-4555-8555-555555555503', 'owner_type' => Fleetbase\FleetOps\Models\Order::class], ]); - expect($helper('findQrModel', ['tracking_statuses'], ['public_id' => 'tracking_status_tnhelper1']))->toBeNull() - // And a mapped table that simply matches nothing is also null, which is the path - // an unknown or unresolved code takes. - ->and($helper('findQrModel', ['entities', 'orders'], ['uuid' => '{{qr_code}}']))->toBeNull(); + $owner = fn (?string $code, ?string $company = 'company-1') => TrackingNumber::findOwnerByCode($code, $company)?->public_id; + + // Labels in circulation carry the owner's bare uuid. + expect($owner('55555555-5555-4555-8555-555555555501'))->toBe('order_codes1') + // Current labels carry a tracking url; the host is irrelevant. + ->and($owner('https://console.fleetbase.test/track-order?order=ACM1111111111SG&r=order_codes1&v=1'))->toBe('order_codes1') + ->and($owner('https://old-host.example/track-order?order=ACM2222222222SG&v=1'))->toBe('entity_codes1') + // The Code 128 barcode, or a value typed by hand: a tracking number or public id. + ->and($owner('ACM2222222222SG'))->toBe('entity_codes1') + ->and($owner('entity_codes1'))->toBe('entity_codes1') + ->and($owner('https://console.fleetbase.test/track-order?r=order_codes1'))->toBe('order_codes1'); + + // A url whose two identifiers disagree, or that names an unknown tracking number, is + // refused rather than resolved through the half that matches. + expect($owner('https://console.fleetbase.test/track-order?order=ACM1111111111SG&r=entity_codes1&v=1'))->toBeNull() + ->and($owner('https://console.fleetbase.test/track-order?order=ACM9999999999SG&r=order_codes1&v=1'))->toBeNull() + // Unknown values, unmapped prefixes, and values that are no public id at all. + ->and($owner('55555555-5555-4555-8555-555555555599'))->toBeNull() + ->and($owner('ACM9999999999SG'))->toBeNull() + ->and($owner('vehicle_codes1'))->toBeNull() + ->and($owner('order_missing'))->toBeNull() + ->and($owner(''))->toBeNull(); + + // Another company's parcel never resolves, in any format, and without a company + // nothing does. + expect($owner('55555555-5555-4555-8555-555555555503'))->toBeNull() + ->and($owner('OTH3333333333SG'))->toBeNull() + ->and($owner('order_codes2'))->toBeNull() + ->and($owner('order_codes2', 'company-2'))->toBe('order_codes2') + ->and($owner('55555555-5555-4555-8555-555555555501', null))->toBeNull(); }); diff --git a/server/tests/ObserverContractsTest.php b/server/tests/ObserverContractsTest.php index 0a15bf65c..b3f7e8f5b 100644 --- a/server/tests/ObserverContractsTest.php +++ b/server/tests/ObserverContractsTest.php @@ -397,11 +397,16 @@ protected function generateTrackingNumber(TrackingNumber $trackingNumber): strin return 'TN-' . $trackingNumber->region; } - protected function generateBarcode(string $ownerUuid, string $type): string + protected function generateCodeImages(string $trackingNumber, ?string $ownerPublicId): array { - $this->barcodes[] = [$ownerUuid, $type]; + $this->barcodes[] = [$trackingNumber, $ownerPublicId]; - return $type . '-png'; + return ['qr_code' => 'qr-png', 'barcode' => 'barcode-png']; + } + + protected function ownerPublicId(TrackingNumber $trackingNumber): ?string + { + return $trackingNumber->owner_uuid ? 'order_public' : null; } protected function createTrackingStatus(array $attributes): TrackingStatus @@ -1292,11 +1297,12 @@ protected function resolveOrder(PurchaseRate $purchaseRate): ?Order $observer->created($trackingNumber); expect($trackingNumber->tracking_number)->toBe('TN-sg') - ->and($trackingNumber->qr_code)->toBe('QRCODE-png') - ->and($trackingNumber->barcode)->toBe('PDF417-png') + ->and($trackingNumber->qr_code)->toBe('qr-png') + ->and($trackingNumber->barcode)->toBe('barcode-png') + // Codes are generated from the new tracking number and the owner's public id, + // never from the owner's uuid. ->and($observer->barcodes)->toBe([ - ['owner-uuid', 'QRCODE'], - ['owner-uuid', 'PDF417'], + ['TN-sg', 'order_public'], ]) ->and($observer->statuses[0])->toMatchArray([ 'company_uuid' => 'company-uuid', @@ -1309,6 +1315,58 @@ protected function resolveOrder(PurchaseRate $purchaseRate): ?Order ->and($trackingNumber->ownerStatuses)->toHaveCount(1); }); +test('tracking number observer reads the owner public id and renders codes through the model', function () { + $observer = new TrackingNumberObserver(); + $owner = new ReflectionMethod($observer, 'ownerPublicId'); + $images = new ReflectionMethod($observer, 'generateCodeImages'); + $owner->setAccessible(true); + $images->setAccessible(true); + + // No owner: nothing to look up. + expect($owner->invoke($observer, new TrackingNumber()))->toBeNull(); + + $order = new Order(); + $order->setRawAttributes(['uuid' => 'owner-uuid', 'public_id' => 'order_public'], true); + $trackingNumber = new TrackingNumber(); + $trackingNumber->setRawAttributes(['owner_uuid' => 'owner-uuid', 'owner_type' => Order::class], true); + $trackingNumber->setRelation('owner', $order); + + expect($owner->invoke($observer, $trackingNumber))->toBe('order_public'); + + // An owner without a public id yields none rather than an empty `r` parameter. + $order->setRawAttributes(['uuid' => 'owner-uuid', 'public_id' => ''], true); + expect($owner->invoke($observer, $trackingNumber))->toBeNull(); + + $previousConsole = config('fleetbase.console'); + config(['fleetbase.console' => ['host' => 'console.fleetbase.test', 'secure' => true]]); + foreach (['DNS2D', 'DNS1D'] as $facade) { + app()->instance($facade, new class($facade) { + public function __construct(private string $facade) + { + } + + public function getBarcodePNG($code, $type, ...$size) + { + return $this->facade . ':' . $type . ':' . $code; + } + }); + Illuminate\Support\Facades\Facade::clearResolvedInstance($facade); + } + + try { + expect($images->invoke($observer, 'TN-sg', 'order_public'))->toBe([ + 'qr_code' => 'DNS2D:QRCODE,M:https://console.fleetbase.test/track-order?order=TN-sg&r=order_public&v=1', + 'barcode' => 'DNS1D:C128:TN-sg', + ]); + } finally { + config(['fleetbase.console' => $previousConsole]); + foreach (['DNS2D', 'DNS1D'] as $facade) { + app()->forgetInstance($facade); + Illuminate\Support\Facades\Facade::clearResolvedInstance($facade); + } + } +}); + test('zone observer invalidates service area cache for lifecycle events and original service area', function () { $zone = new Zone(); $zone->setRawAttributes([ diff --git a/server/tests/Unit/Models/TrackingNumberTest.php b/server/tests/Unit/Models/TrackingNumberTest.php index 6bef10f36..3587e69ef 100644 --- a/server/tests/Unit/Models/TrackingNumberTest.php +++ b/server/tests/Unit/Models/TrackingNumberTest.php @@ -84,13 +84,21 @@ protected static function ownerHasStatusColumn(Fleetbase\Models\Model $owner): b beforeEach(function () { FleetOpsTrackingNumberInsertFake::resetInsertFake(); + + $this->previousConsoleConfig = config('fleetbase.console'); + config(['fleetbase.console' => ['host' => 'console.fleetbase.test', 'subdomain' => null, 'secure' => true]]); +}); + +afterEach(function () { + config(['fleetbase.console' => $this->previousConsoleConfig]); }); test('tracking number insert filters values and creates initial owner status', function () { $owner = new Order(); $owner->setRawAttributes([ - 'uuid' => 'order-uuid', - 'status' => 'pending', + 'uuid' => 'order-uuid', + 'public_id' => 'order_public', + 'status' => 'pending', ], true); $uuid = FleetOpsTrackingNumberInsertFake::insertGetUuid([ @@ -114,8 +122,10 @@ protected static function ownerHasStatusColumn(Fleetbase\Models\Model $owner): b 'owner_uuid' => 'order-uuid', 'owner_type' => Utils::getMutationType($owner), 'tracking_number' => 'TRACK-AE', - 'qr_code' => 'QRCODE:order-uuid', - 'barcode' => 'PDF417:order-uuid', + // The QR carries the tracking url naming the owner by public id, never its uuid; + // the barcode carries the bare tracking number printed beneath it. + 'qr_code' => 'QRCODE,M:https://console.fleetbase.test/track-order?order=TRACK-AE&r=order_public&v=1', + 'barcode' => 'C128:TRACK-AE', 'meta' => '{"source":"api"}', ])->and($inserted)->not->toHaveKey('not_allowed'); @@ -147,8 +157,8 @@ protected static function ownerHasStatusColumn(Fleetbase\Models\Model $owner): b ->and(FleetOpsTrackingNumberInsertFake::$insertedValues[0])->toMatchArray([ 'uuid' => 'tracking-uuid', 'tracking_number' => 'TRACK-SG', - 'qr_code' => 'QRCODE:', - 'barcode' => 'PDF417:', + 'qr_code' => 'QRCODE,M:https://console.fleetbase.test/track-order?order=TRACK-SG&v=1', + 'barcode' => 'C128:TRACK-SG', ]) ->and(FleetOpsTrackingNumberInsertFake::$createdStatuses)->toBe([]) ->and(FleetOpsTrackingNumberInsertFake::$statusUpdates)->toBe([]) @@ -197,3 +207,54 @@ public function __call($method, $arguments) ->and(TrackingNumber::findTrackingOrFail('track_findone1')->uuid)->toBe('tn-find-1') ->and(fn () => TrackingNumber::findTrackingOrFail('missing-number'))->toThrow(Illuminate\Database\Eloquent\ModelNotFoundException::class); }); + +test('tracking number insert takes the owner public id from callers without an owner model', function () { + // Waypoints and entities insert their owner with a raw query and pass only ids. + FleetOpsTrackingNumberInsertFake::insertGetUuid([ + 'owner_uuid' => 'waypoint-uuid', + 'owner_type' => 'Fleetbase\\FleetOps\\Models\\Waypoint', + 'owner_public_id' => 'waypoint_public', + 'region' => 'SG', + ]); + + $inserted = FleetOpsTrackingNumberInsertFake::$insertedValues[0]; + + expect($inserted['qr_code'])->toBe('QRCODE,M:https://console.fleetbase.test/track-order?order=TRACK-SG&r=waypoint_public&v=1') + // A helper key, not a column: the fillable filter keeps it out of the insert. + ->and($inserted)->not->toHaveKey('owner_public_id') + ->and($inserted['owner_uuid'])->toBe('waypoint-uuid'); +}); + +test('tracking number code images render a medium-correction QR and a Code 128 barcode', function () { + $calls = new ArrayObject(); + foreach (['DNS2D', 'DNS1D'] as $facade) { + app()->instance($facade, new class($facade, $calls) { + public function __construct(private string $facade, private ArrayObject $calls) + { + } + + public function getBarcodePNG(...$arguments) + { + $this->calls[] = array_merge([$this->facade], $arguments); + + return $this->facade . '-png'; + } + }); + Illuminate\Support\Facades\Facade::clearResolvedInstance($facade); + } + + try { + expect(TrackingNumber::codeImages('ACM1234567890SG', 'entity_public'))->toBe([ + 'qr_code' => 'DNS2D-png', + 'barcode' => 'DNS1D-png', + ])->and($calls->getArrayCopy())->toBe([ + ['DNS2D', 'https://console.fleetbase.test/track-order?order=ACM1234567890SG&r=entity_public&v=1', 'QRCODE,M'], + ['DNS1D', 'ACM1234567890SG', 'C128', 2, 60], + ]); + } finally { + foreach (['DNS2D', 'DNS1D'] as $facade) { + app()->forgetInstance($facade); + Illuminate\Support\Facades\Facade::clearResolvedInstance($facade); + } + } +}); diff --git a/server/tests/Unit/Models/WaypointTest.php b/server/tests/Unit/Models/WaypointTest.php index 0ee6722db..ec97ac515 100644 --- a/server/tests/Unit/Models/WaypointTest.php +++ b/server/tests/Unit/Models/WaypointTest.php @@ -218,9 +218,10 @@ public static function with($relations) ])->and($inserted)->not->toHaveKey('not_allowed') ->and(FleetOpsWaypointInsertFake::$createdTracking)->toHaveCount(1) ->and(FleetOpsWaypointInsertFake::$createdTracking[0])->toMatchArray([ - 'owner_uuid' => 'waypoint-uuid', - 'owner_type' => Utils::getModelClassName('waypoint'), - 'region' => 'AE', + 'owner_uuid' => 'waypoint-uuid', + 'owner_type' => Utils::getModelClassName('waypoint'), + 'owner_public_id' => FleetOpsWaypointInsertFake::$insertedValues[0]['public_id'], + 'region' => 'AE', ]) ->and(FleetOpsWaypointInsertFake::$createdTracking[0]['location'])->toBe('POINT(55.2708 25.2048)') ->and(FleetOpsWaypointInsertFake::$trackingNumberWrites)->toBe([ diff --git a/server/tests/Unit/Support/TrackingCodeTest.php b/server/tests/Unit/Support/TrackingCodeTest.php new file mode 100644 index 000000000..a62f65586 --- /dev/null +++ b/server/tests/Unit/Support/TrackingCodeTest.php @@ -0,0 +1,132 @@ +previousConsoleConfig = config('fleetbase.console'); + config(['fleetbase.console' => ['host' => 'console.fleetbase.test', 'subdomain' => null, 'secure' => true]]); +}); + +afterEach(function () { + config(['fleetbase.console' => $this->previousConsoleConfig]); +}); + +test('qr content is a versioned tracking url naming the owner by public id', function () { + expect(TrackingCode::qrContent('ACM1234567890SG', 'order_a1b2c3d')) + ->toBe('https://console.fleetbase.test/track-order?order=ACM1234567890SG&r=order_a1b2c3d&v=1') + // No owner: the tracking number alone still resolves. + ->and(TrackingCode::qrContent('ACM1234567890SG')) + ->toBe('https://console.fleetbase.test/track-order?order=ACM1234567890SG&v=1') + // Tracking numbers start with the company name, which may hold spaces. + ->and(TrackingCode::qrContent('A B1234567890SG', '')) + ->toBe('https://console.fleetbase.test/track-order?order=A+B1234567890SG&v=1'); +}); + +test('qr content follows the configured console origin but never the app environment', function () { + config(['fleetbase.console' => ['host' => 'fleetbase.test', 'subdomain' => 'console', 'secure' => false]]); + expect(TrackingCode::qrContent('TN1', 'order_x'))->toBe('http://console.fleetbase.test/track-order?order=TN1&r=order_x&v=1'); + + config(['fleetbase.console' => ['host' => 'https://tracking.example.test/', 'subdomain' => 'ignored']]); + expect(TrackingCode::qrContent('TN1'))->toBe('https://tracking.example.test/track-order?order=TN1&v=1'); + + // Unset `secure` defaults to https, whatever environment generated the label. + config(['fleetbase.console' => ['host' => 'console.fleetbase.test']]); + expect(TrackingCode::qrContent('TN1'))->toStartWith('https://console.fleetbase.test/'); +}); + +test('barcode content is the bare tracking number printed beneath it', function () { + expect(TrackingCode::barcodeContent('ACM1234567890SG'))->toBe('ACM1234567890SG'); +}); + +test('parse reads every code format in circulation', function () { + $empty = ['uuid' => null, 'tracking_number' => null, 'public_id' => null, 'reference' => null, 'version' => null]; + + // Generated codes round-trip. + expect(TrackingCode::parse(TrackingCode::qrContent('ACM1234567890SG', 'waypoint_a1b2c3d')))->toBe(array_merge($empty, [ + 'tracking_number' => 'ACM1234567890SG', + 'public_id' => 'waypoint_a1b2c3d', + 'version' => 1, + ])) + // Legacy labels encode the owner's bare uuid. + ->and(TrackingCode::parse(' 6f1c2a4e-8b3d-4c5f-9a7e-1d2c3b4a5f60 '))->toBe(array_merge($empty, [ + 'uuid' => '6f1c2a4e-8b3d-4c5f-9a7e-1d2c3b4a5f60', + ])) + // A barcode, or a value typed by hand, may be a tracking number or a public id. + ->and(TrackingCode::parse('ACM1234567890SG'))->toBe(array_merge($empty, ['reference' => 'ACM1234567890SG'])) + ->and(TrackingCode::parse(''))->toBe($empty) + ->and(TrackingCode::parse(null))->toBe($empty); +}); + +test('parse ignores host and path and tolerates hostile or partial urls', function () { + // Any host, a `tn` alias, and a tracking number carried in a deeper path. + expect(TrackingCode::parse('HTTPS://old-domain.example/track-order?tn=TN9&r=entity_e1&v=2'))->toMatchArray([ + 'tracking_number' => 'TN9', + 'public_id' => 'entity_e1', + 'version' => 2, + ]) + ->and(TrackingCode::parse('https://console.example/~/track/TN%2042?r=order_o1'))->toMatchArray([ + 'tracking_number' => 'TN 42', + 'public_id' => 'order_o1', + 'version' => null, + ]) + // The tracking page's own path is not mistaken for a tracking number. + ->and(TrackingCode::parse('https://console.example/track-order?r=order_o1'))->toMatchArray([ + 'tracking_number' => null, + 'public_id' => 'order_o1', + ]) + // Array or blank parameters and a non-numeric version are dropped, not trusted. + ->and(TrackingCode::parse('https://console.example/track-order?order[]=a&r=%20&v=1x'))->toMatchArray([ + 'tracking_number' => null, + 'public_id' => null, + 'version' => null, + ]) + // parse_url() rejects this outright. + ->and(TrackingCode::parse('http:///track-order'))->toMatchArray([ + 'tracking_number' => null, + 'public_id' => null, + 'version' => null, + ]); +}); + +test('matches accepts legacy and new codes for the scanned subject only', function () { + $order = (object) [ + 'uuid' => '6f1c2a4e-8b3d-4c5f-9a7e-1d2c3b4a5f60', + 'public_id' => 'order_a1b2c3d', + 'trackingNumber' => (object) ['tracking_number' => 'ACM1234567890SG'], + ]; + + expect(TrackingCode::matches('6f1c2a4e-8b3d-4c5f-9a7e-1d2c3b4a5f60', $order))->toBeTrue() + ->and(TrackingCode::matches('00000000-0000-4000-8000-000000000000', $order))->toBeFalse() + ->and(TrackingCode::matches(TrackingCode::qrContent('ACM1234567890SG', 'order_a1b2c3d'), $order))->toBeTrue() + // Handhelds may not preserve case. + ->and(TrackingCode::matches('acm1234567890sg', $order))->toBeTrue() + ->and(TrackingCode::matches('order_a1b2c3d', $order))->toBeTrue() + ->and(TrackingCode::matches('order_other', $order))->toBeFalse() + ->and(TrackingCode::matches(TrackingCode::qrContent('ACM1234567890SG'), $order))->toBeTrue() + ->and(TrackingCode::matches('https://console.example/track-order?r=order_a1b2c3d', $order))->toBeTrue() + // The owner named in the code decides: another owner is refused even with this + // subject's tracking number, while a second tracking number issued for this + // subject still names it and matches. + ->and(TrackingCode::matches(TrackingCode::qrContent('ACM1234567890SG', 'order_other'), $order))->toBeFalse() + ->and(TrackingCode::matches(TrackingCode::qrContent('ACM0000000000SG', 'order_a1b2c3d'), $order))->toBeTrue() + // Without an owner, the number must be this subject's. + ->and(TrackingCode::matches(TrackingCode::qrContent('ACM0000000000SG'), $order))->toBeFalse() + // A URL that names nothing matches nothing. + ->and(TrackingCode::matches('https://console.example/track-order', $order))->toBeFalse() + ->and(TrackingCode::matches('', $order))->toBeFalse() + ->and(TrackingCode::matches('order_a1b2c3d', null))->toBeFalse() + ->and(TrackingCode::matches('order_a1b2c3d', (object) ['public_id' => 'order_a1b2c3d']))->toBeFalse(); +}); + +test('matches decides a subject without its own tracking number by public id', function () { + // A service stop resolves to a place, which has no tracking number of its own. + $place = (object) ['uuid' => 'place-uuid', 'public_id' => 'place_p1']; + + expect(TrackingCode::matches(TrackingCode::qrContent('ACM1234567890SG', 'place_p1'), $place))->toBeTrue() + ->and(TrackingCode::matches(TrackingCode::qrContent('ACM1234567890SG'), $place))->toBeFalse() + ->and(TrackingCode::matches('ACM1234567890SG', $place))->toBeFalse() + ->and(TrackingCode::matches('place_p1', $place))->toBeTrue() + // A tracking number may also sit directly on the subject. + ->and(TrackingCode::matches('TN-FLAT', (object) ['uuid' => 'u', 'public_id' => '', 'tracking_number' => 'TN-FLAT']))->toBeTrue() + ->and(TrackingCode::matches('TN-FLAT', (object) ['uuid' => 'u', 'public_id' => null]))->toBeFalse(); +}); From 21924b0e90842d2e460302413420a840c6ebbc4d Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 14:42:01 +0800 Subject: [PATCH 3/3] fix(tracking): refuse non-string scan codes with the existing 400 capture-qr reads `code` straight from the request. The navigator v3 branch sends the scanner's whole result object; the old strict comparison answered 400, and the typed matcher would have thrown. Non-strings now match nothing. --- server/src/Support/TrackingCode.php | 9 +++++---- server/tests/ApiOrderControllerContractsTest.php | 11 +++++++++++ server/tests/Unit/Support/TrackingCodeTest.php | 3 +++ 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/server/src/Support/TrackingCode.php b/server/src/Support/TrackingCode.php index 907a79c9c..f043a8a53 100644 --- a/server/src/Support/TrackingCode.php +++ b/server/src/Support/TrackingCode.php @@ -123,18 +123,19 @@ public static function parse(?string $code): array /** * Whether a scanned value identifies the given subject (an order, waypoint, entity or - * place). + * place). The value comes straight from a request, so anything but a string, such as + * a scanner library's whole result object, matches nothing. */ - public static function matches(?string $code, $subject): bool + public static function matches($code, $subject): bool { $subjectUuid = data_get($subject, 'uuid'); - if (!$subject || empty($subjectUuid)) { + if (!is_string($code) || !$subject || empty($subjectUuid)) { return false; } // Labels printed before v1 encode the subject's bare uuid; compare it exactly, as // scans always have been. - if (trim((string) $code) === $subjectUuid) { + if (trim($code) === $subjectUuid) { return true; } diff --git a/server/tests/ApiOrderControllerContractsTest.php b/server/tests/ApiOrderControllerContractsTest.php index f25dd6d80..d22c0394d 100644 --- a/server/tests/ApiOrderControllerContractsTest.php +++ b/server/tests/ApiOrderControllerContractsTest.php @@ -1052,6 +1052,17 @@ public function isString(string $key): bool ->and($controller->createdProofs[0]['subject_uuid'])->toBe('subject-uuid'); } + // A scanner's whole result object, rather than its value, is refused as before + // instead of failing the request. + $controller = new FleetOpsApiOrderCrudControllerProbe(); + $controller->order = new FleetOpsApiOrderCrudFake(); + $controller->resolvedSubject = $subject; + + expect($controller->captureQrScan(new Request(['code' => ['type' => 'qr', 'value' => 'ACM1234567890SG']]), 'order-public', 'waypoint_subject'))->toBe([ + 'apiError' => 'Unable to validate QR code data.', + 'status' => 400, + ]); + // Another parcel's label is refused, whether it names another owner or, naming none, // another tracking number. foreach ([ diff --git a/server/tests/Unit/Support/TrackingCodeTest.php b/server/tests/Unit/Support/TrackingCodeTest.php index a62f65586..deef45224 100644 --- a/server/tests/Unit/Support/TrackingCodeTest.php +++ b/server/tests/Unit/Support/TrackingCodeTest.php @@ -114,6 +114,9 @@ // A URL that names nothing matches nothing. ->and(TrackingCode::matches('https://console.example/track-order', $order))->toBeFalse() ->and(TrackingCode::matches('', $order))->toBeFalse() + // A request may carry a scanner's whole result object instead of its value. + ->and(TrackingCode::matches(['type' => 'qr', 'value' => 'order_a1b2c3d'], $order))->toBeFalse() + ->and(TrackingCode::matches(null, $order))->toBeFalse() ->and(TrackingCode::matches('order_a1b2c3d', null))->toBeFalse() ->and(TrackingCode::matches('order_a1b2c3d', (object) ['public_id' => 'order_a1b2c3d']))->toBeFalse(); });