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 {