@fleetbase/fleetops-engine 0.6.68 → 0.6.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/RELEASE.md +11 -9
  2. package/addon/components/contact/form.hbs +13 -2
  3. package/addon/components/customer/details.hbs +17 -17
  4. package/addon/components/customer/form.hbs +13 -2
  5. package/addon/components/customer/form.js +0 -56
  6. package/addon/components/driver/details.hbs +13 -19
  7. package/addon/components/driver/form.hbs +20 -35
  8. package/addon/components/driver/form.js +0 -58
  9. package/addon/components/modals/driver-form.hbs +2 -2
  10. package/addon/components/modals/reset-profile-credentials.hbs +23 -0
  11. package/addon/components/modals/reset-profile-credentials.js +70 -0
  12. package/addon/components/profile-identity-hint.hbs +13 -0
  13. package/addon/components/profile-identity-hint.js +49 -0
  14. package/addon/controllers/management/drivers/index.js +4 -0
  15. package/addon/extension.js +4 -2
  16. package/addon/services/contact-actions.js +50 -89
  17. package/addon/services/driver-actions.js +98 -0
  18. package/addon/utils/profile-login-actions.js +172 -0
  19. package/app/components/modals/{reset-customer-credentials.js → reset-profile-credentials.js} +1 -1
  20. package/app/components/profile-identity-hint.js +1 -0
  21. package/composer.json +1 -1
  22. package/extension.json +1 -1
  23. package/package.json +3 -3
  24. package/scripts/pest-bootstrap.php +12 -0
  25. package/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php +132 -0
  26. package/server/resources/views/mail/driver-credentials.blade.php +21 -0
  27. package/server/src/Console/Commands/FixCustomerCompanies.php +1 -1
  28. package/server/src/Console/Commands/FixDriverCompanies.php +1 -1
  29. package/server/src/Exceptions/ProfileIdentityConflictException.php +34 -0
  30. package/server/src/Http/Controllers/Api/v1/CustomerController.php +14 -0
  31. package/server/src/Http/Controllers/Api/v1/DriverController.php +56 -39
  32. package/server/src/Http/Controllers/Internal/v1/ContactController.php +5 -17
  33. package/server/src/Http/Controllers/Internal/v1/CustomerController.php +40 -4
  34. package/server/src/Http/Controllers/Internal/v1/DriverController.php +182 -162
  35. package/server/src/Http/Controllers/Internal/v1/FleetOpsLookupController.php +47 -0
  36. package/server/src/Http/Controllers/Internal/v1/OrderController.php +156 -27
  37. package/server/src/Http/Requests/CreateDriverRequest.php +6 -2
  38. package/server/src/Http/Requests/Internal/CreateDriverRequest.php +9 -18
  39. package/server/src/Http/Resources/v1/Contact.php +3 -0
  40. package/server/src/Http/Resources/v1/Driver.php +3 -0
  41. package/server/src/Mail/DriverCredentialsMail.php +55 -0
  42. package/server/src/Models/Contact.php +55 -90
  43. package/server/src/Models/Sensor.php +3 -5
  44. package/server/src/Observers/ContactObserver.php +2 -22
  45. package/server/src/Observers/DriverObserver.php +5 -6
  46. package/server/src/Providers/FleetOpsServiceProvider.php +15 -0
  47. package/server/src/Support/Ai/Capabilities/AbstractFleetOpsAICapability.php +62 -5
  48. package/server/src/Support/Ai/Capabilities/AssetStatusCapability.php +24 -5
  49. package/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php +6 -2
  50. package/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php +40 -7
  51. package/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php +86 -24
  52. package/server/src/Support/Ai/FleetOpsAiConsoleCommands.php +122 -0
  53. package/server/src/Support/Ai/Tools/CreateOrderTool.php +86 -0
  54. package/server/src/Support/Ai/Tools/ImportOrdersTool.php +53 -0
  55. package/server/src/Support/Ai/Tools/OptimizeOrderRouteTool.php +70 -0
  56. package/server/src/Support/Ai/Tools/SearchResourcesTool.php +73 -0
  57. package/server/src/Support/ProfileAccountManager.php +405 -0
  58. package/server/src/routes.php +5 -0
  59. package/server/tests/AiOperationalQueryCapabilityTest.php +1 -0
  60. package/server/tests/ApiCustomerControllerContractsTest.php +21 -0
  61. package/server/tests/ApiDriverControllerContractsTest.php +102 -20
  62. package/server/tests/CommandContractsTest.php +4 -4
  63. package/server/tests/ControllerHelperContractsTest.php +3 -11
  64. package/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +5 -0
  65. package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +36 -0
  66. package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +19 -6
  67. package/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php +201 -0
  68. package/server/tests/Feature/Http/Internal/ContactControllerEndpointsTest.php +6 -4
  69. package/server/tests/Feature/Http/Internal/CustomerControllerCredentialsTest.php +6 -2
  70. package/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +23 -0
  71. package/server/tests/Feature/Http/Internal/DriverControllerCreateRecordTest.php +42 -17
  72. package/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php +168 -277
  73. package/server/tests/Feature/Http/Internal/DriverControllerLoginManagementTest.php +716 -0
  74. package/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php +10 -3
  75. package/server/tests/Feature/Http/Internal/OrderControllerImportFromFilesTest.php +18 -0
  76. package/server/tests/Feature/Http/Internal/OrderControllerTenantScopingTest.php +411 -0
  77. package/server/tests/Feature/Http/Internal/OrderControllerUpstreamNotFoundTest.php +8 -20
  78. package/server/tests/InternalCustomerControllerContractsTest.php +32 -1
  79. package/server/tests/ModelAccessorContractsTest.php +82 -20
  80. package/server/tests/ObserverContractsTest.php +27 -2
  81. package/server/tests/RequestContractsTest.php +19 -11
  82. package/server/tests/SupportJobAndAiCoverageTest.php +3 -3
  83. package/server/tests/TelematicControllerContractsTest.php +22 -4
  84. package/server/tests/Unit/Models/ContactCustomerUserTest.php +90 -14
  85. package/server/tests/Unit/Models/ContactTest.php +48 -5
  86. package/server/tests/Unit/Models/SensorTest.php +2 -2
  87. package/server/tests/Unit/Models/SmallModelSurfacesTest.php +31 -32
  88. package/server/tests/Unit/Observers/ObserverListenerSeamsTest.php +7 -2
  89. package/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php +15 -2
  90. package/server/tests/Unit/Support/Ai/AssetStatusCapabilityQueriesTest.php +42 -0
  91. package/server/tests/Unit/Support/Ai/Capabilities/OrderInsightsCapabilityTest.php +19 -1
  92. package/server/tests/Unit/Support/Ai/Capabilities/SearchResourcesCapabilityTest.php +85 -1
  93. package/server/tests/Unit/Support/Ai/FleetOpsAiConsoleCommandsTest.php +31 -0
  94. package/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php +246 -0
  95. package/server/tests/Unit/Support/AiCapabilityPermissionsTest.php +3 -3
  96. package/server/tests/Unit/Support/ProfileAccountManagerTest.php +643 -0
  97. package/translations/en-us.yaml +37 -2
  98. package/translations/uk-ua.yaml +3475 -0
  99. package/addon/components/modals/reset-customer-credentials.hbs +0 -18
  100. package/addon/components/modals/reset-customer-credentials.js +0 -55
@@ -298,7 +298,7 @@ class FleetOpsInternalOrderLifecycleControllerProbe extends OrderController
298
298
  return $this->order;
299
299
  }
300
300
 
301
- protected function findOrderById(string $id, array $with = []): ?Order
301
+ protected function findOrderById(mixed $id, array $with = []): ?Order
302
302
  {
303
303
  $this->order?->setAttribute('lookup_id', $id);
304
304
  $this->order?->setAttribute('lookup_with', $with);
@@ -990,10 +990,15 @@ test('internal order controller bulk dispatch only dispatches created uncanceled
990
990
  });
991
991
 
992
992
  test('internal order controller bulk assign driver deduplicates orders and queues notifications', function () {
993
- $controller = fleetopsInternalOrderLifecycleController();
994
993
  $driverUuid = '11111111-1111-4111-8111-111111111111';
995
994
  $orderA = '22222222-2222-4222-8222-222222222222';
996
995
  $orderB = '33333333-3333-4333-8333-333333333333';
996
+ // The ids are resolved through the company-scoped `ordersByUuid()` lookup, so
997
+ // only orders it returns are assigned, counted and notified.
998
+ $controller = fleetopsInternalOrderLifecycleController([
999
+ fleetopsInternalOrderLifecycleOrder($orderA),
1000
+ fleetopsInternalOrderLifecycleOrder($orderB),
1001
+ ]);
997
1002
 
998
1003
  $response = $controller->bulkAssignDriver(fleetopsBulkActionRequest([
999
1004
  'ids' => [$orderA, $orderA, $orderB],
@@ -1011,7 +1016,9 @@ test('internal order controller bulk assign driver deduplicates orders and queue
1011
1016
  ->and($controller->assignedDriverUuid)->toBe($driverUuid)
1012
1017
  ->and($controller->bulkNotification)->toBe([[$orderA, $orderB], $driverUuid]);
1013
1018
 
1014
- $controller = fleetopsInternalOrderLifecycleController();
1019
+ $controller = fleetopsInternalOrderLifecycleController([
1020
+ fleetopsInternalOrderLifecycleOrder($orderA),
1021
+ ]);
1015
1022
  $controller->bulkAssignDriver(fleetopsBulkActionRequest([
1016
1023
  'ids' => [$orderA],
1017
1024
  'driver' => $driverUuid,
@@ -177,3 +177,21 @@ test('invalid file types and unreadable spreadsheets return errors', function ()
177
177
  ]));
178
178
  expect($unreadable->getStatusCode())->toBeGreaterThanOrEqual(400);
179
179
  });
180
+
181
+ test('import ignores files uploaded by another company', function () {
182
+ $connection = fleetopsOrderImportFilesBoot();
183
+ $connection->table('files')->insert(['uuid' => '33333333-3333-4333-8333-333333333333', 'public_id' => 'file_ordimport4', 'company_uuid' => 'company-2', 'path' => 'uploads/orders.xlsx', 'disk' => 'local']);
184
+ fleetopsOrderImportFilesExcelFake([[
185
+ ['name' => 'Victim Stop', 'street1' => 'Victim Rd 1', 'city' => 'Singapore'],
186
+ ]]);
187
+
188
+ // The file uuid is caller-supplied and nothing upstream checks who owns it,
189
+ // so an unscoped lookup here would read a rival company's spreadsheet.
190
+ $response = (new OrderController())->importFromFiles(fleetopsOrderImportFilesRequest([
191
+ 'files' => ['33333333-3333-4333-8333-333333333333'],
192
+ ]));
193
+
194
+ $data = $response->getData(true);
195
+ expect($data['places'])->toHaveCount(0)
196
+ ->and($data['entities'])->toHaveCount(0);
197
+ });
@@ -0,0 +1,411 @@
1
+ <?php
2
+
3
+ if (!function_exists('Fleetbase\Support\session')) {
4
+ eval('namespace Fleetbase\Support; function session($key = null, $default = null) { if ($key === null) { return new class { public function has($k) { return \session($k) !== null; } public function get($k, $d = null) { return \session($k, $d); } }; } return \session($key, $default); }');
5
+ }
6
+
7
+ if (!function_exists('Fleetbase\Support\auth')) {
8
+ eval('namespace Fleetbase\Support; function auth() { return new class { public function user() { return null; } public function id() { return null; } }; }');
9
+ }
10
+
11
+ use Fleetbase\FleetOps\Http\Controllers\Internal\v1\OrderController;
12
+ use Fleetbase\FleetOps\Http\Requests\BulkDispatchRequest;
13
+ use Fleetbase\FleetOps\Http\Requests\CancelOrderRequest;
14
+ use Fleetbase\FleetOps\Models\Driver;
15
+ use Fleetbase\FleetOps\Models\Order;
16
+ use Illuminate\Database\ConnectionResolver;
17
+ use Illuminate\Database\Eloquent\Model as EloquentModel;
18
+ use Illuminate\Database\Eloquent\ModelNotFoundException;
19
+ use Illuminate\Database\SQLiteConnection;
20
+ use Illuminate\Http\Request;
21
+
22
+ /**
23
+ * Covers tenant isolation on the internal OrderController.
24
+ *
25
+ * Every order-lifecycle action here takes its target as a caller-supplied
26
+ * identifier in the request body or query string rather than as a bound route
27
+ * parameter, and `fleetbase.protected` only checks that the caller holds the
28
+ * named RBAC capability — never which company the record belongs to. Nothing
29
+ * upstream narrows these queries, so each lookup carries the company
30
+ * constraint itself, and these tests pin that: an identifier belonging to
31
+ * another organization must resolve to nothing, and a request without a
32
+ * company session must fail closed rather than query every tenant at once.
33
+ *
34
+ * `company-1` is the caller throughout; `company-2` is the victim tenant.
35
+ */
36
+ class FleetOpsInternalOrderTenantScopeProbe extends OrderController
37
+ {
38
+ public function callHelper(string $method, ...$arguments): mixed
39
+ {
40
+ return $this->{$method}(...$arguments);
41
+ }
42
+ }
43
+
44
+ const FLEETOPS_TENANT_OWN_ORDER = '55555555-5555-4555-8555-555555555501';
45
+ const FLEETOPS_TENANT_VICTIM_ORDER = '55555555-5555-4555-8555-555555555502';
46
+ const FLEETOPS_TENANT_OWN_DRIVER = '55555555-5555-4555-8555-555555555511';
47
+ const FLEETOPS_TENANT_VICTIM_DRIVER = '55555555-5555-4555-8555-555555555512';
48
+
49
+ function fleetopsOrderTenantBoot(): SQLiteConnection
50
+ {
51
+ $connection = new SQLiteConnection(new PDO('sqlite::memory:'));
52
+ $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
53
+ $resolver->setDefaultConnection('mysql');
54
+ EloquentModel::setConnectionResolver($resolver);
55
+ app()->instance('db', new class($connection) {
56
+ public function __construct(public SQLiteConnection $c)
57
+ {
58
+ }
59
+
60
+ public function connection($name = null): SQLiteConnection
61
+ {
62
+ return $this->c;
63
+ }
64
+
65
+ public function __call($method, $arguments)
66
+ {
67
+ return $this->c->{$method}(...$arguments);
68
+ }
69
+ });
70
+ Illuminate\Support\Facades\DB::clearResolvedInstance('db');
71
+
72
+ $schema = $connection->getSchemaBuilder();
73
+ $tables = [
74
+ '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'],
75
+ 'payloads' => ['uuid', 'public_id', 'company_uuid', 'pickup_uuid', 'dropoff_uuid', 'current_waypoint_uuid', 'meta', 'type'],
76
+ 'places' => ['uuid', 'public_id', 'company_uuid', 'name', 'location'],
77
+ 'waypoints' => ['uuid', 'public_id', 'company_uuid', 'payload_uuid', 'place_uuid', 'tracking_number_uuid', 'order', 'type'],
78
+ 'entities' => ['uuid', 'public_id', 'company_uuid', 'payload_uuid', 'destination_uuid', 'tracking_number_uuid', 'name', 'type'],
79
+ 'drivers' => ['uuid', 'public_id', 'company_uuid', 'user_uuid', 'vehicle_uuid', 'status', 'online', 'location', 'current_job_uuid'],
80
+ 'users' => ['uuid', 'public_id', 'company_uuid', 'name', 'status', 'type'],
81
+ 'tracking_numbers' => ['uuid', 'public_id', 'company_uuid', 'tracking_number', 'owner_uuid', 'owner_type', '_key'],
82
+ 'tracking_statuses' => ['uuid', 'public_id', 'company_uuid', 'tracking_number_uuid', 'status', 'details', 'code', '_key'],
83
+ 'proofs' => ['uuid', 'public_id', 'company_uuid', 'order_uuid', 'subject_uuid', 'subject_type', 'file_uuid', 'remarks', 'raw_data', 'data'],
84
+ 'companies' => ['uuid', 'public_id', 'name', 'country'],
85
+ ];
86
+ foreach ($tables as $table => $columns) {
87
+ $schema->create($table, function ($blueprint) use ($columns) {
88
+ $blueprint->increments('id');
89
+ foreach ($columns as $column) {
90
+ $blueprint->string($column)->nullable();
91
+ }
92
+ $blueprint->timestamps();
93
+ $blueprint->timestamp('deleted_at')->nullable();
94
+ });
95
+ }
96
+
97
+ session(['company' => 'company-1']);
98
+
99
+ return $connection;
100
+ }
101
+
102
+ /**
103
+ * Seeds one order/driver/payload per tenant. The two tenants' records are
104
+ * identical apart from `company_uuid`, so any lookup that resolves the
105
+ * `company-2` identifier is crossing the tenant boundary.
106
+ */
107
+ function fleetopsOrderTenantSeed(SQLiteConnection $connection): void
108
+ {
109
+ $connection->table('users')->insert([
110
+ ['uuid' => 'user-own', 'company_uuid' => 'company-1', 'name' => 'Own Driver'],
111
+ ['uuid' => 'user-victim', 'company_uuid' => 'company-2', 'name' => 'Victim Driver'],
112
+ ]);
113
+ $connection->table('drivers')->insert([
114
+ ['uuid' => FLEETOPS_TENANT_OWN_DRIVER, 'public_id' => 'driver_own1', 'company_uuid' => 'company-1', 'user_uuid' => 'user-own'],
115
+ ['uuid' => FLEETOPS_TENANT_VICTIM_DRIVER, 'public_id' => 'driver_victim1', 'company_uuid' => 'company-2', 'user_uuid' => 'user-victim'],
116
+ ]);
117
+ $connection->table('payloads')->insert([
118
+ ['uuid' => 'payload-own', 'company_uuid' => 'company-1'],
119
+ ['uuid' => 'payload-victim', 'company_uuid' => 'company-2'],
120
+ ]);
121
+ $connection->table('tracking_numbers')->insert([
122
+ ['uuid' => 'tn-own', 'company_uuid' => 'company-1', 'tracking_number' => 'FLB-OWN-1'],
123
+ ['uuid' => 'tn-victim', 'company_uuid' => 'company-2', 'tracking_number' => 'FLB-VICTIM-1'],
124
+ ]);
125
+ $connection->table('orders')->insert([
126
+ [
127
+ 'uuid' => FLEETOPS_TENANT_OWN_ORDER,
128
+ 'public_id' => 'order_own1',
129
+ 'company_uuid' => 'company-1',
130
+ 'payload_uuid' => 'payload-own',
131
+ 'tracking_number_uuid' => 'tn-own',
132
+ 'driver_assigned_uuid' => FLEETOPS_TENANT_OWN_DRIVER,
133
+ 'status' => 'created',
134
+ 'type' => 'transport',
135
+ ],
136
+ [
137
+ 'uuid' => FLEETOPS_TENANT_VICTIM_ORDER,
138
+ 'public_id' => 'order_victim1',
139
+ 'company_uuid' => 'company-2',
140
+ 'payload_uuid' => 'payload-victim',
141
+ 'tracking_number_uuid' => 'tn-victim',
142
+ 'driver_assigned_uuid' => FLEETOPS_TENANT_VICTIM_DRIVER,
143
+ 'status' => 'created',
144
+ 'type' => 'transport',
145
+ ],
146
+ ]);
147
+ $connection->table('entities')->insert([
148
+ ['uuid' => 'entity-own', 'company_uuid' => 'company-1', 'payload_uuid' => 'payload-own', 'name' => 'Own Parcel'],
149
+ ['uuid' => 'entity-victim', 'company_uuid' => 'company-2', 'payload_uuid' => 'payload-victim', 'name' => 'Victim Parcel'],
150
+ ]);
151
+ $connection->table('proofs')->insert([
152
+ ['uuid' => 'proof-own', 'public_id' => 'proof_own1', 'company_uuid' => 'company-1', 'order_uuid' => FLEETOPS_TENANT_OWN_ORDER, 'subject_uuid' => FLEETOPS_TENANT_OWN_ORDER],
153
+ ['uuid' => 'proof-victim', 'public_id' => 'proof_victim1', 'company_uuid' => 'company-2', 'order_uuid' => FLEETOPS_TENANT_VICTIM_ORDER, 'subject_uuid' => FLEETOPS_TENANT_VICTIM_ORDER],
154
+ ]);
155
+ }
156
+
157
+ test('order lookups resolve the callers own records', function () {
158
+ $connection = fleetopsOrderTenantBoot();
159
+ fleetopsOrderTenantSeed($connection);
160
+ $probe = new FleetOpsInternalOrderTenantScopeProbe();
161
+
162
+ expect($probe->callHelper('ordersByUuid', [FLEETOPS_TENANT_OWN_ORDER])->pluck('uuid')->all())->toBe([FLEETOPS_TENANT_OWN_ORDER])
163
+ ->and($probe->callHelper('findOrderByUuid', FLEETOPS_TENANT_OWN_ORDER)?->public_id)->toBe('order_own1')
164
+ ->and($probe->callHelper('findOrderById', FLEETOPS_TENANT_OWN_ORDER)?->public_id)->toBe('order_own1')
165
+ ->and($probe->callHelper('findOrderById', 'order_own1')?->uuid)->toBe(FLEETOPS_TENANT_OWN_ORDER)
166
+ ->and($probe->callHelper('findOrderRouteForEdit', FLEETOPS_TENANT_OWN_ORDER)?->uuid)->toBe(FLEETOPS_TENANT_OWN_ORDER)
167
+ ->and($probe->callHelper('findOrderForStart', FLEETOPS_TENANT_OWN_ORDER)?->uuid)->toBe(FLEETOPS_TENANT_OWN_ORDER)
168
+ ->and($probe->callHelper('findPayloadForStart', 'payload-own')?->uuid)->toBe('payload-own')
169
+ ->and($probe->callHelper('findOrderForSchedule', 'order_own1')?->uuid)->toBe(FLEETOPS_TENANT_OWN_ORDER)
170
+ ->and($probe->callHelper('findOrderForProofs', FLEETOPS_TENANT_OWN_ORDER)?->uuid)->toBe(FLEETOPS_TENANT_OWN_ORDER)
171
+ ->and($probe->callHelper('findOrderForDriverPing', 'order_own1')?->uuid)->toBe(FLEETOPS_TENANT_OWN_ORDER)
172
+ ->and($probe->callHelper('findEntityProofSubject', 'entity-own')?->uuid)->toBe('entity-own')
173
+ ->and($probe->callHelper('resolveProof', 'proof_own1')?->uuid)->toBe('proof-own')
174
+ ->and($probe->callHelper('resolveProof', 'proof-own')?->uuid)->toBe('proof-own');
175
+ });
176
+
177
+ test('driver lookups resolve the callers own drivers', function () {
178
+ $connection = fleetopsOrderTenantBoot();
179
+ fleetopsOrderTenantSeed($connection);
180
+ $probe = new FleetOpsInternalOrderTenantScopeProbe();
181
+
182
+ expect($probe->callHelper('findDriverByUuid', FLEETOPS_TENANT_OWN_DRIVER)?->public_id)->toBe('driver_own1')
183
+ ->and($probe->callHelper('findDriverForStart', FLEETOPS_TENANT_OWN_DRIVER)?->public_id)->toBe('driver_own1')
184
+ ->and($probe->callHelper('findDriverForSchedule', FLEETOPS_TENANT_OWN_DRIVER)?->public_id)->toBe('driver_own1')
185
+ ->and($probe->callHelper('findDriverForSchedule', 'driver_own1')?->uuid)->toBe(FLEETOPS_TENANT_OWN_DRIVER);
186
+ });
187
+
188
+ test('order lookups refuse identifiers belonging to another company', function () {
189
+ $connection = fleetopsOrderTenantBoot();
190
+ fleetopsOrderTenantSeed($connection);
191
+ $probe = new FleetOpsInternalOrderTenantScopeProbe();
192
+
193
+ // Both identifier arms are covered: an unguarded
194
+ // `where(uuid)->orWhere(public_id)->where(company_uuid)` chain would read as
195
+ // `uuid = ? OR (public_id = ? AND company_uuid = ?)` and still resolve the
196
+ // victim by uuid.
197
+ expect($probe->callHelper('ordersByUuid', [FLEETOPS_TENANT_VICTIM_ORDER]))->toHaveCount(0)
198
+ ->and($probe->callHelper('findOrderByUuid', FLEETOPS_TENANT_VICTIM_ORDER))->toBeNull()
199
+ ->and($probe->callHelper('findOrderById', FLEETOPS_TENANT_VICTIM_ORDER))->toBeNull()
200
+ ->and($probe->callHelper('findOrderById', 'order_victim1'))->toBeNull()
201
+ ->and($probe->callHelper('findOrderRouteForEdit', FLEETOPS_TENANT_VICTIM_ORDER))->toBeNull()
202
+ ->and($probe->callHelper('findOrderForStart', FLEETOPS_TENANT_VICTIM_ORDER))->toBeNull()
203
+ ->and($probe->callHelper('findPayloadForStart', 'payload-victim'))->toBeNull()
204
+ ->and($probe->callHelper('findOrderForSchedule', 'order_victim1'))->toBeNull()
205
+ ->and($probe->callHelper('findOrderForProofs', FLEETOPS_TENANT_VICTIM_ORDER))->toBeNull()
206
+ ->and($probe->callHelper('findEntityProofSubject', 'entity-victim'))->toBeNull()
207
+ ->and($probe->callHelper('resolveProof', 'proof_victim1'))->toBeNull()
208
+ ->and($probe->callHelper('resolveProof', 'proof-victim'))->toBeNull()
209
+ ->and($probe->callHelper('findDriverByUuid', FLEETOPS_TENANT_VICTIM_DRIVER))->toBeNull()
210
+ ->and($probe->callHelper('findDriverForStart', FLEETOPS_TENANT_VICTIM_DRIVER))->toBeNull()
211
+ ->and($probe->callHelper('findDriverForSchedule', FLEETOPS_TENANT_VICTIM_DRIVER))->toBeNull()
212
+ ->and($probe->callHelper('findDriverForSchedule', 'driver_victim1'))->toBeNull();
213
+
214
+ // The ping lookup reports a miss the same way it reports an unknown id, so
215
+ // the endpoint cannot be used to probe which ids exist in other tenants.
216
+ expect(fn () => $probe->callHelper('findOrderForDriverPing', 'order_victim1'))
217
+ ->toThrow(ModelNotFoundException::class);
218
+ });
219
+
220
+ test('the public tracking lookup finds an order by its tracking number alone', function () {
221
+ $connection = fleetopsOrderTenantBoot();
222
+ fleetopsOrderTenantSeed($connection);
223
+ $probe = new FleetOpsInternalOrderTenantScopeProbe();
224
+
225
+ // `fleet-ops/lookup` backs the public Track Order page: recipients have no session
226
+ // and the tracking number is the credential. It must work without a company, and
227
+ // for whichever company owns the order.
228
+ session(['company' => null]);
229
+
230
+ expect($probe->callHelper('findOrderByTrackingNumber', 'FLB-OWN-1')?->uuid)->toBe(FLEETOPS_TENANT_OWN_ORDER)
231
+ ->and($probe->callHelper('findOrderByTrackingNumber', 'FLB-VICTIM-1')?->uuid)->toBe(FLEETOPS_TENANT_VICTIM_ORDER)
232
+ ->and($probe->callHelper('findOrderByTrackingNumber', 'FLB-UNKNOWN'))->toBeNull();
233
+
234
+ session(['company' => 'company-1']);
235
+
236
+ expect($probe->callHelper('findOrderByTrackingNumber', 'FLB-VICTIM-1')?->uuid)->toBe(FLEETOPS_TENANT_VICTIM_ORDER);
237
+ });
238
+
239
+ test('order lookups fail closed when no company session is present', function () {
240
+ $connection = fleetopsOrderTenantBoot();
241
+ fleetopsOrderTenantSeed($connection);
242
+ session(['company' => null]);
243
+ $probe = new FleetOpsInternalOrderTenantScopeProbe();
244
+
245
+ expect($probe->callHelper('ordersByUuid', [FLEETOPS_TENANT_OWN_ORDER, FLEETOPS_TENANT_VICTIM_ORDER]))->toHaveCount(0)
246
+ ->and($probe->callHelper('findOrderByUuid', FLEETOPS_TENANT_OWN_ORDER))->toBeNull()
247
+ ->and($probe->callHelper('findOrderById', 'order_own1'))->toBeNull()
248
+ ->and($probe->callHelper('findDriverByUuid', FLEETOPS_TENANT_OWN_DRIVER))->toBeNull()
249
+ ->and($probe->callHelper('findDriverForSchedule', 'driver_own1'))->toBeNull();
250
+ });
251
+
252
+ test('order resolution by id rejects empty identifiers without querying', function () {
253
+ $connection = fleetopsOrderTenantBoot();
254
+ fleetopsOrderTenantSeed($connection);
255
+ $probe = new FleetOpsInternalOrderTenantScopeProbe();
256
+
257
+ // The identifier is raw request input, so a non-string body value has to
258
+ // resolve to no order rather than raising out of the endpoint.
259
+ expect($probe->callHelper('findOrderById', null))->toBeNull()
260
+ ->and($probe->callHelper('findOrderById', ''))->toBeNull()
261
+ ->and($probe->callHelper('findOrderById', ['uuid' => FLEETOPS_TENANT_OWN_ORDER]))->toBeNull()
262
+ ->and($probe->callHelper('findOrderById', 42))->toBeNull()
263
+ ->and($probe->callHelper('findOrderForSchedule', null))->toBeNull();
264
+ });
265
+
266
+ test('bulk driver assignment only touches orders the caller owns', function () {
267
+ $connection = fleetopsOrderTenantBoot();
268
+ fleetopsOrderTenantSeed($connection);
269
+ $connection->table('orders')->update(['driver_assigned_uuid' => null]);
270
+ $controller = new OrderController();
271
+
272
+ $response = $controller->bulkAssignDriver(Request::create('/x', 'PATCH', [
273
+ 'ids' => [FLEETOPS_TENANT_OWN_ORDER, FLEETOPS_TENANT_VICTIM_ORDER],
274
+ 'driver' => FLEETOPS_TENANT_OWN_DRIVER,
275
+ 'silent' => true,
276
+ ]));
277
+
278
+ // The victim order is dropped before the update, so it is neither
279
+ // reassigned nor counted in the response.
280
+ expect($response->getData(true)['count'])->toBe(1)
281
+ ->and($connection->table('orders')->where('uuid', FLEETOPS_TENANT_OWN_ORDER)->value('driver_assigned_uuid'))->toBe(FLEETOPS_TENANT_OWN_DRIVER)
282
+ ->and($connection->table('orders')->where('uuid', FLEETOPS_TENANT_VICTIM_ORDER)->value('driver_assigned_uuid'))->toBeNull();
283
+ });
284
+
285
+ test('bulk driver assignment refuses a driver from another company', function () {
286
+ $connection = fleetopsOrderTenantBoot();
287
+ fleetopsOrderTenantSeed($connection);
288
+ $controller = new OrderController();
289
+
290
+ $response = $controller->bulkAssignDriver(Request::create('/x', 'PATCH', [
291
+ 'ids' => [FLEETOPS_TENANT_OWN_ORDER],
292
+ 'driver' => FLEETOPS_TENANT_VICTIM_DRIVER,
293
+ 'silent' => true,
294
+ ]));
295
+
296
+ expect($response->getData(true)['error'] ?? '')->toContain('Invalid driver selected');
297
+ });
298
+
299
+ test('the bulk assignment update is itself scoped to the callers company', function () {
300
+ $connection = fleetopsOrderTenantBoot();
301
+ fleetopsOrderTenantSeed($connection);
302
+ $connection->table('orders')->update(['driver_assigned_uuid' => null]);
303
+ $probe = new FleetOpsInternalOrderTenantScopeProbe();
304
+ $driver = Driver::where('uuid', FLEETOPS_TENANT_OWN_DRIVER)->first();
305
+
306
+ // Called directly with a victim uuid, standing in for any future caller
307
+ // that reaches this seam without pre-filtering the ids.
308
+ $probe->callHelper('assignDriverToOrders', [FLEETOPS_TENANT_OWN_ORDER, FLEETOPS_TENANT_VICTIM_ORDER], $driver);
309
+
310
+ expect($connection->table('orders')->where('uuid', FLEETOPS_TENANT_OWN_ORDER)->value('driver_assigned_uuid'))->toBe(FLEETOPS_TENANT_OWN_DRIVER)
311
+ ->and($connection->table('orders')->where('uuid', FLEETOPS_TENANT_VICTIM_ORDER)->value('driver_assigned_uuid'))->toBeNull();
312
+ });
313
+
314
+ test('bulk cancel and bulk dispatch skip orders from another company', function () {
315
+ $connection = fleetopsOrderTenantBoot();
316
+ fleetopsOrderTenantSeed($connection);
317
+ $controller = new OrderController();
318
+
319
+ $canceled = $controller->bulkCancel(Request::create('/x', 'PATCH', [
320
+ 'ids' => [FLEETOPS_TENANT_VICTIM_ORDER],
321
+ ]));
322
+ expect($canceled->getData(true)['count'])->toBe(0)
323
+ ->and($connection->table('orders')->where('uuid', FLEETOPS_TENANT_VICTIM_ORDER)->value('status'))->toBe('created');
324
+
325
+ $dispatched = $controller->bulkDispatch(BulkDispatchRequest::create('/x', 'POST', [
326
+ 'ids' => [FLEETOPS_TENANT_VICTIM_ORDER],
327
+ ]));
328
+ expect($dispatched->getData(true)['count'])->toBe(0)
329
+ ->and($connection->table('orders')->where('uuid', FLEETOPS_TENANT_VICTIM_ORDER)->value('dispatched'))->toBeNull();
330
+ });
331
+
332
+ test('cancel rejects a known order uuid that belongs to another company', function () {
333
+ $connection = fleetopsOrderTenantBoot();
334
+ fleetopsOrderTenantSeed($connection);
335
+
336
+ // `exists:orders,uuid` on CancelOrderRequest is a global existence check, so
337
+ // this uuid passes validation and the controller itself has to refuse it.
338
+ $response = (new OrderController())->cancel(
339
+ CancelOrderRequest::create('/x', 'PATCH', ['order' => FLEETOPS_TENANT_VICTIM_ORDER])
340
+ );
341
+
342
+ expect($response->getData(true)['error'] ?? '')->toContain('No order found to cancel')
343
+ ->and($connection->table('orders')->where('uuid', FLEETOPS_TENANT_VICTIM_ORDER)->value('status'))->toBe('created');
344
+ });
345
+
346
+ test('dispatch start and schedule refuse another companys order', function () {
347
+ $connection = fleetopsOrderTenantBoot();
348
+ fleetopsOrderTenantSeed($connection);
349
+ $controller = new OrderController();
350
+
351
+ $dispatched = $controller->dispatchOrder(Request::create('/x', 'PATCH', ['order' => FLEETOPS_TENANT_VICTIM_ORDER]));
352
+ expect($dispatched->getData(true)['error'] ?? '')->toContain('No order found to dispatch');
353
+
354
+ $started = $controller->start(Request::create('/x', 'PATCH', ['order' => FLEETOPS_TENANT_VICTIM_ORDER]));
355
+ expect($started->getData(true)['error'] ?? '')->toContain('Unable to find order to start');
356
+
357
+ $scheduled = $controller->scheduleOrder(Request::create('/x', 'PATCH', [
358
+ 'order' => FLEETOPS_TENANT_VICTIM_ORDER,
359
+ 'scheduled_at' => '2026-01-01 09:00:00',
360
+ ]));
361
+ expect($scheduled->getData(true)['error'] ?? '')->toContain('No order found to schedule')
362
+ ->and($connection->table('orders')->where('uuid', FLEETOPS_TENANT_VICTIM_ORDER)->value('scheduled_at'))->toBeNull()
363
+ ->and($connection->table('drivers')->where('uuid', FLEETOPS_TENANT_VICTIM_DRIVER)->value('current_job_uuid'))->toBeNull();
364
+ });
365
+
366
+ test('schedule ignores a driver from another company', function () {
367
+ $connection = fleetopsOrderTenantBoot();
368
+ fleetopsOrderTenantSeed($connection);
369
+ $connection->table('orders')->where('uuid', FLEETOPS_TENANT_OWN_ORDER)->update(['driver_assigned_uuid' => null]);
370
+
371
+ $response = (new OrderController())->scheduleOrder(Request::create('/x', 'PATCH', [
372
+ 'order' => FLEETOPS_TENANT_OWN_ORDER,
373
+ 'driver_id' => 'driver_victim1',
374
+ ]));
375
+
376
+ expect($response->getData(true)['status'])->toBe('OK')
377
+ ->and($connection->table('orders')->where('uuid', FLEETOPS_TENANT_OWN_ORDER)->value('driver_assigned_uuid'))->toBeNull();
378
+ });
379
+
380
+ test('activity destination and next-activity endpoints refuse another companys order', function () {
381
+ $connection = fleetopsOrderTenantBoot();
382
+ fleetopsOrderTenantSeed($connection);
383
+ $controller = new OrderController();
384
+
385
+ $nextActivity = $controller->nextActivity('order_victim1', Request::create('/x', 'GET'));
386
+ expect($nextActivity->getData(true))->toBe(['error' => 'No order found.']);
387
+
388
+ $updateActivity = $controller->updateActivity(FLEETOPS_TENANT_VICTIM_ORDER, Request::create('/x', 'PATCH', ['activity' => []]));
389
+ expect($updateActivity->getData(true))->toBe(['error' => 'No order found.']);
390
+
391
+ $setDestination = $controller->setDestination(FLEETOPS_TENANT_VICTIM_ORDER, 'place-victim');
392
+ expect($setDestination->getData(true))->toBe(['error' => 'No order found.']);
393
+
394
+ $trackerInfo = $controller->trackerInfo(Request::create('/x', 'GET'), FLEETOPS_TENANT_VICTIM_ORDER);
395
+ expect($trackerInfo->getData(true))->toBe(['error' => 'No order found.']);
396
+
397
+ $waypointEtas = $controller->waypointEtas(Request::create('/x', 'GET'), FLEETOPS_TENANT_VICTIM_ORDER);
398
+ expect($waypointEtas->getData(true))->toBe(['error' => 'No order found.']);
399
+
400
+ $editRoute = $controller->editOrderRoute(FLEETOPS_TENANT_VICTIM_ORDER, Request::create('/x', 'PATCH'));
401
+ expect($editRoute->getData(true)['error'] ?? '')->toContain('Unable to find order to update route for');
402
+ });
403
+
404
+ test('proofs endpoint refuses another companys order', function () {
405
+ $connection = fleetopsOrderTenantBoot();
406
+ fleetopsOrderTenantSeed($connection);
407
+
408
+ $response = (new OrderController())->proofs(Request::create('/x', 'GET'), FLEETOPS_TENANT_VICTIM_ORDER);
409
+
410
+ expect($response->getData(true)['error'] ?? '')->toContain('Unable to retrieve proof');
411
+ });
@@ -6,27 +6,15 @@ use Illuminate\Http\Request;
6
6
  /**
7
7
  * Covers the not-found branch of Internal\v1\OrderController::nextActivity().
8
8
  *
9
- * ---------------------------------------------------------------------------
10
- * THIS FILE IS EXPECTED TO FAIL until fleetbase/core-api 1.6.55 is released and
11
- * pulled into server_vendor. Do not "fix" it by weakening the assertion.
12
- * ---------------------------------------------------------------------------
9
+ * This branch used to depend on upstream behaviour that never fired: the
10
+ * controller wrapped `Order::findByIdOrFail($id)` in a
11
+ * `catch (ModelNotFoundException)`, but core-api's findByIdOrFail() raised a
12
+ * BadMethodCallException that escaped the catch and surfaced as a 500.
13
13
  *
14
- * The controller wraps `Order::findByIdOrFail($id)` in a
15
- * `catch (ModelNotFoundException)` that returns 'No order found.'. Today that
16
- * catch never fires: core-api's Model::findByIdOrFail() calls a
17
- * getModelNotFoundException() method that does not exist on Eloquent's builder,
18
- * so a missing order raises BadMethodCallException, escapes the catch, and
19
- * surfaces as a 500 instead of the intended error response.
20
- *
21
- * core-api#231 (branch dev-v1.6.55) replaces that with
22
- * `throw (new ModelNotFoundException())->setModel(static::class, [$identifier]);`
23
- * which makes this branch live. The assertion below states the post-fix
24
- * contract deliberately — asserting today's BadMethodCallException would
25
- * codify the defect instead of the intent.
26
- *
27
- * If CI must be green before that release lands, neutralise this file with a
28
- * single `->skip('pending fleetbase/core-api 1.6.55')` on the test below rather
29
- * than changing what it asserts.
14
+ * nextActivity() now resolves the order through the controller's own
15
+ * company-scoped `findOrderById()` and returns the error response on a null
16
+ * result, so the branch is live here regardless of the upstream release: an
17
+ * unknown id and an id belonging to another company are reported identically.
30
18
  */
31
19
  function fleetopsUpstreamNotFoundBoot(): Illuminate\Database\SQLiteConnection
32
20
  {
@@ -122,11 +122,12 @@ function fleetopsInternalCustomer(): FleetOpsInternalCustomerContactFake
122
122
  return $customer;
123
123
  }
124
124
 
125
- function fleetopsInternalCustomerUser(string $status = 'active'): FleetOpsInternalCustomerUserFake
125
+ function fleetopsInternalCustomerUser(string $status = 'active', string $type = 'customer'): FleetOpsInternalCustomerUserFake
126
126
  {
127
127
  $user = new FleetOpsInternalCustomerUserFake();
128
128
  $user->setRawAttributes([
129
129
  'uuid' => 'user-uuid',
130
+ 'type' => $type,
130
131
  'public_id' => 'user_public',
131
132
  'name' => 'Ada Customer',
132
133
  'email' => 'ada@example.test',
@@ -236,3 +237,33 @@ test('internal customer controller validates reset credential error branches', f
236
237
  'password_confirmation' => 'one',
237
238
  ])))['error'])->toBe('Unable to reset customer credentials');
238
239
  });
240
+
241
+ test('internal customer controller refuses login management for staff-linked customers', function () {
242
+ $staff = fleetopsInternalCustomerUser('active', 'user');
243
+ $controller = fleetopsInternalCustomerController($staff);
244
+ $message = 'This customer signs in with a team member account. Manage this login in IAM.';
245
+ $request = new Request(['customer' => 'customer_public']);
246
+
247
+ $responses = [
248
+ $controller->createPortalLogin($request),
249
+ $controller->sendCredentials($request),
250
+ $controller->deactivatePortalLogin($request),
251
+ $controller->reactivatePortalLogin($request),
252
+ $controller->resetCredentials(new Request([
253
+ 'customer' => 'customer_public',
254
+ 'password' => 'chosen-secret',
255
+ 'password_confirmation' => 'chosen-secret',
256
+ ])),
257
+ ];
258
+
259
+ foreach ($responses as $response) {
260
+ expect($response->getStatusCode())->toBe(422)
261
+ ->and(fleetopsInternalCustomerJson($response)['error'])->toBe($message);
262
+ }
263
+
264
+ // The team member's account is never touched
265
+ expect($staff->passwords)->toBe([])
266
+ ->and($staff->activations)->toBe(0)
267
+ ->and($staff->deactivations)->toBe(0)
268
+ ->and($controller->sentCredentials)->toBe([]);
269
+ });