@fleetbase/fleetops-engine 0.6.59 → 0.6.60

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 (43) hide show
  1. package/RELEASE.md +36 -0
  2. package/addon/components/vehicle/details/schedules.hbs +2 -2
  3. package/addon/components/vehicle/details/schedules.js +7 -1
  4. package/addon/controllers/maintenance/schedules/index/edit.js +10 -1
  5. package/addon/services/vehicle-actions.js +4 -2
  6. package/composer.json +1 -1
  7. package/extension.json +1 -1
  8. package/package.json +1 -1
  9. package/server/config/fleetops.php +44 -0
  10. package/server/config/geocoder.php +1 -1
  11. package/server/migrations/2026_08_08_000001_make_sku_and_provider_transaction_unique_indexes_soft_delete_aware.php +152 -0
  12. package/server/src/Http/Controllers/Api/v1/CustomerController.php +50 -4
  13. package/server/src/Http/Controllers/Api/v1/DriverController.php +57 -2
  14. package/server/src/Http/Controllers/Api/v1/GeofenceController.php +34 -2
  15. package/server/src/Http/Controllers/Api/v1/NavigatorController.php +14 -13
  16. package/server/src/Http/Controllers/Api/v1/TrackingNumberController.php +10 -1
  17. package/server/src/Http/Controllers/Concerns/ResolvesReviewAccountBypass.php +62 -0
  18. package/server/src/Http/Controllers/Internal/v1/DriverController.php +1 -1
  19. package/server/src/Http/Filter/MaintenanceScheduleFilter.php +40 -0
  20. package/server/src/Http/Requests/CreateCustomerRequest.php +9 -1
  21. package/server/src/Http/Requests/CreateFuelReportRequest.php +14 -3
  22. package/server/src/Http/Requests/CreateFuelTransactionRequest.php +64 -1
  23. package/server/src/Http/Requests/CreatePartRequest.php +39 -1
  24. package/server/src/Http/Resources/v1/TrackingNumber.php +33 -0
  25. package/server/src/Models/FuelReport.php +21 -0
  26. package/server/src/Models/Sensor.php +24 -0
  27. package/server/src/Support/FuelProviders/FuelProviderService.php +10 -1
  28. package/server/src/routes.php +1 -1
  29. package/server/tests/ApiCustomerControllerContractsTest.php +168 -0
  30. package/server/tests/ApiDriverControllerContractsTest.php +32 -0
  31. package/server/tests/CompactResourceSerializationTest.php +93 -0
  32. package/server/tests/CustomerEndpointTest.php +4 -1
  33. package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +81 -2
  34. package/server/tests/Feature/Http/Api/GeofenceControllerContractsTest.php +99 -2
  35. package/server/tests/Feature/Http/Api/TrackingNumberControllerHelpersTest.php +12 -6
  36. package/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +58 -0
  37. package/server/tests/FuelProviderTransactionControllerContractsTest.php +17 -0
  38. package/server/tests/RequestContractsTest.php +97 -5
  39. package/server/tests/SmallControllerContractsTest.php +28 -4
  40. package/server/tests/Unit/Http/Filter/MaintenanceScheduleFilterTest.php +69 -0
  41. package/server/tests/Unit/Http/Requests/CreateFuelReportRequestTest.php +21 -0
  42. package/server/tests/Unit/Jobs/GeofenceDwellAndBulkNotifyTest.php +7 -0
  43. package/server/tests/Unit/Models/SensorLastPositionDefaultTest.php +130 -0
package/RELEASE.md ADDED
@@ -0,0 +1,36 @@
1
+ > v0.6.60 ~ "Closes an authentication bypass, and clears a run of API 500s"
2
+
3
+ ---
4
+ ## Highlights
5
+ A security fix and a broad sweep of public API defects surfaced by running the official Postman collection against a live stack. Several endpoints answered `500` where a `404` or `422` belonged, and a few were unreachable entirely.
6
+
7
+ ---
8
+ ## Security
9
+ - **Closed a verify-code authentication bypass in the driver flow.** Please upgrade.
10
+ - The non-production verification-code bypass is now scoped to explicitly designated review accounts, so a bypass code alone is not enough — the identity has to be on the allowlist too.
11
+
12
+ ---
13
+ ## Bug Fixes
14
+ - **Driver `register-device` was unreachable on both driver routes.** Laravel never injects a class-typed parameter that declares a default, so the injected request was always null.
15
+ - **Geofence driver history asked for a UUID the API never issues.** It now resolves the driver by the public id callers actually hold.
16
+ - **`/from-qr` returned a 500**, and the QR code's content is now published in debug mode so the flow can be exercised.
17
+ - **Fuel reports could not be created without a location**, and could not be updated.
18
+ - **A sensor could not be created at all** — `last_position` had no default.
19
+ - **Customer signup with a place failed** — the Place location now defaults.
20
+ - Unknown onboard organization answers `404` instead of `500`.
21
+ - Duplicate part SKU and fuel transaction answer `422` instead of `500`.
22
+ - Restored the vehicle maintenance schedule workflows.
23
+
24
+ ---
25
+ ## Testing
26
+ - Coverage restored to 100% across the QR, geofence, driver auth, customer request and navigator changes.
27
+
28
+ ---
29
+ ## Continuous Integration
30
+ - The server, Ember and Postman workflows now run on `dev-v*` release branches.
31
+ - The contract run tests this branch's API code rather than the published package.
32
+
33
+ ---
34
+ ## Need help?
35
+ - [GitHub Discussions](https://github.com/fleetbase/fleetbase/discussions)
36
+ - [Discord](https://discord.gg/HnTqQ6zAVn)
@@ -33,8 +33,8 @@
33
33
  <div class="flex flex-col items-center justify-center py-10 text-gray-400 dark:text-gray-600">
34
34
  <FaIcon @icon="calendar-alt" @size="2x" class="mb-3" />
35
35
  <p class="text-sm">No maintenance schedules for this vehicle.</p>
36
- <Button @text="Add Schedule" @icon="plus" @size="xs" @type="primary" class="mt-3" @onClick={{fn this.maintenanceScheduleActions.modal.create (hash subject=@vehicle)}} />
36
+ <Button @text="Add Schedule" @icon="plus" @size="xs" @type="primary" class="mt-3" @onClick={{this.createSchedule}} />
37
37
  </div>
38
38
  {{/if}}
39
39
  {{/if}}
40
- </div>
40
+ </div>
@@ -1,12 +1,14 @@
1
1
  import Component from '@glimmer/component';
2
2
  import { tracked } from '@glimmer/tracking';
3
3
  import { inject as service } from '@ember/service';
4
+ import { action } from '@ember/object';
4
5
  import { task } from 'ember-concurrency';
5
6
 
6
7
  export default class VehicleDetailsSchedulesComponent extends Component {
7
8
  @service maintenanceScheduleActions;
8
9
  @service notifications;
9
10
  @service store;
11
+ @service vehicleActions;
10
12
  @tracked schedules = [];
11
13
 
12
14
  get resourceId() {
@@ -22,11 +24,15 @@ export default class VehicleDetailsSchedulesComponent extends Component {
22
24
  try {
23
25
  this.schedules = yield this.store.query('maintenance-schedule', {
24
26
  subject_uuid: this.resourceId,
25
- subject_type: 'vehicle',
27
+ subject_type: 'fleet-ops:vehicle',
26
28
  sort: '-created_at',
27
29
  });
28
30
  } catch (err) {
29
31
  this.notifications.serverError(err);
30
32
  }
31
33
  }
34
+
35
+ @action createSchedule() {
36
+ return this.vehicleActions.scheduleMaintenance(this.args.vehicle, {}, { refresh: false, callback: () => this.loadSchedules.perform() });
37
+ }
32
38
  }
@@ -21,12 +21,21 @@ export default class MaintenanceSchedulesIndexEditController extends Controller
21
21
  this.overlay?.close();
22
22
  yield this.hostRouter.refresh();
23
23
  yield this.hostRouter.transitionTo('console.fleet-ops.maintenance.schedules.index.details', schedule);
24
- this.notifications.success(this.intl.t('common.resource-updated-success', { resource: this.intl.t('resource.maintenance-schedule') }));
24
+ this.notifyUpdateSuccess(schedule);
25
25
  } catch (err) {
26
26
  this.notifications.serverError(err);
27
27
  }
28
28
  }
29
29
 
30
+ notifyUpdateSuccess(schedule) {
31
+ this.notifications.success(
32
+ this.intl.t('common.resource-updated-success', {
33
+ resource: this.intl.t('resource.maintenance-schedule'),
34
+ resourceName: schedule.name,
35
+ })
36
+ );
37
+ }
38
+
30
39
  @action cancel() {
31
40
  return this.hostRouter.transitionTo('console.fleet-ops.maintenance.schedules.index');
32
41
  }
@@ -184,8 +184,10 @@ export default class VehicleActionsService extends ResourceActionService {
184
184
  },
185
185
  };
186
186
 
187
- @action scheduleMaintenance(vehicle) {
188
- this.maintenanceScheduleActions.modal.create({ subject: vehicle });
187
+ @action async scheduleMaintenance(vehicle, options = {}, saveOptions = {}) {
188
+ vehicle = await this.resolveVehicleResource(vehicle);
189
+
190
+ return this.maintenanceScheduleActions.modal.create({ subject: vehicle }, options, saveOptions);
189
191
  }
190
192
 
191
193
  @action createWorkOrder(vehicle) {
package/composer.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fleetbase/fleetops-api",
3
- "version": "0.6.59",
3
+ "version": "0.6.60",
4
4
  "description": "Fleet & Transport Management Extension for Fleetbase",
5
5
  "keywords": [
6
6
  "fleetbase-extension",
package/extension.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "Fleet-Ops",
3
- "version": "0.6.59",
3
+ "version": "0.6.60",
4
4
  "description": "Fleet & Transport Management Extension for Fleetbase",
5
5
  "repository": "https://github.com/fleetbase/fleetops",
6
6
  "license": "AGPL-3.0-or-later",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fleetbase/fleetops-engine",
3
- "version": "0.6.59",
3
+ "version": "0.6.60",
4
4
  "description": "Fleet & Transport Management Extension for Fleetbase",
5
5
  "fleetbase": {
6
6
  "route": "fleet-ops"
@@ -96,10 +96,54 @@ return [
96
96
  |--------------------------------------------------------------------------
97
97
  */
98
98
  'navigator' => [
99
+ /*
100
+ | App store reviewers cannot receive our SMS, so a fixed verification code has
101
+ | to keep working for them — including in production, which is where a review
102
+ | build is tested. Blocking the bypass outside production made review
103
+ | impossible; restricting it to named accounts makes it safe instead.
104
+ |
105
+ | Both values are required, and neither has a default. The code alone is not
106
+ | sufficient: it is only accepted for an identity listed in review_accounts, so
107
+ | a leaked code cannot be used to authenticate as an arbitrary driver.
108
+ |
109
+ | NAVIGATOR_BYPASS_VERIFICATION_CODE=<a secret, rotated code>
110
+ | NAVIGATOR_REVIEW_ACCOUNTS=+15555550100,apple-review@example.com
111
+ */
99
112
  'bypass_verification_code' => env('SMS_AUTH_BYPASS_CODE', env('NAVIGATOR_BYPASS_VERIFICATION_CODE')),
113
+ 'review_accounts' => array_values(array_filter(array_map(
114
+ 'trim',
115
+ explode(',', (string) env('NAVIGATOR_REVIEW_ACCOUNTS', ''))
116
+ ))),
100
117
  'app_identifier' => env('NAVIGATOR_APP_IDENTIFIER', 'io.fleetbase.navigator'),
101
118
  ],
102
119
 
120
+ /*
121
+ |--------------------------------------------------------------------------
122
+ | Customers
123
+ |--------------------------------------------------------------------------
124
+ |
125
+ | Testing-only verification-code bypass for the customer auth flows
126
+ | (POST /v1/customers, /customers/verify-code, /customers/reset-password).
127
+ | Intended for local development and staging QA, where signing up means
128
+ | waiting on a real email or paying for a real SMS.
129
+ |
130
+ | MUST be left unset in production. It is ignored outright when the app
131
+ | environment is `production`, and when unset no bypass is possible.
132
+ |
133
+ | Deliberately NOT wired to SMS_AUTH_BYPASS_CODE: that variable already
134
+ | gates operator console login and driver login, and reusing it here would
135
+ | make one leaked value unlock three different privilege tiers.
136
+ |
137
+ */
138
+ 'customers' => [
139
+ 'verification_bypass_code' => env('FLEETOPS_CUSTOMER_VERIFICATION_BYPASS_CODE'),
140
+ // Identities the bypass code is accepted for. See the note on navigator below.
141
+ 'review_accounts' => array_values(array_filter(array_map(
142
+ 'trim',
143
+ explode(',', (string) env('FLEETOPS_CUSTOMER_REVIEW_ACCOUNTS', ''))
144
+ ))),
145
+ ],
146
+
103
147
  /*
104
148
  |--------------------------------------------------------------------------
105
149
  | API Events
@@ -57,7 +57,7 @@ return [
57
57
  'providers' => [
58
58
  Chain::class => [
59
59
  GoogleMaps::class => [
60
- config('services.google_maps.locale', env('GOOGLE_MAPS_LOCALE', 'us')),
60
+ config('services.google_maps.locale', env('GOOGLE_MAPS_LOCALE', 'en')),
61
61
  config('services.google_maps.api_key', env('GOOGLE_MAPS_API_KEY')),
62
62
  ]
63
63
  ],
@@ -0,0 +1,152 @@
1
+ <?php
2
+
3
+ use Illuminate\Database\Migrations\Migration;
4
+ use Illuminate\Database\Schema\Blueprint;
5
+ use Illuminate\Support\Facades\DB;
6
+ use Illuminate\Support\Facades\Schema;
7
+
8
+ /**
9
+ * Both `parts` and `fuel_provider_transactions` soft-delete, but their unique
10
+ * indexes were not soft-delete aware. A deleted row kept occupying the key, so
11
+ * re-creating a Part with a previously used SKU (or re-ingesting a fuel provider
12
+ * transaction that had been deleted) raised a UniqueConstraintViolationException.
13
+ * On the public v1 API that surfaced as an HTTP 500, and because there is no
14
+ * restore/force-delete route the key was unusable from then on.
15
+ *
16
+ * The fix follows the pattern already used for alrashed contract numbers in
17
+ * 2026_05_25_000001_make_contract_number_unique_for_active_contracts: a STORED
18
+ * generated column that is NULL for soft-deleted rows. MySQL permits any number
19
+ * of NULLs in a unique index, so tombstones stop colliding while live rows stay
20
+ * constrained.
21
+ *
22
+ * `fuel_provider_transactions` additionally gains `company_uuid` in the key. The
23
+ * old index was global, so one company's provider transaction id blocked — or,
24
+ * via FuelProviderService::ingestTransaction()'s updateOrCreate, silently
25
+ * overwrote — another company's. Provider transaction ids are only unique within
26
+ * the account they were issued for, so the key belongs per tenant.
27
+ *
28
+ * Both new indexes are strictly more permissive than the ones they replace, so no
29
+ * existing row can violate them and no backfill is required.
30
+ */
31
+ return new class extends Migration {
32
+ /**
33
+ * Run the migrations.
34
+ *
35
+ * @return void
36
+ */
37
+ public function up()
38
+ {
39
+ // ---- parts: (company_uuid, sku) -> (company_uuid, active_sku) ----
40
+ if (!Schema::hasColumn('parts', 'active_sku')) {
41
+ Schema::table('parts', function (Blueprint $table) {
42
+ $table->string('active_sku')->nullable()->storedAs('IF(`deleted_at` IS NULL, `sku`, NULL)');
43
+ });
44
+ }
45
+
46
+ // The replacement index MUST be created before the old one is dropped:
47
+ // parts_company_uuid_sku_unique is currently the only index covering
48
+ // company_uuid, and parts_company_uuid_foreign depends on it. Adding an
49
+ // index that also leads with company_uuid lets it take over as the
50
+ // covering index, otherwise the drop below fails with errno 150.
51
+ if (!$this->indexExists('parts', 'parts_company_uuid_active_sku_unique')) {
52
+ Schema::table('parts', function (Blueprint $table) {
53
+ $table->unique(['company_uuid', 'active_sku'], 'parts_company_uuid_active_sku_unique');
54
+ });
55
+ }
56
+
57
+ if ($this->indexExists('parts', 'parts_company_uuid_sku_unique')) {
58
+ Schema::table('parts', function (Blueprint $table) {
59
+ $table->dropUnique('parts_company_uuid_sku_unique');
60
+ });
61
+ }
62
+
63
+ // ---- fuel_provider_transactions: (provider, provider_transaction_id)
64
+ // -> (company_uuid, provider, active_provider_transaction_id) ----
65
+ if (!Schema::hasColumn('fuel_provider_transactions', 'active_provider_transaction_id')) {
66
+ Schema::table('fuel_provider_transactions', function (Blueprint $table) {
67
+ $table->string('active_provider_transaction_id', 191)->nullable()->storedAs('IF(`deleted_at` IS NULL, `provider_transaction_id`, NULL)');
68
+ });
69
+ }
70
+
71
+ if (!$this->indexExists('fuel_provider_transactions', 'fuel_provider_txn_company_provider_unique')) {
72
+ Schema::table('fuel_provider_transactions', function (Blueprint $table) {
73
+ $table->unique(['company_uuid', 'provider', 'active_provider_transaction_id'], 'fuel_provider_txn_company_provider_unique');
74
+ });
75
+ }
76
+
77
+ if ($this->indexExists('fuel_provider_transactions', 'fuel_provider_txn_provider_unique')) {
78
+ Schema::table('fuel_provider_transactions', function (Blueprint $table) {
79
+ $table->dropUnique('fuel_provider_txn_provider_unique');
80
+ });
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Reverse the migrations.
86
+ *
87
+ * Restoring the old global index can fail if rows now exist that only the new
88
+ * key permits (two tenants sharing a provider transaction id, or a live row
89
+ * whose SKU matches a soft-deleted one). That is inherent to reversing a
90
+ * relaxed constraint, so the old indexes are restored on a best-effort basis
91
+ * and the generated columns are dropped either way.
92
+ *
93
+ * @return void
94
+ */
95
+ public function down()
96
+ {
97
+ if (!$this->indexExists('parts', 'parts_company_uuid_sku_unique')) {
98
+ try {
99
+ Schema::table('parts', function (Blueprint $table) {
100
+ $table->unique(['company_uuid', 'sku'], 'parts_company_uuid_sku_unique');
101
+ });
102
+ } catch (Throwable $e) {
103
+ // Duplicate SKUs the new index allowed now block the old one.
104
+ }
105
+ }
106
+
107
+ if ($this->indexExists('parts', 'parts_company_uuid_active_sku_unique')) {
108
+ Schema::table('parts', function (Blueprint $table) {
109
+ $table->dropUnique('parts_company_uuid_active_sku_unique');
110
+ });
111
+ }
112
+
113
+ if (Schema::hasColumn('parts', 'active_sku')) {
114
+ Schema::table('parts', function (Blueprint $table) {
115
+ $table->dropColumn('active_sku');
116
+ });
117
+ }
118
+
119
+ if (!$this->indexExists('fuel_provider_transactions', 'fuel_provider_txn_provider_unique')) {
120
+ try {
121
+ Schema::table('fuel_provider_transactions', function (Blueprint $table) {
122
+ $table->unique(['provider', 'provider_transaction_id'], 'fuel_provider_txn_provider_unique');
123
+ });
124
+ } catch (Throwable $e) {
125
+ // Cross-tenant duplicates the new index allows now block the old one.
126
+ }
127
+ }
128
+
129
+ if ($this->indexExists('fuel_provider_transactions', 'fuel_provider_txn_company_provider_unique')) {
130
+ Schema::table('fuel_provider_transactions', function (Blueprint $table) {
131
+ $table->dropUnique('fuel_provider_txn_company_provider_unique');
132
+ });
133
+ }
134
+
135
+ if (Schema::hasColumn('fuel_provider_transactions', 'active_provider_transaction_id')) {
136
+ Schema::table('fuel_provider_transactions', function (Blueprint $table) {
137
+ $table->dropColumn('active_provider_transaction_id');
138
+ });
139
+ }
140
+ }
141
+
142
+ private function indexExists(string $table, string $index): bool
143
+ {
144
+ $database = DB::connection()->getDatabaseName();
145
+
146
+ return DB::table('information_schema.statistics')
147
+ ->where('table_schema', $database)
148
+ ->where('table_name', $table)
149
+ ->where('index_name', $index)
150
+ ->exists();
151
+ }
152
+ };
@@ -19,6 +19,7 @@ use Fleetbase\FleetOps\Models\ServiceQuote;
19
19
  use Fleetbase\FleetOps\Support\CustomerAuth;
20
20
  use Fleetbase\FleetOps\Support\Utils;
21
21
  use Fleetbase\Http\Controllers\Controller;
22
+ use Fleetbase\LaravelMysqlSpatial\Types\Point as SpatialPoint;
22
23
  use Fleetbase\Models\File;
23
24
  use Fleetbase\Models\User;
24
25
  use Fleetbase\Models\UserDevice;
@@ -41,6 +42,8 @@ use Illuminate\Support\Str;
41
42
  */
42
43
  class CustomerController extends Controller
43
44
  {
45
+ use \Fleetbase\FleetOps\Http\Controllers\Concerns\ResolvesReviewAccountBypass;
46
+
44
47
  /* ============================================================
45
48
  | Public auth flows (API credential only, no Customer-Token)
46
49
  * ============================================================ */
@@ -144,7 +147,7 @@ class CustomerController extends Controller
144
147
  'for' => 'fleetops_create_customer',
145
148
  'meta->identity' => $identity,
146
149
  ]);
147
- if (!$verificationCode) {
150
+ if (!$verificationCode && !$this->verificationBypassMatches($identity, $code)) {
148
151
  return response()->apiError('Invalid verification code provided.');
149
152
  }
150
153
 
@@ -312,6 +315,14 @@ class CustomerController extends Controller
312
315
  'company_uuid' => $companyUuid,
313
316
  'owner_uuid' => $contact->uuid,
314
317
  'owner_type' => get_class($contact),
318
+ // `places.location` is a NOT NULL POINT column with no database default,
319
+ // and $allowed above is address-only — a caller cannot supply coordinates
320
+ // here. Without this the insert fails with SQLSTATE[HY000] 1364 ("Field
321
+ // 'location' doesn't have a default value"), turning a documented signup
322
+ // payload into a 500. Point(0, 0) is the same placeholder the geocoding
323
+ // helpers fall back to when an address cannot be resolved — see
324
+ // Place::getGoogleAddressArray and Place::findExistingSharedPlace.
325
+ 'location' => new SpatialPoint(0, 0),
315
326
  ],
316
327
  $attributes,
317
328
  ));
@@ -417,7 +428,7 @@ class CustomerController extends Controller
417
428
  'code' => $code,
418
429
  'for' => $for,
419
430
  ]);
420
- if (!$verificationCode) {
431
+ if (!$verificationCode && !$this->verificationBypassMatches($identity, $code)) {
421
432
  return response()->apiError('Invalid verification code.');
422
433
  }
423
434
 
@@ -503,7 +514,9 @@ class CustomerController extends Controller
503
514
  'for' => 'fleetops_customer_password_reset',
504
515
  'meta->identity' => $needle,
505
516
  ]);
506
- if (!$verificationCode) {
517
+ // $needle, not $identity: the other verify paths normalise in place, so an
518
+ // allowlisted phone must be compared in the same normalised form here too.
519
+ if (!$verificationCode && !$this->verificationBypassMatches($needle, $code)) {
507
520
  return response()->apiError('Invalid reset code.');
508
521
  }
509
522
 
@@ -517,7 +530,10 @@ class CustomerController extends Controller
517
530
  $user->save();
518
531
  // Invalidate all existing sessions for this user after a password reset.
519
532
  $this->deleteUserTokens($user);
520
- $verificationCode->delete();
533
+ // Null on the testing-bypass path — there is no row to consume.
534
+ if ($verificationCode) {
535
+ $verificationCode->delete();
536
+ }
521
537
 
522
538
  return response()->json(['status' => 'ok']);
523
539
  }
@@ -918,6 +934,36 @@ class CustomerController extends Controller
918
934
  return VerificationCode::generateSmsVerificationFor($user, $for, $options);
919
935
  }
920
936
 
937
+ /**
938
+ * Whether the supplied code matches the configured testing bypass code.
939
+ *
940
+ * Three conditions, all required, mirroring the console equivalent in
941
+ * Fleetbase\Http\Controllers\Internal\v1\AuthController::authenticateWithVerificationCode:
942
+ * a bypass code must actually be configured, the app must not be running in
943
+ * production, and the comparison is constant-time.
944
+ *
945
+ * Fails safe by default: config/app.php resolves `env` to `production` when
946
+ * neither APP_ENV nor ENVIRONMENT is set, so an unconfigured install cannot
947
+ * be bypassed even accidentally.
948
+ *
949
+ * `!== null && !== ''` rather than `!empty()` — `!empty('0')` is false, so a
950
+ * configured bypass code of "0" would otherwise be silently ignored.
951
+ *
952
+ * Kept out of verificationCodeExists()/findVerificationCode() on purpose:
953
+ * those two are test seams that the controller contract tests override, so a
954
+ * policy living inside them would be stubbed away exactly where it matters.
955
+ */
956
+ protected function verificationBypassMatches(?string $identity, ?string $code): bool
957
+ {
958
+ return static::reviewAccountBypassMatches(
959
+ 'fleetops.customers.verification_bypass_code',
960
+ 'fleetops.customers.review_accounts',
961
+ $identity,
962
+ $code,
963
+ 'fleetops-customer'
964
+ );
965
+ }
966
+
921
967
  protected function verificationCodeExists(array $attributes): bool
922
968
  {
923
969
  return VerificationCode::where($attributes)->exists();
@@ -39,6 +39,8 @@ use Illuminate\Support\Str;
39
39
 
40
40
  class DriverController extends Controller
41
41
  {
42
+ use \Fleetbase\FleetOps\Http\Controllers\Concerns\ResolvesReviewAccountBypass;
43
+
42
44
  /**
43
45
  * Creates a new Fleetbase Driver resource.
44
46
  *
@@ -438,6 +440,15 @@ class DriverController extends Controller
438
440
  */
439
441
  public function registerDevice(?string $id = null, ?Request $request = null)
440
442
  {
443
+ // Laravel does NOT inject a class-typed parameter that declares a default value —
444
+ // RouteDependencyResolverTrait skips it and the default (null) is used. So the
445
+ // router always called this with $request === null, which made
446
+ // POST /v1/drivers/{id}/register-device fail with "Call to a member function
447
+ // input() on null" and POST /v1/drivers/register-device answer 404 (the driver was
448
+ // looked up by a null user_uuid). The default has to stay, because the internal
449
+ // controller delegates to this method with an id only.
450
+ $request = $request ?? request();
451
+
441
452
  try {
442
453
  // With an id (…/{id}/register-device) look the driver up directly; without
443
454
  // one (…/register-device and the internal delegation) resolve the driver
@@ -611,7 +622,7 @@ class DriverController extends Controller
611
622
 
612
623
  // find and verify code
613
624
  $verificationCode = VerificationCode::where(['subject_uuid' => $user->uuid, 'code' => $code, 'for' => $for])->exists();
614
- if (!$verificationCode && $code !== config('fleetops.navigator.bypass_verification_code')) {
625
+ if (!$verificationCode && !static::verificationBypassMatches($identity, $code)) {
615
626
  return response()->apiError('Invalid verification code!');
616
627
  }
617
628
 
@@ -960,7 +971,21 @@ class DriverController extends Controller
960
971
  */
961
972
  protected function currentDriver(?Request $request): Driver
962
973
  {
963
- return Driver::where('user_uuid', optional(optional($request)->user())->uuid)->firstOrFail();
974
+ // $request->user() is never populated on the public API: the `fleetbase.api`
975
+ // middleware calls Auth::setSession($apiCredential), which writes the session keys
976
+ // but leaves $login false, so no user resolver is ever bound. Reading it alone
977
+ // meant POST /v1/drivers/register-device looked a driver up by a null user_uuid
978
+ // and answered 404 for every consumer of the route.
979
+ //
980
+ // Auth::getUserFromSession() encodes this same order, but it also calls auth() and
981
+ // session()->has(), neither of which exists in the SQLite test harness — so the
982
+ // two sources are read directly here.
983
+ $user = optional($request)->user();
984
+ if (!$user instanceof User) {
985
+ $user = User::where('uuid', session('user'))->first();
986
+ }
987
+
988
+ return Driver::where('user_uuid', optional($user)->uuid)->firstOrFail();
964
989
  }
965
990
 
966
991
  protected function queryDrivers(Request $request)
@@ -1035,6 +1060,36 @@ class DriverController extends Controller
1035
1060
  return $company;
1036
1061
  }
1037
1062
 
1063
+ /**
1064
+ * Whether the supplied code matches the configured testing bypass code.
1065
+ *
1066
+ * Three conditions, all required, mirroring the console equivalent in
1067
+ * Fleetbase\Http\Controllers\Internal\v1\AuthController::authenticateWithVerificationCode:
1068
+ *
1069
+ * - a bypass code must actually be configured. The previous
1070
+ * `$code !== config(...)` comparison meant that on a default install --
1071
+ * where the config resolves to null -- omitting `code` entirely made the
1072
+ * check `null !== null`, i.e. false, so the guard never fired and any
1073
+ * caller with a valid org API credential could mint a driver token for
1074
+ * any driver in the install without a code at all;
1075
+ * - the app must not be in production, so a code left set in a deployed
1076
+ * .env cannot be used against a live fleet;
1077
+ * - the comparison is constant-time.
1078
+ *
1079
+ * Shared with Internal\v1\DriverController so both verify-code paths cannot
1080
+ * drift apart.
1081
+ */
1082
+ public static function verificationBypassMatches(?string $identity, ?string $code): bool
1083
+ {
1084
+ return static::reviewAccountBypassMatches(
1085
+ 'fleetops.navigator.bypass_verification_code',
1086
+ 'fleetops.navigator.review_accounts',
1087
+ $identity,
1088
+ $code,
1089
+ 'navigator'
1090
+ );
1091
+ }
1092
+
1038
1093
  /**
1039
1094
  * Patches phone number with international code.
1040
1095
  */
@@ -4,6 +4,7 @@ namespace Fleetbase\FleetOps\Http\Controllers\Api\v1;
4
4
 
5
5
  use Fleetbase\FleetOps\Models\GeofenceEventLog;
6
6
  use Fleetbase\FleetOps\Support\Utils;
7
+ use Fleetbase\FleetOps\Models\Driver;
7
8
  use Fleetbase\Http\Controllers\Controller;
8
9
  use Illuminate\Http\JsonResponse;
9
10
  use Illuminate\Http\Request;
@@ -201,13 +202,22 @@ class GeofenceController extends Controller
201
202
  *
202
203
  * Returns the geofence event history for a specific driver.
203
204
  */
204
- public function driverHistory(Request $request, string $driverUuid): JsonResponse
205
+ public function driverHistory(Request $request, string $driverId): JsonResponse
205
206
  {
206
207
  $companyUuid = session('company');
207
208
  $perPage = min((int) $request->input('per_page', 50), 200);
208
209
 
210
+ // Addressed by public_id, like every other public endpoint. The route parameter
211
+ // was named driverUuid and matched against driver_uuid directly, so this was the
212
+ // one place in the public API asking a caller for an internal identifier — which
213
+ // the API does not hand out in the first place, making it unusable.
214
+ $driver = $this->findDriverForHistory($driverId);
215
+ if (!$driver) {
216
+ return response()->json(['error' => 'Driver resource not found.'], 404);
217
+ }
218
+
209
219
  $events = $this->geofenceEventLogQuery($companyUuid)
210
- ->where('driver_uuid', $driverUuid)
220
+ ->where('driver_uuid', $driver->uuid)
211
221
  ->with(['driver.vehicle', 'vehicle', 'order'])
212
222
  ->orderBy('occurred_at', 'desc')
213
223
  ->paginate($perPage);
@@ -217,6 +227,28 @@ class GeofenceController extends Controller
217
227
  return response()->json($events);
218
228
  }
219
229
 
230
+ /**
231
+ * Resolve the driver a history request addresses.
232
+ *
233
+ * Public callers use the public_id, like every other v1 endpoint. The console reaches
234
+ * the same method through the internal route with a uuid, so that is accepted there
235
+ * and ONLY there — the public contract stays public_id-only rather than quietly
236
+ * taking either.
237
+ *
238
+ * A seam, like geofenceEventLogQuery above, so the contract tests can drive this
239
+ * without a database.
240
+ */
241
+ protected function findDriverForHistory(string $driverId): ?Driver
242
+ {
243
+ $driver = Driver::where('public_id', $driverId)->first();
244
+
245
+ if (!$driver && \Fleetbase\Support\Http::isInternalRequest()) {
246
+ $driver = Driver::where('uuid', $driverId)->first();
247
+ }
248
+
249
+ return $driver;
250
+ }
251
+
220
252
  protected function serializeEvent(GeofenceEventLog $event): array
221
253
  {
222
254
  $driver = $event->driver;