@fleetbase/fleetops-engine 0.6.59 → 0.6.61

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 (91) hide show
  1. package/RELEASE.md +25 -0
  2. package/addon/components/ai/route-preview-map.js +1 -5
  3. package/addon/components/customer/orders.js +2 -1
  4. package/addon/components/driver/card.hbs +49 -0
  5. package/addon/components/driver/card.js +30 -0
  6. package/addon/components/map/leaflet-live-map.js +4 -19
  7. package/addon/components/modals/place-details.hbs +1 -1
  8. package/addon/components/modals/point-map.hbs +1 -1
  9. package/addon/components/orchestrator-workbench.js +2 -1
  10. package/addon/components/order/form/route.js +3 -0
  11. package/addon/components/order-tracking-lookup.js +2 -1
  12. package/addon/components/place/details.hbs +1 -1
  13. package/addon/components/positions-replay.js +4 -1
  14. package/addon/components/service-area/details.hbs +1 -1
  15. package/addon/components/vehicle/details/schedules.hbs +2 -2
  16. package/addon/components/vehicle/details/schedules.js +7 -1
  17. package/addon/components/widget/live-fleet.js +7 -7
  18. package/addon/components/zone/details.hbs +1 -1
  19. package/addon/controllers/maintenance/schedules/index/edit.js +10 -1
  20. package/addon/controllers/management/drivers/index.js +22 -1
  21. package/addon/controllers/settings/map.js +16 -0
  22. package/addon/helpers/leaflet-tile-url.js +19 -0
  23. package/addon/services/map-settings.js +22 -0
  24. package/addon/services/vehicle-actions.js +4 -2
  25. package/addon/templates/management/drivers/index.hbs +67 -33
  26. package/addon/templates/settings/map.hbs +10 -0
  27. package/addon/utils/leaflet-tile-url.js +31 -0
  28. package/addon/utils/prepare-place-for-save.js +13 -0
  29. package/app/components/driver/card.js +1 -0
  30. package/app/helpers/leaflet-tile-url.js +1 -0
  31. package/app/utils/leaflet-tile-url.js +1 -0
  32. package/composer.json +1 -1
  33. package/extension.json +1 -1
  34. package/index.js +11 -0
  35. package/package.json +2 -1
  36. package/server/config/fleetops.php +44 -0
  37. package/server/config/geocoder.php +1 -1
  38. package/server/migrations/2026_08_08_000001_make_sku_and_provider_transaction_unique_indexes_soft_delete_aware.php +152 -0
  39. package/server/src/Http/Controllers/Api/v1/CustomerController.php +50 -4
  40. package/server/src/Http/Controllers/Api/v1/DriverController.php +277 -5
  41. package/server/src/Http/Controllers/Api/v1/GeofenceController.php +34 -2
  42. package/server/src/Http/Controllers/Api/v1/ManifestController.php +250 -0
  43. package/server/src/Http/Controllers/Api/v1/NavigatorController.php +14 -13
  44. package/server/src/Http/Controllers/Api/v1/TrackingNumberController.php +10 -1
  45. package/server/src/Http/Controllers/Api/v1/VehicleController.php +6 -0
  46. package/server/src/Http/Controllers/Concerns/ResolvesReviewAccountBypass.php +62 -0
  47. package/server/src/Http/Controllers/Internal/v1/DriverController.php +1 -1
  48. package/server/src/Http/Controllers/Internal/v1/SettingController.php +22 -0
  49. package/server/src/Http/Filter/FuelReportFilter.php +29 -0
  50. package/server/src/Http/Filter/IssueFilter.php +29 -0
  51. package/server/src/Http/Filter/MaintenanceScheduleFilter.php +40 -0
  52. package/server/src/Http/Requests/CreateCustomerRequest.php +9 -1
  53. package/server/src/Http/Requests/CreateFuelReportRequest.php +14 -3
  54. package/server/src/Http/Requests/CreateFuelTransactionRequest.php +64 -1
  55. package/server/src/Http/Requests/CreatePartRequest.php +39 -1
  56. package/server/src/Http/Requests/CreateVehicleRequest.php +10 -5
  57. package/server/src/Http/Resources/v1/Manifest.php +52 -0
  58. package/server/src/Http/Resources/v1/ManifestStop.php +47 -0
  59. package/server/src/Http/Resources/v1/OrderConfig.php +51 -4
  60. package/server/src/Http/Resources/v1/TrackingNumber.php +33 -0
  61. package/server/src/Models/FuelReport.php +21 -0
  62. package/server/src/Models/Place.php +1 -1
  63. package/server/src/Models/Sensor.php +24 -0
  64. package/server/src/Support/FuelProviders/FuelProviderService.php +10 -1
  65. package/server/src/routes.php +22 -1
  66. package/server/tests/ApiCustomerControllerContractsTest.php +168 -0
  67. package/server/tests/ApiDriverControllerContractsTest.php +65 -0
  68. package/server/tests/ApiDriverPasswordContractsTest.php +457 -0
  69. package/server/tests/ApiManifestControllerContractsTest.php +599 -0
  70. package/server/tests/ApiVehicleControllerContractsTest.php +35 -0
  71. package/server/tests/CompactResourceSerializationTest.php +99 -0
  72. package/server/tests/CustomerEndpointTest.php +4 -1
  73. package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +81 -2
  74. package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +61 -0
  75. package/server/tests/Feature/Http/Api/GeofenceControllerContractsTest.php +99 -2
  76. package/server/tests/Feature/Http/Api/TrackingNumberControllerHelpersTest.php +12 -6
  77. package/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +58 -0
  78. package/server/tests/Feature/Http/Internal/PlaceControllerTest.php +45 -0
  79. package/server/tests/FuelProviderTransactionControllerContractsTest.php +17 -0
  80. package/server/tests/RequestContractsTest.php +102 -5
  81. package/server/tests/SettingControllerContractsTest.php +33 -0
  82. package/server/tests/SmallControllerContractsTest.php +28 -4
  83. package/server/tests/Unit/Http/Filter/FuelReportFilterTest.php +135 -0
  84. package/server/tests/Unit/Http/Filter/IssueFilterTest.php +43 -0
  85. package/server/tests/Unit/Http/Filter/MaintenanceScheduleFilterTest.php +69 -0
  86. package/server/tests/Unit/Http/Requests/CreateFuelReportRequestTest.php +21 -0
  87. package/server/tests/Unit/Http/Resources/ManifestResourceTest.php +129 -0
  88. package/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php +91 -0
  89. package/server/tests/Unit/Jobs/GeofenceDwellAndBulkNotifyTest.php +7 -0
  90. package/server/tests/Unit/Models/SensorLastPositionDefaultTest.php +130 -0
  91. package/translations/en-us.yaml +4 -0
@@ -30,6 +30,17 @@ class TrackingNumber extends FleetbaseResource
30
30
  'status' => $this->last_status,
31
31
  'status_code' => $this->last_status_code,
32
32
  'qr_code' => $this->qr_code,
33
+ // The raw text encoded inside qr_code, exposed ONLY in debug mode.
34
+ //
35
+ // qr_code is a base64 PNG generated from owner_uuid (TrackingNumber::newBarcode),
36
+ // and the endpoints that consume a scanned code match on that uuid. A client
37
+ // scans the image to obtain it; an automated contract run cannot, so debug
38
+ // builds publish the value beside the image rather than the API growing a
39
+ // decode endpoint or handing out uuids generally.
40
+ //
41
+ // `when()` omits the key entirely when false — it is absent, not null — so a
42
+ // production response is byte-identical to before.
43
+ 'qr_code_content' => $this->when(static::exposesQrCodeContent(), fn () => $this->owner_uuid),
33
44
  'barcode' => $this->barcode,
34
45
  'url' => Utils::consoleUrl('track-order', ['order' => $this->tracking_number]),
35
46
  'type' => Utils::getTypeFromClassName($this->owner_type),
@@ -38,6 +49,28 @@ class TrackingNumber extends FleetbaseResource
38
49
  ];
39
50
  }
40
51
 
52
+ /**
53
+ * Whether the QR code's decoded content may be published.
54
+ *
55
+ * Debug mode only, and fails closed: any failure to determine the debug state — no
56
+ * container, an application without the accessor — answers false rather than
57
+ * defaulting to exposure.
58
+ */
59
+ protected static function exposesQrCodeContent(): bool
60
+ {
61
+ try {
62
+ $app = app();
63
+
64
+ if (!is_object($app) || !method_exists($app, 'hasDebugModeEnabled')) {
65
+ return false;
66
+ }
67
+
68
+ return (bool) $app->hasDebugModeEnabled();
69
+ } catch (\Throwable $e) {
70
+ return false;
71
+ }
72
+ }
73
+
41
74
  /**
42
75
  * Transform the resource into an webhook payload.
43
76
  *
@@ -6,6 +6,7 @@ use Fleetbase\Casts\Json;
6
6
  use Fleetbase\FleetOps\Casts\Point;
7
7
  use Fleetbase\FleetOps\Support\Utils;
8
8
  use Fleetbase\LaravelMysqlSpatial\Eloquent\SpatialTrait;
9
+ use Fleetbase\LaravelMysqlSpatial\Types\Point as SpatialPoint;
9
10
  use Fleetbase\Models\Model;
10
11
  use Fleetbase\Models\User;
11
12
  use Fleetbase\Traits\HasApiModelBehavior;
@@ -149,6 +150,26 @@ class FuelReport extends Model
149
150
  return $this->belongsTo(User::class, 'reported_by_uuid');
150
151
  }
151
152
 
153
+ /**
154
+ * Bootstrap the model.
155
+ *
156
+ * `location` is declared nullable in CreateFuelReportRequest but the column is
157
+ * POINT NOT NULL, so a report created without one failed outright with
158
+ * SQLSTATE[HY000] 1364 "Field 'location' doesn't have a default value" — a 500 for
159
+ * a request the validator had just accepted. Mirrors Device and Sensor, which
160
+ * default their spatial columns the same way.
161
+ */
162
+ protected static function boot()
163
+ {
164
+ parent::boot();
165
+
166
+ static::creating(function (self $fuelReport) {
167
+ if (empty($fuelReport->location)) {
168
+ $fuelReport->location = new SpatialPoint(0, 0);
169
+ }
170
+ });
171
+ }
172
+
152
173
  /**
153
174
  * Get the driver's name assigned to vehicle.
154
175
  *
@@ -129,7 +129,7 @@ class Place extends Model
129
129
  *
130
130
  * @var array
131
131
  */
132
- protected $filterParams = ['vendor', 'contact', 'vendor_uuid', 'vendor_name'];
132
+ protected $filterParams = ['vendor', 'contact', 'vendor_uuid', 'vendor_name', 'avatar_value', 'eta'];
133
133
 
134
134
  /**
135
135
  * @return \Illuminate\Database\Eloquent\Relations\MorphTo
@@ -147,6 +147,30 @@ class Sensor extends Model
147
147
  */
148
148
  protected $spatialFields = ['last_position'];
149
149
 
150
+ /**
151
+ * Bootstrap the model.
152
+ *
153
+ * The same migration that made devices.last_position NOT NULL did it to
154
+ * sensors.last_position too, but only Device got a default. Creating a sensor
155
+ * without a position therefore failed outright:
156
+ *
157
+ * SQLSTATE[HY000]: General error: 1364 Field 'last_position' doesn't have a
158
+ * default value
159
+ *
160
+ * Position is not something a caller has when registering a sensor, so default
161
+ * it to POINT(0,0) on create, exactly as Device does.
162
+ */
163
+ protected static function boot()
164
+ {
165
+ parent::boot();
166
+
167
+ static::creating(function (self $sensor) {
168
+ if (empty($sensor->last_position)) {
169
+ $sensor->last_position = new SpatialPoint(0, 0);
170
+ }
171
+ });
172
+ }
173
+
150
174
  /**
151
175
  * The attributes that should be cast to native types.
152
176
  *
@@ -173,8 +173,17 @@ class FuelProviderService
173
173
  return DB::transaction(function () use ($connection, $payload) {
174
174
  $provider = $payload['provider'] ?? $connection->provider;
175
175
  $providerTransactionId = $payload['provider_transaction_id'];
176
+ // The match must include company_uuid. Provider transaction ids are only
177
+ // unique within the account they were issued for, so keying on
178
+ // (provider, provider_transaction_id) alone let one company's sync find
179
+ // and overwrite another company's transaction — reassigning its
180
+ // company_uuid. Mirrors fuel_provider_txn_company_provider_unique.
176
181
  $transaction = FuelProviderTransaction::updateOrCreate(
177
- ['provider' => $provider, 'provider_transaction_id' => $providerTransactionId],
182
+ [
183
+ 'company_uuid' => $connection->company_uuid,
184
+ 'provider' => $provider,
185
+ 'provider_transaction_id' => $providerTransactionId,
186
+ ],
178
187
  array_merge($payload, [
179
188
  'company_uuid' => $connection->company_uuid,
180
189
  'fuel_provider_connection_uuid' => $connection->uuid,
@@ -28,6 +28,13 @@ Route::prefix(config('fleetops.api.routing.prefix'))->namespace('Fleetbase\Fleet
28
28
  $router->post('login-with-sms', 'DriverController@loginWithPhone');
29
29
  $router->post('verify-code', 'DriverController@verifyCode');
30
30
  $router->post('login', 'DriverController@login');
31
+ // Password management. Deliberately separate from the driver
32
+ // update: changing a password requires proving the old one, and
33
+ // resetting it requires a code, neither of which a general PUT
34
+ // can express.
35
+ $router->post('forgot-password', 'DriverController@forgotPassword');
36
+ $router->post('reset-password', 'DriverController@resetPassword');
37
+ $router->post('{id}/change-password', 'DriverController@changePassword');
31
38
  $router->post('{id}/simulate', 'DriverController@simulate');
32
39
  $router->match(['put', 'patch', 'post'], '{id}/track', 'DriverController@track');
33
40
  $router->post('{id}/register-device', 'DriverController@registerDevice');
@@ -37,6 +44,8 @@ Route::prefix(config('fleetops.api.routing.prefix'))->namespace('Fleetbase\Fleet
37
44
  $router->get('/', 'DriverController@query');
38
45
  $router->get('{id}', 'DriverController@find');
39
46
  $router->get('{id}/organizations', 'DriverController@listOrganizations');
47
+ // A driver's own routes for the day.
48
+ $router->get('{id}/manifests', 'ManifestController@forDriver');
40
49
  $router->get('{id}/current-organization', 'DriverController@currentOrganization');
41
50
  $router->put('{id}', 'DriverController@update');
42
51
  $router->delete('{id}', 'DriverController@delete');
@@ -185,6 +194,18 @@ Route::prefix(config('fleetops.api.routing.prefix'))->namespace('Fleetbase\Fleet
185
194
  $router->delete('{id}', 'OrderController@delete');
186
195
  $router->get('{id}/editable-entity-fields', 'OrderController@getEditableEntityFields');
187
196
  });
197
+ // manifests — a driver's route. Read and run only: creating,
198
+ // cancelling and deleting a manifest is dispatch work and stays on
199
+ // the internal namespace.
200
+ $router->group(['prefix' => 'manifests'], function () use ($router) {
201
+ $router->get('{id}', 'ManifestController@show');
202
+ $router->post('{id}/optimize', 'ManifestController@optimize');
203
+ });
204
+ $router->group(['prefix' => 'manifest-stops'], function () use ($router) {
205
+ $router->patch('{id}', 'ManifestController@updateStop');
206
+ $router->post('{id}', 'ManifestController@updateStop');
207
+ });
208
+
188
209
  // entities routes
189
210
  $router->group(['prefix' => 'entities'], function () use ($router) {
190
211
  $router->post('/', 'EntityController@create');
@@ -237,7 +258,7 @@ Route::prefix(config('fleetops.api.routing.prefix'))->namespace('Fleetbase\Fleet
237
258
  $router->get('events', 'GeofenceController@events');
238
259
  $router->get('inventory', 'GeofenceController@inventory');
239
260
  $router->get('dwell-report', 'GeofenceController@dwellReport');
240
- $router->get('driver/{driverUuid}/history', 'GeofenceController@driverHistory');
261
+ $router->get('driver/{driverId}/history', 'GeofenceController@driverHistory');
241
262
  });
242
263
  // service-rates routes
243
264
  $router->group(['prefix' => 'service-rates'], function () use ($router) {
@@ -960,3 +960,171 @@ test('api customer controller handles verification delivery fallbacks and profil
960
960
  'name' => 'explode',
961
961
  ]))))->toBe(['error' => 'update failed']);
962
962
  });
963
+
964
+ /**
965
+ * Run a callback with an app container that supports environment().
966
+ *
967
+ * The harness binds a bare Illuminate\Container\Container, which has no
968
+ * environment() — so CustomerController::verificationBypassMatches fatals with
969
+ * "Call to undefined method" without this. Mirrors the swap in
970
+ * NotificationAndMailContractsTest, but carries every existing binding across so
971
+ * the controller can still resolve config/request/db.
972
+ */
973
+ function fleetopsApiCustomerWithEnvironment(string $environment, callable $callback): mixed
974
+ {
975
+ $previousApp = Illuminate\Container\Container::getInstance();
976
+ $app = new class extends Illuminate\Container\Container {
977
+ public string $fleetopsEnvironment = 'testing';
978
+
979
+ public function environment(...$environments)
980
+ {
981
+ if (empty($environments)) {
982
+ return $this->fleetopsEnvironment;
983
+ }
984
+
985
+ $environments = is_array($environments[0]) ? $environments[0] : $environments;
986
+
987
+ return in_array($this->fleetopsEnvironment, $environments, true);
988
+ }
989
+
990
+ public function hasDebugModeEnabled()
991
+ {
992
+ return false;
993
+ }
994
+ };
995
+ $app->fleetopsEnvironment = $environment;
996
+
997
+ $reflection = new ReflectionClass(Illuminate\Container\Container::class);
998
+ foreach (['bindings', 'instances', 'aliases', 'abstractAliases', 'resolved', 'scopedInstances'] as $property) {
999
+ if (!$reflection->hasProperty($property)) {
1000
+ continue;
1001
+ }
1002
+ $handle = $reflection->getProperty($property);
1003
+ $handle->setAccessible(true);
1004
+ $handle->setValue($app, $handle->getValue($previousApp));
1005
+ }
1006
+
1007
+ Illuminate\Container\Container::setInstance($app);
1008
+
1009
+ try {
1010
+ return $callback();
1011
+ } finally {
1012
+ Illuminate\Container\Container::setInstance($previousApp);
1013
+ }
1014
+ }
1015
+
1016
+ test('api customer controller ignores the verification bypass unless it is configured', function () {
1017
+ // Unset and empty-string are both inert. Without this the bypass would be a
1018
+ // standing hole in every default install, which is the entire risk of shipping one.
1019
+ foreach ([null, ''] as $bypassCode) {
1020
+ config(['fleetops.customers.verification_bypass_code' => $bypassCode]);
1021
+
1022
+ fleetopsApiCustomerWithEnvironment('local', function () {
1023
+ $create = fleetopsApiCustomerController();
1024
+ $create->verificationExists = false;
1025
+ $verify = fleetopsApiCustomerController();
1026
+ $verify->verificationExists = false;
1027
+ $reset = fleetopsApiCustomerController();
1028
+ $reset->verificationCode = null;
1029
+
1030
+ expect(fleetopsApiCustomerJson($create->create(new CreateCustomerRequest([
1031
+ 'code' => '000000',
1032
+ 'identity' => 'jane@example.test',
1033
+ ]))))->toBe(['error' => 'Invalid verification code provided.'])
1034
+ ->and(fleetopsApiCustomerJson($verify->verifyCode(Request::create('/v1/customers/verify-code', 'POST', [
1035
+ 'identity' => 'jane@example.test',
1036
+ 'code' => '000000',
1037
+ ]))))->toBe(['error' => 'Invalid verification code.'])
1038
+ ->and(fleetopsApiCustomerJson($reset->resetPassword(Request::create('/v1/customers/reset-password', 'POST', [
1039
+ 'identity' => 'jane@example.test',
1040
+ 'code' => '000000',
1041
+ 'password' => 'password-secret',
1042
+ ]))))->toBe(['error' => 'Invalid reset code.']);
1043
+ });
1044
+ }
1045
+
1046
+ config(['fleetops.customers.verification_bypass_code' => null]);
1047
+ });
1048
+
1049
+ test('api customer controller accepts a configured verification bypass for a listed review account', function () {
1050
+ config([
1051
+ 'fleetops.customers.verification_bypass_code' => '000000',
1052
+ 'fleetops.customers.review_accounts' => ['jane@example.test'],
1053
+ ]);
1054
+
1055
+ // Deliberately production. App store reviewers test a release build against
1056
+ // production, so refusing the bypass there made review impossible — that is the
1057
+ // behaviour this replaces. Safety now comes from the allowlist, not the environment.
1058
+ fleetopsApiCustomerWithEnvironment('production', function () {
1059
+ $create = fleetopsApiCustomerController();
1060
+ $create->verificationExists = false;
1061
+ $verify = fleetopsApiCustomerController();
1062
+ $verify->verificationExists = false;
1063
+ // No VerificationCode row on the bypass path — resetPassword must not fatal
1064
+ // calling ->delete() on null, and must still revoke existing sessions.
1065
+ $reset = fleetopsApiCustomerController();
1066
+ $reset->verificationCode = null;
1067
+ // A non-matching code is still rejected while the bypass is live.
1068
+ $wrong = fleetopsApiCustomerController();
1069
+ $wrong->verificationExists = false;
1070
+
1071
+ expect($create->create(new CreateCustomerRequest([
1072
+ 'code' => '000000',
1073
+ 'identity' => 'jane@example.test',
1074
+ 'name' => 'Jane',
1075
+ 'password' => 'password-secret',
1076
+ ])))->toMatchArray(['resource' => 'customer', 'token' => 'plain-token'])
1077
+ ->and($verify->verifyCode(Request::create('/v1/customers/verify-code', 'POST', [
1078
+ 'identity' => 'jane@example.test',
1079
+ 'code' => '000000',
1080
+ ])))->toMatchArray(['resource' => 'customer', 'token' => 'plain-token'])
1081
+ ->and(fleetopsApiCustomerJson($reset->resetPassword(Request::create('/v1/customers/reset-password', 'POST', [
1082
+ 'identity' => 'jane@example.test',
1083
+ 'code' => '000000',
1084
+ 'password' => 'password-secret',
1085
+ ]))))->toBe(['status' => 'ok'])
1086
+ ->and($reset->genericUser->tokensDeleted)->toBeTrue()
1087
+ ->and(fleetopsApiCustomerJson($wrong->create(new CreateCustomerRequest([
1088
+ 'code' => '999999',
1089
+ 'identity' => 'jane@example.test',
1090
+ ]))))->toBe(['error' => 'Invalid verification code provided.']);
1091
+ });
1092
+
1093
+ config(['fleetops.customers.verification_bypass_code' => null, 'fleetops.customers.review_accounts' => []]);
1094
+ });
1095
+
1096
+ test('api customer controller refuses the verification bypass for an identity that is not listed', function () {
1097
+ // The point of the allowlist: holding the code is not sufficient. Previously
1098
+ // anyone who learned it could authenticate as any customer.
1099
+ foreach ([['someone-else@example.test'], []] as $reviewAccounts) {
1100
+ config([
1101
+ 'fleetops.customers.verification_bypass_code' => '000000',
1102
+ 'fleetops.customers.review_accounts' => $reviewAccounts,
1103
+ ]);
1104
+
1105
+ fleetopsApiCustomerWithEnvironment('local', function () {
1106
+ $create = fleetopsApiCustomerController();
1107
+ $create->verificationExists = false;
1108
+ $verify = fleetopsApiCustomerController();
1109
+ $verify->verificationExists = false;
1110
+ $reset = fleetopsApiCustomerController();
1111
+ $reset->verificationCode = null;
1112
+
1113
+ expect(fleetopsApiCustomerJson($create->create(new CreateCustomerRequest([
1114
+ 'code' => '000000',
1115
+ 'identity' => 'jane@example.test',
1116
+ ]))))->toBe(['error' => 'Invalid verification code provided.'])
1117
+ ->and(fleetopsApiCustomerJson($verify->verifyCode(Request::create('/v1/customers/verify-code', 'POST', [
1118
+ 'identity' => 'jane@example.test',
1119
+ 'code' => '000000',
1120
+ ]))))->toBe(['error' => 'Invalid verification code.'])
1121
+ ->and(fleetopsApiCustomerJson($reset->resetPassword(Request::create('/v1/customers/reset-password', 'POST', [
1122
+ 'identity' => 'jane@example.test',
1123
+ 'code' => '000000',
1124
+ 'password' => 'password-secret',
1125
+ ]))))->toBe(['error' => 'Invalid reset code.']);
1126
+ });
1127
+ }
1128
+
1129
+ config(['fleetops.customers.verification_bypass_code' => null, 'fleetops.customers.review_accounts' => []]);
1130
+ });
@@ -550,6 +550,38 @@ test('api driver controller registers a device for the authenticated driver with
550
550
  ]);
551
551
  });
552
552
 
553
+ test('api driver controller falls back to the container request when the router injects null', function () {
554
+ // Laravel's ResolvesRouteDependencies::transformDependency() returns null — it does
555
+ // NOT resolve from the container — for any class-typed parameter that declares a
556
+ // default value. registerDevice() has to declare one, because the internal controller
557
+ // delegates with an id only, so every routed call arrived with $request === null.
558
+ // Every other test here passes a request explicitly, which is why they stayed green
559
+ // while POST /v1/drivers/{id}/register-device answered 500.
560
+ $driver = new FleetOpsApiDriverFake();
561
+ $driver->setRawAttributes([
562
+ 'uuid' => 'driver-uuid',
563
+ 'public_id' => 'driver_public',
564
+ 'user_uuid' => 'user-uuid',
565
+ ], true);
566
+
567
+ $controller = new FleetOpsApiDriverControllerProbe();
568
+ $controller->driver = $driver;
569
+
570
+ $bound = new FleetOpsApiDriverRegisterDeviceRequest(['token' => 'push-token', 'platform' => 'android']);
571
+ app()->instance('request', $bound);
572
+
573
+ expect($controller->registerDevice('driver_public', null))->toBe([
574
+ 'json' => ['device' => 'device_public'],
575
+ 'status' => 200,
576
+ ])
577
+ ->and($controller->deviceCreates)->toBe([
578
+ [
579
+ ['token' => 'push-token', 'platform' => 'android'],
580
+ ['user_uuid' => 'user-uuid', 'platform' => 'android', 'token' => 'push-token', 'status' => 'active'],
581
+ ],
582
+ ]);
583
+ });
584
+
553
585
  test('api driver controller reports missing driver branches', function () {
554
586
  $controller = new FleetOpsApiDriverControllerProbe();
555
587
  $controller->driverNotFound = true;
@@ -569,3 +601,36 @@ test('api driver controller reports missing driver branches', function () {
569
601
  'status' => 404,
570
602
  ]);
571
603
  });
604
+
605
+ test('api driver controller refuses to set a password through a general update', function () {
606
+ /*
607
+ * Regression for a takeover path: `update()` passed `password` straight
608
+ * through to the user, so anyone holding a driver's token — or an unlocked
609
+ * handset — could set a new one without proving they knew the old one, and
610
+ * the account was theirs. A password change is an authorisation decision
611
+ * and now has its own endpoint that demands the current password.
612
+ */
613
+ $user = new FleetOpsApiDriverUserFake();
614
+ $user->setRawAttributes(['uuid' => 'user-uuid'], true);
615
+ $driver = new FleetOpsApiDriverFake();
616
+ $driver->setRawAttributes([
617
+ 'uuid' => 'driver-uuid',
618
+ 'public_id' => 'driver_public',
619
+ 'user_uuid' => 'user-uuid',
620
+ ], true);
621
+ $driver->userForTest = $user;
622
+ $driver->setRelation('user', $user);
623
+
624
+ $controller = new FleetOpsApiDriverControllerProbe();
625
+ $controller->driver = $driver;
626
+ $controller->sessionCompanyUuid = 'session-company-uuid';
627
+
628
+ $controller->update('driver_public', new UpdateDriverRequest([
629
+ 'name' => 'Driver Updated',
630
+ 'password' => 'attacker-chosen-password',
631
+ ]));
632
+
633
+ expect($user->updates)->not->toBeEmpty()
634
+ ->and($user->updates[0])->not->toHaveKey('password')
635
+ ->and($user->updates[0])->toHaveKey('name');
636
+ });