@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.
- package/RELEASE.md +36 -0
- package/addon/components/vehicle/details/schedules.hbs +2 -2
- package/addon/components/vehicle/details/schedules.js +7 -1
- package/addon/controllers/maintenance/schedules/index/edit.js +10 -1
- package/addon/services/vehicle-actions.js +4 -2
- package/composer.json +1 -1
- package/extension.json +1 -1
- package/package.json +1 -1
- package/server/config/fleetops.php +44 -0
- package/server/config/geocoder.php +1 -1
- package/server/migrations/2026_08_08_000001_make_sku_and_provider_transaction_unique_indexes_soft_delete_aware.php +152 -0
- package/server/src/Http/Controllers/Api/v1/CustomerController.php +50 -4
- package/server/src/Http/Controllers/Api/v1/DriverController.php +57 -2
- package/server/src/Http/Controllers/Api/v1/GeofenceController.php +34 -2
- package/server/src/Http/Controllers/Api/v1/NavigatorController.php +14 -13
- package/server/src/Http/Controllers/Api/v1/TrackingNumberController.php +10 -1
- package/server/src/Http/Controllers/Concerns/ResolvesReviewAccountBypass.php +62 -0
- package/server/src/Http/Controllers/Internal/v1/DriverController.php +1 -1
- package/server/src/Http/Filter/MaintenanceScheduleFilter.php +40 -0
- package/server/src/Http/Requests/CreateCustomerRequest.php +9 -1
- package/server/src/Http/Requests/CreateFuelReportRequest.php +14 -3
- package/server/src/Http/Requests/CreateFuelTransactionRequest.php +64 -1
- package/server/src/Http/Requests/CreatePartRequest.php +39 -1
- package/server/src/Http/Resources/v1/TrackingNumber.php +33 -0
- package/server/src/Models/FuelReport.php +21 -0
- package/server/src/Models/Sensor.php +24 -0
- package/server/src/Support/FuelProviders/FuelProviderService.php +10 -1
- package/server/src/routes.php +1 -1
- package/server/tests/ApiCustomerControllerContractsTest.php +168 -0
- package/server/tests/ApiDriverControllerContractsTest.php +32 -0
- package/server/tests/CompactResourceSerializationTest.php +93 -0
- package/server/tests/CustomerEndpointTest.php +4 -1
- package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +81 -2
- package/server/tests/Feature/Http/Api/GeofenceControllerContractsTest.php +99 -2
- package/server/tests/Feature/Http/Api/TrackingNumberControllerHelpersTest.php +12 -6
- package/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +58 -0
- package/server/tests/FuelProviderTransactionControllerContractsTest.php +17 -0
- package/server/tests/RequestContractsTest.php +97 -5
- package/server/tests/SmallControllerContractsTest.php +28 -4
- package/server/tests/Unit/Http/Filter/MaintenanceScheduleFilterTest.php +69 -0
- package/server/tests/Unit/Http/Requests/CreateFuelReportRequestTest.php +21 -0
- package/server/tests/Unit/Jobs/GeofenceDwellAndBulkNotifyTest.php +7 -0
- 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={{
|
|
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.
|
|
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.
|
|
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
package/extension.json
CHANGED
package/package.json
CHANGED
|
@@ -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', '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 $
|
|
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', $
|
|
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;
|