Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 153 additions & 31 deletions server/src/Http/Controllers/Internal/v1/OrderController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<int, File> $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'];
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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<int, Order>
*/
protected function ordersByUuid(array $ids)
{
return Order::whereIn('uuid', $ids)->get();
/** @var \Illuminate\Database\Eloquent\Collection<int, Order> $orders */
$orders = $this->scopedToCompany(Order::whereIn('uuid', $ids))->get();

return $orders;
}

protected function trackingStatusExists(?string $trackingNumberUuid, string $code): bool
Expand All @@ -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
Expand All @@ -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<int, string> $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
Expand All @@ -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(),
]);
Expand Down Expand Up @@ -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
Expand All @@ -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',
Expand Down Expand Up @@ -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.');
}

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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.');
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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],
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading
Loading