From 0c79b2b3efdcb5a5f35a3f959393bcae456d1f25 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 18 Sep 2026 15:27:31 +0800 Subject: [PATCH] Scope internal order lifecycle lookups to the caller's company MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The internal OrderController resolved every order-lifecycle target straight from a caller-supplied identifier with no company constraint: Order::where('uuid', $uuid)->first() // cancel Order::findById($id) // dispatch, schedule, tracker Order::where('uuid', $uuid)->withoutGlobalScopes() // start Order::whereIn('uuid', $ids)->get() // bulk-cancel, bulk-dispatch Driver::whereUuid($uuid)->first() // bulk-assign-driver Order::whereIn('uuid', $uuids)->update([...]) // bulk-assign-driver These targets arrive as body/query params rather than bound route parameters, so nothing upstream narrows them: `fleetbase.protected` runs auth:sanctum plus AuthorizationGuard, which only checks that the caller holds the named RBAC capability — it never inspects which company the record belongs to. Any authenticated user with ordinary "manage orders" rights could therefore cancel, dispatch, start, schedule or bulk-reassign another organization's orders by supplying their uuid, which is not secret (tracking links, labels, webhooks). Generic CRUD on the same model was already safe because it carries its own explicit company_uuid clause; these hand-rolled lifecycle lookups did not. Every by-identifier lookup in this controller now goes through a single `scopedToCompany()` guard that adds the company_uuid constraint and fails the query closed when no company is in session. Beyond the lifecycle actions, the same guard now covers update-activity, next-activity, set-destination, capture-photo, edit-route, ping-driver, proofs, entity/proof subjects, tracking-number lookup and the import file lookup, which had the identical gap. Two behavioural notes: - `cancel()` now rejects an unresolvable order instead of dereferencing null. `exists:orders,uuid` on CancelOrderRequest is a global existence check, so a cross-tenant uuid passes validation and has to be refused in the controller. - `bulkAssignDriver()` resolves the ids through the scoped lookup first, so orders owned by another company are dropped before the update, and are neither counted in the response nor queued for driver notification. `findOrderById()` also takes the identifier as mixed and resolves anything that is not a non-empty string to null, since it is raw request input. nextActivity() no longer depends on core-api's findByIdOrFail() raising a catchable ModelNotFoundException, so its not-found branch is live regardless of the upstream release; the test documenting that dependency is updated. Tests: new OrderControllerTenantScopingTest covers, for every patched lookup, the owning-company hit, the cross-tenant miss (both the uuid and public_id arms, so a regrouped OR cannot regress), and the no-company fail-closed path, plus the endpoint-level refusals. OrderController.php is at 853/853 statements covered with no uncovered lines. --- .../Internal/v1/OrderController.php | 184 ++++++-- .../Internal/OrderControllerContractsTest.php | 13 +- .../OrderControllerImportFromFilesTest.php | 18 + .../OrderControllerTenantScopingTest.php | 394 ++++++++++++++++++ .../OrderControllerUpstreamNotFoundTest.php | 28 +- 5 files changed, 583 insertions(+), 54 deletions(-) create mode 100644 server/tests/Feature/Http/Internal/OrderControllerTenantScopingTest.php diff --git a/server/src/Http/Controllers/Internal/v1/OrderController.php b/server/src/Http/Controllers/Internal/v1/OrderController.php index e07c7bce9..1c4740c97 100644 --- a/server/src/Http/Controllers/Internal/v1/OrderController.php +++ b/server/src/Http/Controllers/Internal/v1/OrderController.php @@ -39,6 +39,7 @@ use Fleetbase\Models\Type; use Fleetbase\Support\Auth; use Fleetbase\Support\TemplateString; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\QueryException; use Illuminate\Http\Request; @@ -341,7 +342,8 @@ public function importFromFiles(Request $request) $info = Utils::lookupIp(); $disk = $request->input('disk', config('filesystems.default')); $files = $request->input('files'); - $files = File::whereIn('uuid', $files)->get(); + /** @var \Illuminate\Database\Eloquent\Collection $files */ + $files = $this->scopedToCompany(File::whereIn('uuid', $files))->get(); $country = $request->input('country', Utils::or($info, ['country_name', 'region'], 'Singapore')); $validFileTypes = ['csv', 'tsv', 'xls', 'xlsx']; @@ -513,7 +515,13 @@ public function bulkAssignDriver(Request $request) } // Prepare Order UUID Collection - $orderUuids = collect($data['ids'])->unique()->values(); + // + // Resolved through the company-scoped lookup so ids naming another + // organization's orders are dropped here rather than being assigned, + // counted in the response and queued for notification. + $orderUuids = $this->ordersByUuid(collect($data['ids'])->unique()->values()->all()) + ->pluck('uuid') + ->values(); // Bulk Update Inside A Transaction $this->runTransaction(function () use ($orderUuids, $driver): void { @@ -545,8 +553,14 @@ public function bulkAssignDriver(Request $request) */ public function cancel(CancelOrderRequest $request) { - /** @var Order */ + /** @var Order|null */ $order = $this->findOrderByUuid($request->input('order')); + if (!$order) { + // `exists:orders,uuid` on the form request is a global existence + // check, so a known uuid belonging to another organization reaches + // here and must be rejected rather than dereferenced. + return $this->errorResponse('No order found to cancel.'); + } $order->cancel(); @@ -599,9 +613,39 @@ public function dispatchOrder(Request $request) ); } + /** + * Constrain a tenant-owned lookup to the company the caller is acting for. + * + * The order-lifecycle actions on this controller receive their target as a + * caller-supplied identifier in the request body or query string rather than + * as a bound route parameter, so nothing upstream narrows these queries to + * the caller's tenant: `fleetbase.protected` only checks that the caller + * holds the named RBAC capability, never which company the record belongs to. + * + * A missing company session fails the query closed instead of letting it run + * unbounded across every tenant. + */ + protected function scopedToCompany(Builder $query): Builder + { + $companyUuid = $this->sessionCompany(); + if (!$companyUuid) { + $query->whereRaw('1 = 0'); + + return $query; + } + + return $query->where($query->getModel()->qualifyColumn('company_uuid'), $companyUuid); + } + + /** + * @return \Illuminate\Database\Eloquent\Collection + */ protected function ordersByUuid(array $ids) { - return Order::whereIn('uuid', $ids)->get(); + /** @var \Illuminate\Database\Eloquent\Collection $orders */ + $orders = $this->scopedToCompany(Order::whereIn('uuid', $ids))->get(); + + return $orders; } protected function trackingStatusExists(?string $trackingNumberUuid, string $code): bool @@ -611,7 +655,10 @@ protected function trackingStatusExists(?string $trackingNumberUuid, string $cod protected function findDriverByUuid(string $uuid): ?Driver { - return Driver::whereUuid($uuid)->first(); + /** @var Driver|null $driver */ + $driver = $this->scopedToCompany(Driver::whereUuid($uuid))->first(); + + return $driver; } protected function driverDisplayName(Driver $driver): string @@ -630,17 +677,47 @@ protected function validateBulkAssignDriverRequest(Request $request): array protected function findOrderByUuid(string $uuid): ?Order { - return Order::where('uuid', $uuid)->first(); + /** @var Order|null $order */ + $order = $this->scopedToCompany(Order::where('uuid', $uuid))->first(); + + return $order; } - protected function findOrderById(string $id, array $with = []): ?Order + /** + * Resolve an order by uuid or public_id, constrained to the caller's company. + * + * The identifier match is grouped so the company constraint applies to both + * arms — ungrouped it would read as `uuid = ? OR (public_id = ? AND + * company_uuid = ?)` and still resolve another organization's orders. + * + * The identifier is whatever the caller put in the request, so anything + * that is not a non-empty string resolves to no order rather than raising. + * + * @param array $with + */ + protected function findOrderById(mixed $id, array $with = []): ?Order { - return Order::findById($id, $with); + if (!is_string($id) || $id === '') { + return null; + } + + /** @var Order|null $order */ + $order = $this->scopedToCompany(Order::query()) + ->where(function (Builder $query) use ($id) { + $query->where('uuid', $id)->orWhere('public_id', $id); + }) + ->with($with) + ->first(); + + return $order; } protected function findOrderRouteForEdit(string $uuid): ?Order { - return Order::where('uuid', $uuid)->with(['payload'])->first(); + /** @var Order|null $order */ + $order = $this->scopedToCompany(Order::where('uuid', $uuid))->with(['payload'])->first(); + + return $order; } protected function orderResponse(Order $order): array @@ -650,7 +727,7 @@ protected function orderResponse(Order $order): array protected function assignDriverToOrders($orderUuids, Driver $driver): void { - Order::whereIn('uuid', $orderUuids)->update([ + $this->scopedToCompany(Order::whereIn('uuid', $orderUuids))->update([ 'driver_assigned_uuid' => $driver->uuid, 'updated_at' => now(), ]); @@ -739,17 +816,28 @@ public function start(Request $request) protected function findOrderForStart(?string $uuid): ?Order { - return Order::where('uuid', $uuid)->withoutGlobalScopes()->first(); + /** @var Order|null $order */ + $order = $this->scopedToCompany(Order::where('uuid', $uuid)->withoutGlobalScopes())->first(); + + return $order; } protected function findDriverForStart(?string $uuid): ?Driver { - return Driver::where('uuid', $uuid)->withoutGlobalScopes()->first(); + /** @var Driver|null $driver */ + $driver = $this->scopedToCompany(Driver::where('uuid', $uuid)->withoutGlobalScopes())->first(); + + return $driver; } protected function findPayloadForStart(?string $uuid): ?Payload { - return Payload::where('uuid', $uuid)->withoutGlobalScopes()->with(['waypoints', 'waypointMarkers', 'entities'])->first(); + /** @var Payload|null $payload */ + $payload = $this->scopedToCompany(Payload::where('uuid', $uuid)->withoutGlobalScopes()) + ->with(['waypoints', 'waypointMarkers', 'entities']) + ->first(); + + return $payload; } protected function dispatchDomainEvent(object $event): object @@ -771,7 +859,7 @@ protected function orderStartedEvent(Order $order): object */ public function updateActivity(string $id, Request $request) { - $order = Order::findById($id, [ + $order = $this->findOrderById($id, [ 'driverAssigned', 'payload.entities', 'payload.pickup', @@ -882,9 +970,8 @@ public function updateActivity(string $id, Request $request) */ public function nextActivity(string $id, Request $request) { - try { - $order = Order::findByIdOrFail($id); - } catch (ModelNotFoundException $e) { + $order = $this->findOrderById($id); + if (!$order) { return response()->error('No order found.'); } @@ -935,7 +1022,7 @@ public function nextActivity(string $id, Request $request) */ public function setDestination(string $id, string $placeId) { - $order = Order::findById($id, [ + $order = $this->findOrderById($id, [ 'payload.pickup', 'payload.dropoff', 'payload.return', @@ -1013,7 +1100,7 @@ function ($attribute, $value, $fail) { return response()->error($errorMessage, 422); } - $order = Order::findById($id, ['payload.pickup', 'payload.dropoff', 'payload.return', 'payload.waypoints', 'payload.waypointMarkers.place']); + $order = $this->findOrderById($id, ['payload.pickup', 'payload.dropoff', 'payload.return', 'payload.waypoints', 'payload.waypointMarkers.place']); if (!$order) { return response()->error('No order found.'); } @@ -1214,7 +1301,14 @@ protected function resolveProof($proof): ?Proof } if (is_string($proof)) { - return Proof::where('public_id', $proof)->orWhere('uuid', $proof)->first(); + /** @var Proof|null $resolved */ + $resolved = $this->scopedToCompany(Proof::query()) + ->where(function (Builder $query) use ($proof) { + $query->where('public_id', $proof)->orWhere('uuid', $proof); + }) + ->first(); + + return $resolved; } return null; @@ -1419,7 +1513,12 @@ protected function canPingDriver(): bool protected function findOrderForDriverPing(string $id): Order { - return Order::findByIdOrFail($id, ['driverAssigned']); + $order = $this->findOrderById($id, ['driverAssigned']); + if (!$order) { + throw new ModelNotFoundException(); + } + + return $order; } protected function sendDriverPing(Driver $driver, Order $order): void @@ -1692,7 +1791,10 @@ public function proofs(Request $request, string $id, ?string $subjectId = null) protected function findOrderForProofs(string $id): ?Order { - return Order::where('uuid', $id)->first(); + /** @var Order|null $order */ + $order = $this->scopedToCompany(Order::where('uuid', $id))->first(); + + return $order; } protected function findWaypointProofSubject(Order $order, string $subjectId): ?Waypoint @@ -1708,7 +1810,10 @@ protected function findWaypointProofSubject(Order $order, string $subjectId): ?W protected function findEntityProofSubject(string $subjectId): ?Entity { - return Entity::where('uuid', $subjectId)->withoutGlobalScopes()->first(); + /** @var Entity|null $entity */ + $entity = $this->scopedToCompany(Entity::where('uuid', $subjectId)->withoutGlobalScopes())->first(); + + return $entity; } protected function proofsForSubject(Order $order, Order|Waypoint|Entity $subject) @@ -1774,12 +1879,17 @@ public function lookup(Request $request) protected function findOrderByTrackingNumber(string $trackingNumber): ?Order { - return Order::whereHas( - 'trackingNumber', - function ($query) use ($trackingNumber) { - $query->where('tracking_number', $trackingNumber); - } + /** @var Order|null $order */ + $order = $this->scopedToCompany( + Order::whereHas( + 'trackingNumber', + function ($query) use ($trackingNumber) { + $query->where('tracking_number', $trackingNumber); + } + ) )->first(); + + return $order; } /** @@ -1824,13 +1934,25 @@ public function scheduleOrder(Request $request) protected function findOrderForSchedule(?string $id): ?Order { - return Order::findById($id); + return $this->findOrderById($id); } + /** + * Resolve a driver by uuid or public_id, constrained to the caller's company. + * + * The identifier match is grouped so the company constraint applies to both + * arms — ungrouped it would read as `uuid = ? OR (public_id = ? AND + * company_uuid = ?)` and still resolve another organization's drivers. + */ protected function findDriverForSchedule(string $id): ?Driver { - return Driver::where('uuid', $id) - ->orWhere('public_id', $id) + /** @var Driver|null $driver */ + $driver = $this->scopedToCompany(Driver::query()) + ->where(function (Builder $query) use ($id) { + $query->where('uuid', $id)->orWhere('public_id', $id); + }) ->first(); + + return $driver; } } diff --git a/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php b/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php index 400e68b11..6c815da8a 100644 --- a/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php +++ b/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php @@ -298,7 +298,7 @@ protected function findOrderByUuid(string $uuid): ?Order return $this->order; } - protected function findOrderById(string $id, array $with = []): ?Order + protected function findOrderById(mixed $id, array $with = []): ?Order { $this->order?->setAttribute('lookup_id', $id); $this->order?->setAttribute('lookup_with', $with); @@ -990,10 +990,15 @@ function fleetopsSuppressStrNullDeprecations(): Closure }); test('internal order controller bulk assign driver deduplicates orders and queues notifications', function () { - $controller = fleetopsInternalOrderLifecycleController(); $driverUuid = '11111111-1111-4111-8111-111111111111'; $orderA = '22222222-2222-4222-8222-222222222222'; $orderB = '33333333-3333-4333-8333-333333333333'; + // The ids are resolved through the company-scoped `ordersByUuid()` lookup, so + // only orders it returns are assigned, counted and notified. + $controller = fleetopsInternalOrderLifecycleController([ + fleetopsInternalOrderLifecycleOrder($orderA), + fleetopsInternalOrderLifecycleOrder($orderB), + ]); $response = $controller->bulkAssignDriver(fleetopsBulkActionRequest([ 'ids' => [$orderA, $orderA, $orderB], @@ -1011,7 +1016,9 @@ function fleetopsSuppressStrNullDeprecations(): Closure ->and($controller->assignedDriverUuid)->toBe($driverUuid) ->and($controller->bulkNotification)->toBe([[$orderA, $orderB], $driverUuid]); - $controller = fleetopsInternalOrderLifecycleController(); + $controller = fleetopsInternalOrderLifecycleController([ + fleetopsInternalOrderLifecycleOrder($orderA), + ]); $controller->bulkAssignDriver(fleetopsBulkActionRequest([ 'ids' => [$orderA], 'driver' => $driverUuid, diff --git a/server/tests/Feature/Http/Internal/OrderControllerImportFromFilesTest.php b/server/tests/Feature/Http/Internal/OrderControllerImportFromFilesTest.php index 48d7fa42a..cd4cba0b2 100644 --- a/server/tests/Feature/Http/Internal/OrderControllerImportFromFilesTest.php +++ b/server/tests/Feature/Http/Internal/OrderControllerImportFromFilesTest.php @@ -177,3 +177,21 @@ function fleetopsOrderImportFilesRequest(array $input): Request ])); expect($unreadable->getStatusCode())->toBeGreaterThanOrEqual(400); }); + +test('import ignores files uploaded by another company', function () { + $connection = fleetopsOrderImportFilesBoot(); + $connection->table('files')->insert(['uuid' => '33333333-3333-4333-8333-333333333333', 'public_id' => 'file_ordimport4', 'company_uuid' => 'company-2', 'path' => 'uploads/orders.xlsx', 'disk' => 'local']); + fleetopsOrderImportFilesExcelFake([[ + ['name' => 'Victim Stop', 'street1' => 'Victim Rd 1', 'city' => 'Singapore'], + ]]); + + // The file uuid is caller-supplied and nothing upstream checks who owns it, + // so an unscoped lookup here would read a rival company's spreadsheet. + $response = (new OrderController())->importFromFiles(fleetopsOrderImportFilesRequest([ + 'files' => ['33333333-3333-4333-8333-333333333333'], + ])); + + $data = $response->getData(true); + expect($data['places'])->toHaveCount(0) + ->and($data['entities'])->toHaveCount(0); +}); diff --git a/server/tests/Feature/Http/Internal/OrderControllerTenantScopingTest.php b/server/tests/Feature/Http/Internal/OrderControllerTenantScopingTest.php new file mode 100644 index 000000000..fbcc3d182 --- /dev/null +++ b/server/tests/Feature/Http/Internal/OrderControllerTenantScopingTest.php @@ -0,0 +1,394 @@ +{$method}(...$arguments); + } +} + +const FLEETOPS_TENANT_OWN_ORDER = '55555555-5555-4555-8555-555555555501'; +const FLEETOPS_TENANT_VICTIM_ORDER = '55555555-5555-4555-8555-555555555502'; +const FLEETOPS_TENANT_OWN_DRIVER = '55555555-5555-4555-8555-555555555511'; +const FLEETOPS_TENANT_VICTIM_DRIVER = '55555555-5555-4555-8555-555555555512'; + +function fleetopsOrderTenantBoot(): SQLiteConnection +{ + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); + app()->instance('db', new class($connection) { + public function __construct(public SQLiteConnection $c) + { + } + + public function connection($name = null): SQLiteConnection + { + return $this->c; + } + + public function __call($method, $arguments) + { + return $this->c->{$method}(...$arguments); + } + }); + Illuminate\Support\Facades\DB::clearResolvedInstance('db'); + + $schema = $connection->getSchemaBuilder(); + $tables = [ + 'orders' => ['uuid', 'public_id', 'company_uuid', 'payload_uuid', 'order_config_uuid', 'tracking_number_uuid', 'driver_assigned_uuid', 'status', 'type', 'adhoc', 'dispatched', 'started', 'scheduled_at', 'meta', '_key'], + 'payloads' => ['uuid', 'public_id', 'company_uuid', 'pickup_uuid', 'dropoff_uuid', 'current_waypoint_uuid', 'meta', 'type'], + 'places' => ['uuid', 'public_id', 'company_uuid', 'name', 'location'], + 'waypoints' => ['uuid', 'public_id', 'company_uuid', 'payload_uuid', 'place_uuid', 'tracking_number_uuid', 'order', 'type'], + 'entities' => ['uuid', 'public_id', 'company_uuid', 'payload_uuid', 'destination_uuid', 'tracking_number_uuid', 'name', 'type'], + 'drivers' => ['uuid', 'public_id', 'company_uuid', 'user_uuid', 'vehicle_uuid', 'status', 'online', 'location', 'current_job_uuid'], + 'users' => ['uuid', 'public_id', 'company_uuid', 'name', 'status', 'type'], + 'tracking_numbers' => ['uuid', 'public_id', 'company_uuid', 'tracking_number', 'owner_uuid', 'owner_type', '_key'], + 'tracking_statuses' => ['uuid', 'public_id', 'company_uuid', 'tracking_number_uuid', 'status', 'details', 'code', '_key'], + 'proofs' => ['uuid', 'public_id', 'company_uuid', 'order_uuid', 'subject_uuid', 'subject_type', 'file_uuid', 'remarks', 'raw_data', 'data'], + 'companies' => ['uuid', 'public_id', 'name', 'country'], + ]; + foreach ($tables as $table => $columns) { + $schema->create($table, function ($blueprint) use ($columns) { + $blueprint->increments('id'); + foreach ($columns as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + + session(['company' => 'company-1']); + + return $connection; +} + +/** + * Seeds one order/driver/payload per tenant. The two tenants' records are + * identical apart from `company_uuid`, so any lookup that resolves the + * `company-2` identifier is crossing the tenant boundary. + */ +function fleetopsOrderTenantSeed(SQLiteConnection $connection): void +{ + $connection->table('users')->insert([ + ['uuid' => 'user-own', 'company_uuid' => 'company-1', 'name' => 'Own Driver'], + ['uuid' => 'user-victim', 'company_uuid' => 'company-2', 'name' => 'Victim Driver'], + ]); + $connection->table('drivers')->insert([ + ['uuid' => FLEETOPS_TENANT_OWN_DRIVER, 'public_id' => 'driver_own1', 'company_uuid' => 'company-1', 'user_uuid' => 'user-own'], + ['uuid' => FLEETOPS_TENANT_VICTIM_DRIVER, 'public_id' => 'driver_victim1', 'company_uuid' => 'company-2', 'user_uuid' => 'user-victim'], + ]); + $connection->table('payloads')->insert([ + ['uuid' => 'payload-own', 'company_uuid' => 'company-1'], + ['uuid' => 'payload-victim', 'company_uuid' => 'company-2'], + ]); + $connection->table('tracking_numbers')->insert([ + ['uuid' => 'tn-own', 'company_uuid' => 'company-1', 'tracking_number' => 'FLB-OWN-1'], + ['uuid' => 'tn-victim', 'company_uuid' => 'company-2', 'tracking_number' => 'FLB-VICTIM-1'], + ]); + $connection->table('orders')->insert([ + [ + 'uuid' => FLEETOPS_TENANT_OWN_ORDER, + 'public_id' => 'order_own1', + 'company_uuid' => 'company-1', + 'payload_uuid' => 'payload-own', + 'tracking_number_uuid' => 'tn-own', + 'driver_assigned_uuid' => FLEETOPS_TENANT_OWN_DRIVER, + 'status' => 'created', + 'type' => 'transport', + ], + [ + 'uuid' => FLEETOPS_TENANT_VICTIM_ORDER, + 'public_id' => 'order_victim1', + 'company_uuid' => 'company-2', + 'payload_uuid' => 'payload-victim', + 'tracking_number_uuid' => 'tn-victim', + 'driver_assigned_uuid' => FLEETOPS_TENANT_VICTIM_DRIVER, + 'status' => 'created', + 'type' => 'transport', + ], + ]); + $connection->table('entities')->insert([ + ['uuid' => 'entity-own', 'company_uuid' => 'company-1', 'payload_uuid' => 'payload-own', 'name' => 'Own Parcel'], + ['uuid' => 'entity-victim', 'company_uuid' => 'company-2', 'payload_uuid' => 'payload-victim', 'name' => 'Victim Parcel'], + ]); + $connection->table('proofs')->insert([ + ['uuid' => 'proof-own', 'public_id' => 'proof_own1', 'company_uuid' => 'company-1', 'order_uuid' => FLEETOPS_TENANT_OWN_ORDER, 'subject_uuid' => FLEETOPS_TENANT_OWN_ORDER], + ['uuid' => 'proof-victim', 'public_id' => 'proof_victim1', 'company_uuid' => 'company-2', 'order_uuid' => FLEETOPS_TENANT_VICTIM_ORDER, 'subject_uuid' => FLEETOPS_TENANT_VICTIM_ORDER], + ]); +} + +test('order lookups resolve the callers own records', function () { + $connection = fleetopsOrderTenantBoot(); + fleetopsOrderTenantSeed($connection); + $probe = new FleetOpsInternalOrderTenantScopeProbe(); + + expect($probe->callHelper('ordersByUuid', [FLEETOPS_TENANT_OWN_ORDER])->pluck('uuid')->all())->toBe([FLEETOPS_TENANT_OWN_ORDER]) + ->and($probe->callHelper('findOrderByUuid', FLEETOPS_TENANT_OWN_ORDER)?->public_id)->toBe('order_own1') + ->and($probe->callHelper('findOrderById', FLEETOPS_TENANT_OWN_ORDER)?->public_id)->toBe('order_own1') + ->and($probe->callHelper('findOrderById', 'order_own1')?->uuid)->toBe(FLEETOPS_TENANT_OWN_ORDER) + ->and($probe->callHelper('findOrderRouteForEdit', FLEETOPS_TENANT_OWN_ORDER)?->uuid)->toBe(FLEETOPS_TENANT_OWN_ORDER) + ->and($probe->callHelper('findOrderForStart', FLEETOPS_TENANT_OWN_ORDER)?->uuid)->toBe(FLEETOPS_TENANT_OWN_ORDER) + ->and($probe->callHelper('findPayloadForStart', 'payload-own')?->uuid)->toBe('payload-own') + ->and($probe->callHelper('findOrderForSchedule', 'order_own1')?->uuid)->toBe(FLEETOPS_TENANT_OWN_ORDER) + ->and($probe->callHelper('findOrderForProofs', FLEETOPS_TENANT_OWN_ORDER)?->uuid)->toBe(FLEETOPS_TENANT_OWN_ORDER) + ->and($probe->callHelper('findOrderForDriverPing', 'order_own1')?->uuid)->toBe(FLEETOPS_TENANT_OWN_ORDER) + ->and($probe->callHelper('findOrderByTrackingNumber', 'FLB-OWN-1')?->uuid)->toBe(FLEETOPS_TENANT_OWN_ORDER) + ->and($probe->callHelper('findEntityProofSubject', 'entity-own')?->uuid)->toBe('entity-own') + ->and($probe->callHelper('resolveProof', 'proof_own1')?->uuid)->toBe('proof-own') + ->and($probe->callHelper('resolveProof', 'proof-own')?->uuid)->toBe('proof-own'); +}); + +test('driver lookups resolve the callers own drivers', function () { + $connection = fleetopsOrderTenantBoot(); + fleetopsOrderTenantSeed($connection); + $probe = new FleetOpsInternalOrderTenantScopeProbe(); + + expect($probe->callHelper('findDriverByUuid', FLEETOPS_TENANT_OWN_DRIVER)?->public_id)->toBe('driver_own1') + ->and($probe->callHelper('findDriverForStart', FLEETOPS_TENANT_OWN_DRIVER)?->public_id)->toBe('driver_own1') + ->and($probe->callHelper('findDriverForSchedule', FLEETOPS_TENANT_OWN_DRIVER)?->public_id)->toBe('driver_own1') + ->and($probe->callHelper('findDriverForSchedule', 'driver_own1')?->uuid)->toBe(FLEETOPS_TENANT_OWN_DRIVER); +}); + +test('order lookups refuse identifiers belonging to another company', function () { + $connection = fleetopsOrderTenantBoot(); + fleetopsOrderTenantSeed($connection); + $probe = new FleetOpsInternalOrderTenantScopeProbe(); + + // Both identifier arms are covered: an unguarded + // `where(uuid)->orWhere(public_id)->where(company_uuid)` chain would read as + // `uuid = ? OR (public_id = ? AND company_uuid = ?)` and still resolve the + // victim by uuid. + expect($probe->callHelper('ordersByUuid', [FLEETOPS_TENANT_VICTIM_ORDER]))->toHaveCount(0) + ->and($probe->callHelper('findOrderByUuid', FLEETOPS_TENANT_VICTIM_ORDER))->toBeNull() + ->and($probe->callHelper('findOrderById', FLEETOPS_TENANT_VICTIM_ORDER))->toBeNull() + ->and($probe->callHelper('findOrderById', 'order_victim1'))->toBeNull() + ->and($probe->callHelper('findOrderRouteForEdit', FLEETOPS_TENANT_VICTIM_ORDER))->toBeNull() + ->and($probe->callHelper('findOrderForStart', FLEETOPS_TENANT_VICTIM_ORDER))->toBeNull() + ->and($probe->callHelper('findPayloadForStart', 'payload-victim'))->toBeNull() + ->and($probe->callHelper('findOrderForSchedule', 'order_victim1'))->toBeNull() + ->and($probe->callHelper('findOrderForProofs', FLEETOPS_TENANT_VICTIM_ORDER))->toBeNull() + ->and($probe->callHelper('findOrderByTrackingNumber', 'FLB-VICTIM-1'))->toBeNull() + ->and($probe->callHelper('findEntityProofSubject', 'entity-victim'))->toBeNull() + ->and($probe->callHelper('resolveProof', 'proof_victim1'))->toBeNull() + ->and($probe->callHelper('resolveProof', 'proof-victim'))->toBeNull() + ->and($probe->callHelper('findDriverByUuid', FLEETOPS_TENANT_VICTIM_DRIVER))->toBeNull() + ->and($probe->callHelper('findDriverForStart', FLEETOPS_TENANT_VICTIM_DRIVER))->toBeNull() + ->and($probe->callHelper('findDriverForSchedule', FLEETOPS_TENANT_VICTIM_DRIVER))->toBeNull() + ->and($probe->callHelper('findDriverForSchedule', 'driver_victim1'))->toBeNull(); + + // The ping lookup reports a miss the same way it reports an unknown id, so + // the endpoint cannot be used to probe which ids exist in other tenants. + expect(fn () => $probe->callHelper('findOrderForDriverPing', 'order_victim1')) + ->toThrow(ModelNotFoundException::class); +}); + +test('order lookups fail closed when no company session is present', function () { + $connection = fleetopsOrderTenantBoot(); + fleetopsOrderTenantSeed($connection); + session(['company' => null]); + $probe = new FleetOpsInternalOrderTenantScopeProbe(); + + expect($probe->callHelper('ordersByUuid', [FLEETOPS_TENANT_OWN_ORDER, FLEETOPS_TENANT_VICTIM_ORDER]))->toHaveCount(0) + ->and($probe->callHelper('findOrderByUuid', FLEETOPS_TENANT_OWN_ORDER))->toBeNull() + ->and($probe->callHelper('findOrderById', 'order_own1'))->toBeNull() + ->and($probe->callHelper('findDriverByUuid', FLEETOPS_TENANT_OWN_DRIVER))->toBeNull() + ->and($probe->callHelper('findDriverForSchedule', 'driver_own1'))->toBeNull(); +}); + +test('order resolution by id rejects empty identifiers without querying', function () { + $connection = fleetopsOrderTenantBoot(); + fleetopsOrderTenantSeed($connection); + $probe = new FleetOpsInternalOrderTenantScopeProbe(); + + // The identifier is raw request input, so a non-string body value has to + // resolve to no order rather than raising out of the endpoint. + expect($probe->callHelper('findOrderById', null))->toBeNull() + ->and($probe->callHelper('findOrderById', ''))->toBeNull() + ->and($probe->callHelper('findOrderById', ['uuid' => FLEETOPS_TENANT_OWN_ORDER]))->toBeNull() + ->and($probe->callHelper('findOrderById', 42))->toBeNull() + ->and($probe->callHelper('findOrderForSchedule', null))->toBeNull(); +}); + +test('bulk driver assignment only touches orders the caller owns', function () { + $connection = fleetopsOrderTenantBoot(); + fleetopsOrderTenantSeed($connection); + $connection->table('orders')->update(['driver_assigned_uuid' => null]); + $controller = new OrderController(); + + $response = $controller->bulkAssignDriver(Request::create('/x', 'PATCH', [ + 'ids' => [FLEETOPS_TENANT_OWN_ORDER, FLEETOPS_TENANT_VICTIM_ORDER], + 'driver' => FLEETOPS_TENANT_OWN_DRIVER, + 'silent' => true, + ])); + + // The victim order is dropped before the update, so it is neither + // reassigned nor counted in the response. + expect($response->getData(true)['count'])->toBe(1) + ->and($connection->table('orders')->where('uuid', FLEETOPS_TENANT_OWN_ORDER)->value('driver_assigned_uuid'))->toBe(FLEETOPS_TENANT_OWN_DRIVER) + ->and($connection->table('orders')->where('uuid', FLEETOPS_TENANT_VICTIM_ORDER)->value('driver_assigned_uuid'))->toBeNull(); +}); + +test('bulk driver assignment refuses a driver from another company', function () { + $connection = fleetopsOrderTenantBoot(); + fleetopsOrderTenantSeed($connection); + $controller = new OrderController(); + + $response = $controller->bulkAssignDriver(Request::create('/x', 'PATCH', [ + 'ids' => [FLEETOPS_TENANT_OWN_ORDER], + 'driver' => FLEETOPS_TENANT_VICTIM_DRIVER, + 'silent' => true, + ])); + + expect($response->getData(true)['error'] ?? '')->toContain('Invalid driver selected'); +}); + +test('the bulk assignment update is itself scoped to the callers company', function () { + $connection = fleetopsOrderTenantBoot(); + fleetopsOrderTenantSeed($connection); + $connection->table('orders')->update(['driver_assigned_uuid' => null]); + $probe = new FleetOpsInternalOrderTenantScopeProbe(); + $driver = Driver::where('uuid', FLEETOPS_TENANT_OWN_DRIVER)->first(); + + // Called directly with a victim uuid, standing in for any future caller + // that reaches this seam without pre-filtering the ids. + $probe->callHelper('assignDriverToOrders', [FLEETOPS_TENANT_OWN_ORDER, FLEETOPS_TENANT_VICTIM_ORDER], $driver); + + expect($connection->table('orders')->where('uuid', FLEETOPS_TENANT_OWN_ORDER)->value('driver_assigned_uuid'))->toBe(FLEETOPS_TENANT_OWN_DRIVER) + ->and($connection->table('orders')->where('uuid', FLEETOPS_TENANT_VICTIM_ORDER)->value('driver_assigned_uuid'))->toBeNull(); +}); + +test('bulk cancel and bulk dispatch skip orders from another company', function () { + $connection = fleetopsOrderTenantBoot(); + fleetopsOrderTenantSeed($connection); + $controller = new OrderController(); + + $canceled = $controller->bulkCancel(Request::create('/x', 'PATCH', [ + 'ids' => [FLEETOPS_TENANT_VICTIM_ORDER], + ])); + expect($canceled->getData(true)['count'])->toBe(0) + ->and($connection->table('orders')->where('uuid', FLEETOPS_TENANT_VICTIM_ORDER)->value('status'))->toBe('created'); + + $dispatched = $controller->bulkDispatch(BulkDispatchRequest::create('/x', 'POST', [ + 'ids' => [FLEETOPS_TENANT_VICTIM_ORDER], + ])); + expect($dispatched->getData(true)['count'])->toBe(0) + ->and($connection->table('orders')->where('uuid', FLEETOPS_TENANT_VICTIM_ORDER)->value('dispatched'))->toBeNull(); +}); + +test('cancel rejects a known order uuid that belongs to another company', function () { + $connection = fleetopsOrderTenantBoot(); + fleetopsOrderTenantSeed($connection); + + // `exists:orders,uuid` on CancelOrderRequest is a global existence check, so + // this uuid passes validation and the controller itself has to refuse it. + $response = (new OrderController())->cancel( + CancelOrderRequest::create('/x', 'PATCH', ['order' => FLEETOPS_TENANT_VICTIM_ORDER]) + ); + + expect($response->getData(true)['error'] ?? '')->toContain('No order found to cancel') + ->and($connection->table('orders')->where('uuid', FLEETOPS_TENANT_VICTIM_ORDER)->value('status'))->toBe('created'); +}); + +test('dispatch start and schedule refuse another companys order', function () { + $connection = fleetopsOrderTenantBoot(); + fleetopsOrderTenantSeed($connection); + $controller = new OrderController(); + + $dispatched = $controller->dispatchOrder(Request::create('/x', 'PATCH', ['order' => FLEETOPS_TENANT_VICTIM_ORDER])); + expect($dispatched->getData(true)['error'] ?? '')->toContain('No order found to dispatch'); + + $started = $controller->start(Request::create('/x', 'PATCH', ['order' => FLEETOPS_TENANT_VICTIM_ORDER])); + expect($started->getData(true)['error'] ?? '')->toContain('Unable to find order to start'); + + $scheduled = $controller->scheduleOrder(Request::create('/x', 'PATCH', [ + 'order' => FLEETOPS_TENANT_VICTIM_ORDER, + 'scheduled_at' => '2026-01-01 09:00:00', + ])); + expect($scheduled->getData(true)['error'] ?? '')->toContain('No order found to schedule') + ->and($connection->table('orders')->where('uuid', FLEETOPS_TENANT_VICTIM_ORDER)->value('scheduled_at'))->toBeNull() + ->and($connection->table('drivers')->where('uuid', FLEETOPS_TENANT_VICTIM_DRIVER)->value('current_job_uuid'))->toBeNull(); +}); + +test('schedule ignores a driver from another company', function () { + $connection = fleetopsOrderTenantBoot(); + fleetopsOrderTenantSeed($connection); + $connection->table('orders')->where('uuid', FLEETOPS_TENANT_OWN_ORDER)->update(['driver_assigned_uuid' => null]); + + $response = (new OrderController())->scheduleOrder(Request::create('/x', 'PATCH', [ + 'order' => FLEETOPS_TENANT_OWN_ORDER, + 'driver_id' => 'driver_victim1', + ])); + + expect($response->getData(true)['status'])->toBe('OK') + ->and($connection->table('orders')->where('uuid', FLEETOPS_TENANT_OWN_ORDER)->value('driver_assigned_uuid'))->toBeNull(); +}); + +test('activity destination and next-activity endpoints refuse another companys order', function () { + $connection = fleetopsOrderTenantBoot(); + fleetopsOrderTenantSeed($connection); + $controller = new OrderController(); + + $nextActivity = $controller->nextActivity('order_victim1', Request::create('/x', 'GET')); + expect($nextActivity->getData(true))->toBe(['error' => 'No order found.']); + + $updateActivity = $controller->updateActivity(FLEETOPS_TENANT_VICTIM_ORDER, Request::create('/x', 'PATCH', ['activity' => []])); + expect($updateActivity->getData(true))->toBe(['error' => 'No order found.']); + + $setDestination = $controller->setDestination(FLEETOPS_TENANT_VICTIM_ORDER, 'place-victim'); + expect($setDestination->getData(true))->toBe(['error' => 'No order found.']); + + $trackerInfo = $controller->trackerInfo(Request::create('/x', 'GET'), FLEETOPS_TENANT_VICTIM_ORDER); + expect($trackerInfo->getData(true))->toBe(['error' => 'No order found.']); + + $waypointEtas = $controller->waypointEtas(Request::create('/x', 'GET'), FLEETOPS_TENANT_VICTIM_ORDER); + expect($waypointEtas->getData(true))->toBe(['error' => 'No order found.']); + + $editRoute = $controller->editOrderRoute(FLEETOPS_TENANT_VICTIM_ORDER, Request::create('/x', 'PATCH')); + expect($editRoute->getData(true)['error'] ?? '')->toContain('Unable to find order to update route for'); +}); + +test('proofs endpoint refuses another companys order', function () { + $connection = fleetopsOrderTenantBoot(); + fleetopsOrderTenantSeed($connection); + + $response = (new OrderController())->proofs(Request::create('/x', 'GET'), FLEETOPS_TENANT_VICTIM_ORDER); + + expect($response->getData(true)['error'] ?? '')->toContain('Unable to retrieve proof'); +}); diff --git a/server/tests/Feature/Http/Internal/OrderControllerUpstreamNotFoundTest.php b/server/tests/Feature/Http/Internal/OrderControllerUpstreamNotFoundTest.php index deed7491c..cd0cc3069 100644 --- a/server/tests/Feature/Http/Internal/OrderControllerUpstreamNotFoundTest.php +++ b/server/tests/Feature/Http/Internal/OrderControllerUpstreamNotFoundTest.php @@ -6,27 +6,15 @@ /** * Covers the not-found branch of Internal\v1\OrderController::nextActivity(). * - * --------------------------------------------------------------------------- - * THIS FILE IS EXPECTED TO FAIL until fleetbase/core-api 1.6.55 is released and - * pulled into server_vendor. Do not "fix" it by weakening the assertion. - * --------------------------------------------------------------------------- + * This branch used to depend on upstream behaviour that never fired: the + * controller wrapped `Order::findByIdOrFail($id)` in a + * `catch (ModelNotFoundException)`, but core-api's findByIdOrFail() raised a + * BadMethodCallException that escaped the catch and surfaced as a 500. * - * The controller wraps `Order::findByIdOrFail($id)` in a - * `catch (ModelNotFoundException)` that returns 'No order found.'. Today that - * catch never fires: core-api's Model::findByIdOrFail() calls a - * getModelNotFoundException() method that does not exist on Eloquent's builder, - * so a missing order raises BadMethodCallException, escapes the catch, and - * surfaces as a 500 instead of the intended error response. - * - * core-api#231 (branch dev-v1.6.55) replaces that with - * `throw (new ModelNotFoundException())->setModel(static::class, [$identifier]);` - * which makes this branch live. The assertion below states the post-fix - * contract deliberately — asserting today's BadMethodCallException would - * codify the defect instead of the intent. - * - * If CI must be green before that release lands, neutralise this file with a - * single `->skip('pending fleetbase/core-api 1.6.55')` on the test below rather - * than changing what it asserts. + * nextActivity() now resolves the order through the controller's own + * company-scoped `findOrderById()` and returns the error response on a null + * result, so the branch is live here regardless of the upstream release: an + * unknown id and an id belonging to another company are reported identically. */ function fleetopsUpstreamNotFoundBoot(): Illuminate\Database\SQLiteConnection {