@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.
- package/RELEASE.md +25 -0
- package/addon/components/ai/route-preview-map.js +1 -5
- package/addon/components/customer/orders.js +2 -1
- package/addon/components/driver/card.hbs +49 -0
- package/addon/components/driver/card.js +30 -0
- package/addon/components/map/leaflet-live-map.js +4 -19
- package/addon/components/modals/place-details.hbs +1 -1
- package/addon/components/modals/point-map.hbs +1 -1
- package/addon/components/orchestrator-workbench.js +2 -1
- package/addon/components/order/form/route.js +3 -0
- package/addon/components/order-tracking-lookup.js +2 -1
- package/addon/components/place/details.hbs +1 -1
- package/addon/components/positions-replay.js +4 -1
- package/addon/components/service-area/details.hbs +1 -1
- package/addon/components/vehicle/details/schedules.hbs +2 -2
- package/addon/components/vehicle/details/schedules.js +7 -1
- package/addon/components/widget/live-fleet.js +7 -7
- package/addon/components/zone/details.hbs +1 -1
- package/addon/controllers/maintenance/schedules/index/edit.js +10 -1
- package/addon/controllers/management/drivers/index.js +22 -1
- package/addon/controllers/settings/map.js +16 -0
- package/addon/helpers/leaflet-tile-url.js +19 -0
- package/addon/services/map-settings.js +22 -0
- package/addon/services/vehicle-actions.js +4 -2
- package/addon/templates/management/drivers/index.hbs +67 -33
- package/addon/templates/settings/map.hbs +10 -0
- package/addon/utils/leaflet-tile-url.js +31 -0
- package/addon/utils/prepare-place-for-save.js +13 -0
- package/app/components/driver/card.js +1 -0
- package/app/helpers/leaflet-tile-url.js +1 -0
- package/app/utils/leaflet-tile-url.js +1 -0
- package/composer.json +1 -1
- package/extension.json +1 -1
- package/index.js +11 -0
- package/package.json +2 -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 +277 -5
- package/server/src/Http/Controllers/Api/v1/GeofenceController.php +34 -2
- package/server/src/Http/Controllers/Api/v1/ManifestController.php +250 -0
- 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/Api/v1/VehicleController.php +6 -0
- 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/Controllers/Internal/v1/SettingController.php +22 -0
- package/server/src/Http/Filter/FuelReportFilter.php +29 -0
- package/server/src/Http/Filter/IssueFilter.php +29 -0
- 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/Requests/CreateVehicleRequest.php +10 -5
- package/server/src/Http/Resources/v1/Manifest.php +52 -0
- package/server/src/Http/Resources/v1/ManifestStop.php +47 -0
- package/server/src/Http/Resources/v1/OrderConfig.php +51 -4
- package/server/src/Http/Resources/v1/TrackingNumber.php +33 -0
- package/server/src/Models/FuelReport.php +21 -0
- package/server/src/Models/Place.php +1 -1
- package/server/src/Models/Sensor.php +24 -0
- package/server/src/Support/FuelProviders/FuelProviderService.php +10 -1
- package/server/src/routes.php +22 -1
- package/server/tests/ApiCustomerControllerContractsTest.php +168 -0
- package/server/tests/ApiDriverControllerContractsTest.php +65 -0
- package/server/tests/ApiDriverPasswordContractsTest.php +457 -0
- package/server/tests/ApiManifestControllerContractsTest.php +599 -0
- package/server/tests/ApiVehicleControllerContractsTest.php +35 -0
- package/server/tests/CompactResourceSerializationTest.php +99 -0
- package/server/tests/CustomerEndpointTest.php +4 -1
- package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +81 -2
- package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +61 -0
- 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/Feature/Http/Internal/PlaceControllerTest.php +45 -0
- package/server/tests/FuelProviderTransactionControllerContractsTest.php +17 -0
- package/server/tests/RequestContractsTest.php +102 -5
- package/server/tests/SettingControllerContractsTest.php +33 -0
- package/server/tests/SmallControllerContractsTest.php +28 -4
- package/server/tests/Unit/Http/Filter/FuelReportFilterTest.php +135 -0
- package/server/tests/Unit/Http/Filter/IssueFilterTest.php +43 -0
- package/server/tests/Unit/Http/Filter/MaintenanceScheduleFilterTest.php +69 -0
- package/server/tests/Unit/Http/Requests/CreateFuelReportRequestTest.php +21 -0
- package/server/tests/Unit/Http/Resources/ManifestResourceTest.php +129 -0
- package/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php +91 -0
- package/server/tests/Unit/Jobs/GeofenceDwellAndBulkNotifyTest.php +7 -0
- package/server/tests/Unit/Models/SensorLastPositionDefaultTest.php +130 -0
- package/translations/en-us.yaml +4 -0
|
@@ -1400,6 +1400,12 @@ test('order config resource projects public flow activities', function () {
|
|
|
1400
1400
|
'complete' => false,
|
|
1401
1401
|
'pod_method' => 'photo',
|
|
1402
1402
|
'require_pod' => true,
|
|
1403
|
+
// The flow's shape now rides along so consumers can sequence
|
|
1404
|
+
// it. An activity that declares none of it still publishes the
|
|
1405
|
+
// keys, so the payload shape does not vary per activity.
|
|
1406
|
+
'sequence' => null,
|
|
1407
|
+
'activities' => [],
|
|
1408
|
+
'logic' => null,
|
|
1403
1409
|
],
|
|
1404
1410
|
],
|
|
1405
1411
|
]);
|
|
@@ -2697,6 +2703,10 @@ test('tracking number and waypoint webhook resources serialize tracking contract
|
|
|
2697
2703
|
'scheduled_at' => '2026-07-30 09:00:00',
|
|
2698
2704
|
]);
|
|
2699
2705
|
|
|
2706
|
+
// The decoded QR content is a debug-only field and must never ride along on a webhook,
|
|
2707
|
+
// which is a separate literal payload rather than a filtered toArray().
|
|
2708
|
+
expect(array_key_exists('qr_code_content', (new TrackingNumberResource($trackingNumber))->toWebhookPayload()))->toBeFalse();
|
|
2709
|
+
|
|
2700
2710
|
expect((new TrackingNumberResource($trackingNumber))->toWebhookPayload())->toMatchArray([
|
|
2701
2711
|
'id' => 'fixture_public',
|
|
2702
2712
|
'tracking_number' => 'TN-ZERO',
|
|
@@ -2743,3 +2753,92 @@ test('tracking number and waypoint webhook resources serialize tracking contract
|
|
|
2743
2753
|
'meta' => ['sequence' => 2],
|
|
2744
2754
|
]);
|
|
2745
2755
|
});
|
|
2756
|
+
|
|
2757
|
+
test('tracking number resource publishes the qr code content only in debug mode', function () {
|
|
2758
|
+
// qr_code is a base64 PNG generated from owner_uuid, and the endpoints that consume a
|
|
2759
|
+
// scanned code match on that uuid. Debug builds publish the value beside the image so
|
|
2760
|
+
// an automated client can follow the flow without decoding a PNG; production must not.
|
|
2761
|
+
$trackingNumber = fleetopsCompactResourceFixture([
|
|
2762
|
+
'tracking_number' => 'TN-DEBUG',
|
|
2763
|
+
'owner_uuid' => 'owner-uuid-under-the-qr',
|
|
2764
|
+
'owner_type' => 'Fleetbase\\FleetOps\\Models\\Order',
|
|
2765
|
+
'region' => 'sg',
|
|
2766
|
+
'qr_code' => 'qr-data',
|
|
2767
|
+
'barcode' => 'barcode-data',
|
|
2768
|
+
'last_status' => 'created',
|
|
2769
|
+
'last_status_code' => 'CREATED',
|
|
2770
|
+
]);
|
|
2771
|
+
$request = Request::create('/v1/tracking-numbers', 'GET');
|
|
2772
|
+
|
|
2773
|
+
// The full resource builds a console url, which needs app()->environment(). The bare
|
|
2774
|
+
// container this suite runs on has no such method, which is why the tests above only
|
|
2775
|
+
// cover the index resource and the webhook payload.
|
|
2776
|
+
$previousContainer = Illuminate\Container\Container::getInstance();
|
|
2777
|
+
$container = new class extends Illuminate\Container\Container {
|
|
2778
|
+
public bool $debugMode = false;
|
|
2779
|
+
|
|
2780
|
+
public function environment(...$environments)
|
|
2781
|
+
{
|
|
2782
|
+
return in_array('testing', $environments, true) || $environments === [] ? 'testing' : false;
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
// Stands in for the framework's Application::hasDebugModeEnabled().
|
|
2786
|
+
public function hasDebugModeEnabled()
|
|
2787
|
+
{
|
|
2788
|
+
return $this->debugMode;
|
|
2789
|
+
}
|
|
2790
|
+
};
|
|
2791
|
+
$container->instance('config', new Illuminate\Config\Repository([
|
|
2792
|
+
'fleetbase' => ['console' => ['host' => 'console.fleetbase.test', 'subdomain' => null, 'secure' => true]],
|
|
2793
|
+
]));
|
|
2794
|
+
Illuminate\Container\Container::setInstance($container);
|
|
2795
|
+
|
|
2796
|
+
// Drive the REAL accessor through the container rather than overriding it, so the
|
|
2797
|
+
// debug check itself is exercised and not just the resource's use of it.
|
|
2798
|
+
$container->debugMode = true;
|
|
2799
|
+
$withDebug = new TrackingNumberResource($trackingNumber);
|
|
2800
|
+
|
|
2801
|
+
try {
|
|
2802
|
+
$debugPayload = $withDebug->resolve($request);
|
|
2803
|
+
|
|
2804
|
+
$container->debugMode = false;
|
|
2805
|
+
$productionPayload = (new TrackingNumberResource($trackingNumber))->resolve($request);
|
|
2806
|
+
} finally {
|
|
2807
|
+
Illuminate\Container\Container::setInstance($previousContainer);
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
// Present and exactly the value the QR was generated from — not a public id.
|
|
2811
|
+
expect($debugPayload['qr_code_content'])->toBe('owner-uuid-under-the-qr')
|
|
2812
|
+
->and($debugPayload['qr_code'])->toBe('qr-data')
|
|
2813
|
+
// Absent, not null: `when()` drops the key entirely, so a production response is
|
|
2814
|
+
// byte-identical to one from before the field existed.
|
|
2815
|
+
->and(array_key_exists('qr_code_content', $productionPayload))->toBeFalse()
|
|
2816
|
+
->and($productionPayload['qr_code'])->toBe('qr-data');
|
|
2817
|
+
});
|
|
2818
|
+
|
|
2819
|
+
test('tracking number resource fails closed when the debug state cannot be determined', function () {
|
|
2820
|
+
// Any failure to resolve the debug state must answer false rather than defaulting to
|
|
2821
|
+
// exposure. The real accessor is exercised here with no usable application bound.
|
|
2822
|
+
$expose = new ReflectionMethod(TrackingNumberResource::class, 'exposesQrCodeContent');
|
|
2823
|
+
$expose->setAccessible(true);
|
|
2824
|
+
|
|
2825
|
+
$previous = Illuminate\Container\Container::getInstance();
|
|
2826
|
+
|
|
2827
|
+
try {
|
|
2828
|
+
// A container that is not an Application has no hasDebugModeEnabled().
|
|
2829
|
+
Illuminate\Container\Container::setInstance(new Illuminate\Container\Container());
|
|
2830
|
+
expect($expose->invoke(null))->toBeFalse();
|
|
2831
|
+
|
|
2832
|
+
// And an accessor that throws must also answer false rather than propagating —
|
|
2833
|
+
// a resource must not be able to fail serialization over a debug check.
|
|
2834
|
+
Illuminate\Container\Container::setInstance(new class extends Illuminate\Container\Container {
|
|
2835
|
+
public function hasDebugModeEnabled()
|
|
2836
|
+
{
|
|
2837
|
+
throw new RuntimeException('debug state unavailable');
|
|
2838
|
+
}
|
|
2839
|
+
});
|
|
2840
|
+
expect($expose->invoke(null))->toBeFalse();
|
|
2841
|
+
} finally {
|
|
2842
|
+
Illuminate\Container\Container::setInstance($previous);
|
|
2843
|
+
}
|
|
2844
|
+
});
|
|
@@ -231,8 +231,11 @@ test('FormRequest validators are present and authorize via api credential', func
|
|
|
231
231
|
$create = file_get_contents(dirname(__DIR__) . '/src/Http/Requests/CreateCustomerRequest.php');
|
|
232
232
|
$verify = file_get_contents(dirname(__DIR__) . '/src/Http/Requests/VerifyCreateCustomerRequest.php');
|
|
233
233
|
|
|
234
|
+
// These match against source text, so they have to be updated whenever the rule
|
|
235
|
+
// strings change. `code` is presence-only by design — the controller's own check
|
|
236
|
+
// (code + for + meta->identity) is strictly stronger than exists:verification_codes.
|
|
234
237
|
expect($create)
|
|
235
|
-
->toContain("'code' => 'required|
|
|
238
|
+
->toContain("'code' => 'required|string'")
|
|
236
239
|
->toContain("'password' => 'required|string|min:8'")
|
|
237
240
|
->and($verify)->toContain("'mode' => 'required|in:email,sms'");
|
|
238
241
|
});
|
|
@@ -84,8 +84,59 @@ class FleetOpsDriverAuthMailerFake
|
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Swap in a container that answers environment().
|
|
89
|
+
*
|
|
90
|
+
* verificationBypassMatches() asks app()->environment('production'), which the bare
|
|
91
|
+
* Illuminate\Container\Container used by this harness does not implement. Mirrors
|
|
92
|
+
* fleetopsCustomerHelperContainer() in CustomerControllerHelperSeamsTest.
|
|
93
|
+
*/
|
|
94
|
+
function fleetopsDriverAuthContainer(): void
|
|
95
|
+
{
|
|
96
|
+
$current = Illuminate\Container\Container::getInstance();
|
|
97
|
+
if (method_exists($current, 'hasDebugModeEnabled')) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
$replacement = new class extends Illuminate\Container\Container {
|
|
102
|
+
public function environment(...$environments)
|
|
103
|
+
{
|
|
104
|
+
if (empty($environments)) {
|
|
105
|
+
return 'testing';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
$checks = is_array($environments[0]) ? $environments[0] : $environments;
|
|
109
|
+
|
|
110
|
+
return in_array('testing', $checks, true);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
public function hasDebugModeEnabled()
|
|
114
|
+
{
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
foreach (['bindings', 'instances', 'aliases', 'abstractAliases', 'resolved', 'extenders', 'tags', 'contextual', 'scopedInstances', 'reboundCallbacks', 'globalBeforeResolvingCallbacks', 'globalResolvingCallbacks', 'globalAfterResolvingCallbacks', 'beforeResolvingCallbacks', 'resolvingCallbacks', 'afterResolvingCallbacks'] as $property) {
|
|
120
|
+
if (!property_exists(Illuminate\Container\Container::class, $property)) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
$reflection = new ReflectionProperty(Illuminate\Container\Container::class, $property);
|
|
124
|
+
$reflection->setAccessible(true);
|
|
125
|
+
if ($reflection->isInitialized($current)) {
|
|
126
|
+
$reflection->setValue($replacement, $reflection->getValue($current));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
Illuminate\Container\Container::setInstance($replacement);
|
|
131
|
+
Illuminate\Support\Facades\Facade::setFacadeApplication($replacement);
|
|
132
|
+
}
|
|
133
|
+
|
|
87
134
|
function fleetopsDriverAuthBoot(): SQLiteConnection
|
|
88
135
|
{
|
|
136
|
+
// Must run before the app()->instance() calls below, so those bindings land on
|
|
137
|
+
// the replacement container rather than the one it supersedes.
|
|
138
|
+
fleetopsDriverAuthContainer();
|
|
139
|
+
|
|
89
140
|
$connection = new SQLiteConnection(new PDO('sqlite::memory:'));
|
|
90
141
|
$resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
|
|
91
142
|
$resolver->setDefaultConnection('mysql');
|
|
@@ -231,6 +282,7 @@ test('code verification handles unknown users invalid codes bypass and success',
|
|
|
231
282
|
|
|
232
283
|
// Bypass code from config authenticates without a stored code
|
|
233
284
|
config()->set('fleetops.navigator.bypass_verification_code', '777777');
|
|
285
|
+
config()->set('fleetops.navigator.review_accounts', ['driver@example.test']);
|
|
234
286
|
$bypassed = $controller->verifyCode(Request::create('/x', 'POST', ['identity' => 'driver@example.test', 'code' => '777777']));
|
|
235
287
|
expect($bypassed)->toBeInstanceOf(DriverResource::class);
|
|
236
288
|
|
|
@@ -346,6 +398,7 @@ test('code verification reports token issuance failures', function () {
|
|
|
346
398
|
// A bypass code authenticates without a stored verification code, so the
|
|
347
399
|
// flow reaches token issuance; without the token table that send fails
|
|
348
400
|
config()->set('fleetops.navigator.bypass_verification_code', '777777');
|
|
401
|
+
config()->set('fleetops.navigator.review_accounts', ['driver@example.test']);
|
|
349
402
|
$connection->getSchemaBuilder()->drop('personal_access_tokens');
|
|
350
403
|
|
|
351
404
|
$response = $controller->verifyCode(Request::create('/x', 'POST', [
|
|
@@ -400,6 +453,7 @@ test('token persistence failures are reported to sentry', function () {
|
|
|
400
453
|
}
|
|
401
454
|
|
|
402
455
|
config()->set('fleetops.navigator.bypass_verification_code', '777777');
|
|
456
|
+
config()->set('fleetops.navigator.review_accounts', ['driver@example.test']);
|
|
403
457
|
$response = $controller->verifyCode(Request::create('/x', 'POST', [
|
|
404
458
|
'identity' => 'driver@example.test',
|
|
405
459
|
'code' => '777777',
|
|
@@ -416,14 +470,16 @@ test('current driver resolves the authenticated users driver profile', function
|
|
|
416
470
|
$connection->table('users')->insert(['uuid' => 'user-current-1', 'public_id' => 'user_current1', 'company_uuid' => 'company-1', 'name' => 'Current Driver', 'email' => 'current@example.test', 'type' => 'driver']);
|
|
417
471
|
$connection->table('drivers')->insert(['uuid' => 'driver-current-1', 'public_id' => 'driver_current1', 'company_uuid' => 'company-1', 'user_uuid' => 'user-current-1']);
|
|
418
472
|
|
|
419
|
-
// registerDevice without an id resolves the driver from the
|
|
473
|
+
// registerDevice without an id resolves the driver from the authenticated user
|
|
420
474
|
// rather than a route parameter, so exercise the real seam here — the
|
|
421
475
|
// contract probe overrides it and never runs this body
|
|
422
476
|
$currentDriver = new ReflectionMethod(DriverController::class, 'currentDriver');
|
|
423
477
|
$currentDriver->setAccessible(true);
|
|
424
478
|
|
|
425
479
|
$request = Request::create('/v1/drivers/register-device', 'POST');
|
|
426
|
-
|
|
480
|
+
// Auth::getUserFromSession only accepts a User model from the resolver — anything
|
|
481
|
+
// else falls through to the session, so this must be a real model, not a row object.
|
|
482
|
+
$request->setUserResolver(fn () => User::query()->where('uuid', 'user-current-1')->first());
|
|
427
483
|
|
|
428
484
|
$resolved = $currentDriver->invoke(new DriverController(), $request);
|
|
429
485
|
|
|
@@ -431,8 +487,31 @@ test('current driver resolves the authenticated users driver profile', function
|
|
|
431
487
|
->and($resolved->uuid)->toBe('driver-current-1');
|
|
432
488
|
});
|
|
433
489
|
|
|
490
|
+
test('current driver resolves from the session when the request has no user resolver', function () {
|
|
491
|
+
$connection = fleetopsDriverAuthBoot();
|
|
492
|
+
$connection->table('users')->insert(['uuid' => 'user-session-1', 'public_id' => 'user_session1', 'company_uuid' => 'company-1', 'name' => 'Session Driver', 'email' => 'session@example.test', 'type' => 'driver']);
|
|
493
|
+
$connection->table('drivers')->insert(['uuid' => 'driver-session-1', 'public_id' => 'driver_session1', 'company_uuid' => 'company-1', 'user_uuid' => 'user-session-1']);
|
|
494
|
+
|
|
495
|
+
// This is the production path for the public API. `fleetbase.api` authenticates with
|
|
496
|
+
// Auth::setSession($apiCredential), which writes session('user') but does NOT bind a
|
|
497
|
+
// user resolver — so reading $request->user() directly resolved nothing and
|
|
498
|
+
// POST /v1/drivers/register-device answered 404 for every consumer.
|
|
499
|
+
session(['user' => 'user-session-1']);
|
|
500
|
+
|
|
501
|
+
$currentDriver = new ReflectionMethod(DriverController::class, 'currentDriver');
|
|
502
|
+
$currentDriver->setAccessible(true);
|
|
503
|
+
|
|
504
|
+
$resolved = $currentDriver->invoke(new DriverController(), Request::create('/v1/drivers/register-device', 'POST'));
|
|
505
|
+
|
|
506
|
+
expect($resolved)->toBeInstanceOf(Driver::class)
|
|
507
|
+
->and($resolved->uuid)->toBe('driver-session-1');
|
|
508
|
+
});
|
|
509
|
+
|
|
434
510
|
test('current driver fails when the request has no matching driver', function () {
|
|
435
511
|
fleetopsDriverAuthBoot();
|
|
512
|
+
// Explicit, because the session survives between tests and a leftover `user` key
|
|
513
|
+
// would silently resolve a driver and make this assertion vacuous.
|
|
514
|
+
session(['user' => null]);
|
|
436
515
|
|
|
437
516
|
$currentDriver = new ReflectionMethod(DriverController::class, 'currentDriver');
|
|
438
517
|
$currentDriver->setAccessible(true);
|
|
@@ -10,6 +10,7 @@ use Illuminate\Database\ConnectionResolver;
|
|
|
10
10
|
use Illuminate\Database\Eloquent\Model as EloquentModel;
|
|
11
11
|
use Illuminate\Database\SQLiteConnection;
|
|
12
12
|
use Illuminate\Http\Request;
|
|
13
|
+
use Illuminate\Support\Facades\Hash;
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Covers the API DriverController protected session/persistence helpers
|
|
@@ -73,6 +74,35 @@ function fleetopsDriverSessionHelpersBoot(): SQLiteConnection
|
|
|
73
74
|
});
|
|
74
75
|
Illuminate\Support\Facades\DB::clearResolvedInstance('db');
|
|
75
76
|
|
|
77
|
+
/*
|
|
78
|
+
* Only the hashing contract ships with this package, not an implementation,
|
|
79
|
+
* so the User password mutator has nothing to call. Bind PHP's own hashing
|
|
80
|
+
* behind the contract: the point of the assertions below is that a password
|
|
81
|
+
* is stored hashed and verifies, not which algorithm did it.
|
|
82
|
+
*/
|
|
83
|
+
app()->instance('hash', new class implements Illuminate\Contracts\Hashing\Hasher {
|
|
84
|
+
public function info($hashedValue): array
|
|
85
|
+
{
|
|
86
|
+
return password_get_info($hashedValue);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
public function make($value, array $options = []): string
|
|
90
|
+
{
|
|
91
|
+
return password_hash($value, PASSWORD_BCRYPT);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
public function check($value, $hashedValue, array $options = []): bool
|
|
95
|
+
{
|
|
96
|
+
return is_string($hashedValue) && password_verify($value, $hashedValue);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
public function needsRehash($hashedValue, array $options = []): bool
|
|
100
|
+
{
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
Hash::clearResolvedInstance('hash');
|
|
105
|
+
|
|
76
106
|
$schema = $connection->getSchemaBuilder();
|
|
77
107
|
app()->instance('db.schema', $schema);
|
|
78
108
|
$tables = [
|
|
@@ -163,6 +193,18 @@ test('user and driver persistence helpers write records', function () {
|
|
|
163
193
|
expect($user)->toBeInstanceOf(User::class)
|
|
164
194
|
->and($connection->table('users')->where('name', 'New User')->count())->toBe(1);
|
|
165
195
|
|
|
196
|
+
/*
|
|
197
|
+
* `password` is guarded on User, so mass assignment drops it without a
|
|
198
|
+
* word. The create endpoint has always documented and validated one, so
|
|
199
|
+
* unless the helper sets it explicitly a driver created through the API
|
|
200
|
+
* can never sign in with the password chosen for them.
|
|
201
|
+
*/
|
|
202
|
+
$withPassword = $probe->callHelper('createUser', ['name' => 'Password User', 'password' => 'seeded-password']);
|
|
203
|
+
$stored = $connection->table('users')->where('name', 'Password User')->value('password');
|
|
204
|
+
expect(Hash::check('seeded-password', $withPassword->password))->toBeTrue()
|
|
205
|
+
->and($stored)->not->toBeNull()
|
|
206
|
+
->and($stored)->not->toBe('seeded-password');
|
|
207
|
+
|
|
166
208
|
$driver = $probe->callHelper('createDriver', ['company_uuid' => '22222222-2222-4222-8222-222222222222', 'user_uuid' => 'user-1']);
|
|
167
209
|
expect($driver)->toBeInstanceOf(Driver::class);
|
|
168
210
|
|
|
@@ -173,6 +215,25 @@ test('user and driver persistence helpers write records', function () {
|
|
|
173
215
|
->and($connection->table('user_devices')->count())->toBe(1);
|
|
174
216
|
});
|
|
175
217
|
|
|
218
|
+
test('the driver user loader reads the password column the relation omits', function () {
|
|
219
|
+
$connection = fleetopsDriverSessionHelpersBoot();
|
|
220
|
+
$probe = new FleetOpsDriverSessionHelpersProbe();
|
|
221
|
+
|
|
222
|
+
$connection->table('users')->where('uuid', 'user-1')->update(['password' => 'stored-hash']);
|
|
223
|
+
|
|
224
|
+
/*
|
|
225
|
+
* The `user` relation selects a named subset of columns and `password` is
|
|
226
|
+
* not among them, so a password comparison made through it fails for
|
|
227
|
+
* everyone regardless of what they typed. Anything checking a password has
|
|
228
|
+
* to load the record itself.
|
|
229
|
+
*/
|
|
230
|
+
$driver = Driver::where('public_id', 'driver_one')->first();
|
|
231
|
+
expect($probe->callHelper('findDriverUserRecord', $driver)->password)->toBe('stored-hash');
|
|
232
|
+
|
|
233
|
+
$unlinked = new Driver();
|
|
234
|
+
expect($probe->callHelper('findDriverUserRecord', $unlinked))->toBeNull();
|
|
235
|
+
});
|
|
236
|
+
|
|
176
237
|
test('lookup utility and resource helpers resolve records and wrappers', function () {
|
|
177
238
|
fleetopsDriverSessionHelpersBoot();
|
|
178
239
|
$probe = new FleetOpsDriverSessionHelpersProbe();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<?php
|
|
2
2
|
|
|
3
3
|
use Fleetbase\FleetOps\Http\Controllers\Api\v1\GeofenceController;
|
|
4
|
+
use Fleetbase\FleetOps\Models\Driver;
|
|
4
5
|
use Fleetbase\FleetOps\Models\GeofenceEventLog;
|
|
5
6
|
use Illuminate\Http\Request;
|
|
6
7
|
use Illuminate\Support\Carbon;
|
|
@@ -183,6 +184,16 @@ class FleetOpsGeofenceControllerFake extends GeofenceController
|
|
|
183
184
|
public ?FleetOpsGeofenceQueryFake $nextEventQuery = null;
|
|
184
185
|
public array $serializedEvents = [];
|
|
185
186
|
|
|
187
|
+
public ?Fleetbase\FleetOps\Models\Driver $historyDriver = null;
|
|
188
|
+
public array $historyDriverLookups = [];
|
|
189
|
+
|
|
190
|
+
protected function findDriverForHistory(string $driverId): ?Fleetbase\FleetOps\Models\Driver
|
|
191
|
+
{
|
|
192
|
+
$this->historyDriverLookups[] = $driverId;
|
|
193
|
+
|
|
194
|
+
return $this->historyDriver;
|
|
195
|
+
}
|
|
196
|
+
|
|
186
197
|
protected function geofenceEventLogQuery(?string $companyUuid): mixed
|
|
187
198
|
{
|
|
188
199
|
$query = $this->nextEventQuery ?? new FleetOpsGeofenceQueryFake($companyUuid);
|
|
@@ -351,10 +362,17 @@ test('api geofence driver history applies driver filter and caps pagination', fu
|
|
|
351
362
|
'per_page' => 75,
|
|
352
363
|
]);
|
|
353
364
|
|
|
354
|
-
|
|
365
|
+
// The endpoint is addressed by public_id and resolves the driver itself; the query
|
|
366
|
+
// still filters on the driver's uuid.
|
|
367
|
+
$historyDriver = new Fleetbase\FleetOps\Models\Driver();
|
|
368
|
+
$historyDriver->setRawAttributes(['uuid' => 'driver-9', 'public_id' => 'driver_public9'], true);
|
|
369
|
+
$controller->historyDriver = $historyDriver;
|
|
370
|
+
|
|
371
|
+
$controller->driverHistory($request, 'driver_public9');
|
|
355
372
|
$query = $controller->eventQueries[0];
|
|
356
373
|
|
|
357
|
-
expect($
|
|
374
|
+
expect($controller->historyDriverLookups)->toBe(['driver_public9'])
|
|
375
|
+
->and($query->companyUuid)->toBe('company-4')
|
|
358
376
|
->and($query->calls)->toContain(
|
|
359
377
|
['where', 'driver_uuid', 'driver-9'],
|
|
360
378
|
['with', ['driver.vehicle', 'vehicle', 'order']],
|
|
@@ -362,3 +380,82 @@ test('api geofence driver history applies driver filter and caps pagination', fu
|
|
|
362
380
|
['paginate', 75],
|
|
363
381
|
);
|
|
364
382
|
});
|
|
383
|
+
|
|
384
|
+
test('driver history answers 404 when the driver does not resolve', function () {
|
|
385
|
+
// The endpoint's own not-found branch: findDriverForHistory returns null and the
|
|
386
|
+
// request must not proceed to a query keyed on nothing.
|
|
387
|
+
$controller = new FleetOpsGeofenceControllerFake();
|
|
388
|
+
$controller->historyDriver = null;
|
|
389
|
+
|
|
390
|
+
$response = $controller->driverHistory(Request::create('/geofences/driver/driver_missing/history', 'GET'), 'driver_missing');
|
|
391
|
+
|
|
392
|
+
expect($response->getStatusCode())->toBe(404)
|
|
393
|
+
->and($response->getData(true))->toBe(['error' => 'Driver resource not found.'])
|
|
394
|
+
->and($controller->historyDriverLookups)->toBe(['driver_missing'])
|
|
395
|
+
// and no event query was ever built
|
|
396
|
+
->and($controller->eventQueries)->toBe([]);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test('driver history resolves by public id, and by uuid only for internal requests', function () {
|
|
400
|
+
// The contract tests above stub findDriverForHistory(), so the real lookup needs its
|
|
401
|
+
// own coverage. It is the whole point of the change: the public endpoint used to take
|
|
402
|
+
// a uuid, which the public API never issues.
|
|
403
|
+
$connection = new Illuminate\Database\SQLiteConnection(new PDO('sqlite::memory:'));
|
|
404
|
+
$resolver = new Illuminate\Database\ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
|
|
405
|
+
$resolver->setDefaultConnection('mysql');
|
|
406
|
+
Illuminate\Database\Eloquent\Model::setConnectionResolver($resolver);
|
|
407
|
+
|
|
408
|
+
$schema = $connection->getSchemaBuilder();
|
|
409
|
+
// Driver carries a global scope requiring a related user, so both tables are needed.
|
|
410
|
+
$schema->create('users', function ($table) {
|
|
411
|
+
$table->increments('id');
|
|
412
|
+
$table->string('uuid')->nullable();
|
|
413
|
+
$table->timestamp('deleted_at')->nullable();
|
|
414
|
+
$table->timestamps();
|
|
415
|
+
});
|
|
416
|
+
$schema->create('drivers', function ($table) {
|
|
417
|
+
$table->increments('id');
|
|
418
|
+
$table->string('uuid')->nullable();
|
|
419
|
+
$table->string('public_id')->nullable();
|
|
420
|
+
$table->string('company_uuid')->nullable();
|
|
421
|
+
$table->string('user_uuid')->nullable();
|
|
422
|
+
$table->timestamp('deleted_at')->nullable();
|
|
423
|
+
$table->timestamps();
|
|
424
|
+
});
|
|
425
|
+
$connection->table('users')->insert(['uuid' => 'user-uuid-1']);
|
|
426
|
+
$connection->table('drivers')->insert([
|
|
427
|
+
'uuid' => 'driver-uuid-1',
|
|
428
|
+
'public_id' => 'driver_public1',
|
|
429
|
+
'user_uuid' => 'user-uuid-1',
|
|
430
|
+
]);
|
|
431
|
+
|
|
432
|
+
$resolve = new ReflectionMethod(GeofenceController::class, 'findDriverForHistory');
|
|
433
|
+
$resolve->setAccessible(true);
|
|
434
|
+
$controller = new GeofenceController();
|
|
435
|
+
|
|
436
|
+
$byPublicId = $resolve->invoke($controller, 'driver_public1');
|
|
437
|
+
// A uuid is NOT accepted on a public request — that is the contract this restores.
|
|
438
|
+
$byUuidPublic = $resolve->invoke($controller, 'driver-uuid-1');
|
|
439
|
+
$missing = $resolve->invoke($controller, 'driver_nope');
|
|
440
|
+
|
|
441
|
+
expect($byPublicId)->toBeInstanceOf(Driver::class)
|
|
442
|
+
->and($byPublicId->uuid)->toBe('driver-uuid-1')
|
|
443
|
+
->and($byUuidPublic)->toBeNull()
|
|
444
|
+
->and($missing)->toBeNull();
|
|
445
|
+
|
|
446
|
+
// The console reaches the same method through the internal route with a uuid.
|
|
447
|
+
// Http::isInternalRequest() decides from the ROUTE's uri — any route with an "int"
|
|
448
|
+
// segment — so the request has to carry one, not a header.
|
|
449
|
+
$internalRequest = Request::create('/fleet-ops/int/v1/geofences/driver/driver-uuid-1/history', 'GET');
|
|
450
|
+
$internalRequest->setRouteResolver(fn () => new Illuminate\Routing\Route(
|
|
451
|
+
['GET'],
|
|
452
|
+
'fleet-ops/int/v1/geofences/driver/{driverUuid}/history',
|
|
453
|
+
['uses' => fn () => null]
|
|
454
|
+
));
|
|
455
|
+
app()->instance('request', $internalRequest);
|
|
456
|
+
|
|
457
|
+
$byUuidInternal = $resolve->invoke($controller, 'driver-uuid-1');
|
|
458
|
+
|
|
459
|
+
expect($byUuidInternal)->toBeInstanceOf(Driver::class)
|
|
460
|
+
->and($byUuidInternal->public_id)->toBe('driver_public1');
|
|
461
|
+
});
|
|
@@ -138,8 +138,14 @@ test('tracking number helpers look up owners create records and wrap resources',
|
|
|
138
138
|
expect($qrModel)->not->toBeNull()
|
|
139
139
|
->and($helper('qrModelResource', $qrModel))->toBeInstanceOf(Fleetbase\FleetOps\Http\Resources\v1\Entity::class);
|
|
140
140
|
|
|
141
|
-
//
|
|
142
|
-
//
|
|
141
|
+
// A table with no eloquent mapping resolves to null rather than a raw row.
|
|
142
|
+
//
|
|
143
|
+
// The raw-row fallback was removed: it passed the ARRAY of table names to
|
|
144
|
+
// DB::table(), which stringified it to the literal table "Array" and threw
|
|
145
|
+
// SQLSTATE[42S02], so every unresolvable QR code answered 500 instead of the 400 the
|
|
146
|
+
// caller already handles. It could not have helped either way — qrModelResource()
|
|
147
|
+
// serializes through a typed resource keyed on the model class, and a raw stdClass
|
|
148
|
+
// row has none.
|
|
143
149
|
$connection->table('tracking_statuses')->insert([
|
|
144
150
|
'uuid' => '44444444-4444-4444-8444-444444444403',
|
|
145
151
|
'public_id' => 'tracking_status_tnhelper1',
|
|
@@ -147,8 +153,8 @@ test('tracking number helpers look up owners create records and wrap resources',
|
|
|
147
153
|
'status' => 'Dispatched',
|
|
148
154
|
]);
|
|
149
155
|
|
|
150
|
-
$
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
->and($
|
|
156
|
+
expect($helper('findQrModel', ['tracking_statuses'], ['public_id' => 'tracking_status_tnhelper1']))->toBeNull()
|
|
157
|
+
// And a mapped table that simply matches nothing is also null, which is the path
|
|
158
|
+
// an unknown or unresolved code takes.
|
|
159
|
+
->and($helper('findQrModel', ['entities', 'orders'], ['uuid' => '{{qr_code}}']))->toBeNull();
|
|
154
160
|
});
|
|
@@ -307,6 +307,11 @@ class FleetOpsInternalDriverAuthControllerProbe extends DriverController
|
|
|
307
307
|
|
|
308
308
|
public static function resetProbe(): void
|
|
309
309
|
{
|
|
310
|
+
// verifyCode() defers to DriverController::verificationBypassMatches(), which
|
|
311
|
+
// asks app()->environment('production'). The bare container this harness binds
|
|
312
|
+
// has no such method, so swap it before any verify-code test runs.
|
|
313
|
+
fleetopsInternalDriverAuthContainer();
|
|
314
|
+
|
|
310
315
|
static::$loginUser = null;
|
|
311
316
|
static::$verificationUser = null;
|
|
312
317
|
static::$loginDriver = null;
|
|
@@ -531,6 +536,53 @@ function fleetopsInternalDriverExportSelections(DriverExport $export): array
|
|
|
531
536
|
return $property->getValue($export);
|
|
532
537
|
}
|
|
533
538
|
|
|
539
|
+
/**
|
|
540
|
+
* Swap in a container that answers environment().
|
|
541
|
+
*
|
|
542
|
+
* Mirrors fleetopsCustomerHelperContainer() in CustomerControllerHelperSeamsTest and
|
|
543
|
+
* fleetopsDriverAuthContainer() in Api/DriverControllerAuthFlowsTest. Idempotent, so
|
|
544
|
+
* resetProbe() can call it per test.
|
|
545
|
+
*/
|
|
546
|
+
function fleetopsInternalDriverAuthContainer(): void
|
|
547
|
+
{
|
|
548
|
+
$current = Illuminate\Container\Container::getInstance();
|
|
549
|
+
if (method_exists($current, 'hasDebugModeEnabled')) {
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
$replacement = new class extends Illuminate\Container\Container {
|
|
554
|
+
public function environment(...$environments)
|
|
555
|
+
{
|
|
556
|
+
if (empty($environments)) {
|
|
557
|
+
return 'testing';
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
$checks = is_array($environments[0]) ? $environments[0] : $environments;
|
|
561
|
+
|
|
562
|
+
return in_array('testing', $checks, true);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
public function hasDebugModeEnabled()
|
|
566
|
+
{
|
|
567
|
+
return true;
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
foreach (['bindings', 'instances', 'aliases', 'abstractAliases', 'resolved', 'extenders', 'tags', 'contextual', 'scopedInstances', 'reboundCallbacks', 'globalBeforeResolvingCallbacks', 'globalResolvingCallbacks', 'globalAfterResolvingCallbacks', 'beforeResolvingCallbacks', 'resolvingCallbacks', 'afterResolvingCallbacks'] as $property) {
|
|
572
|
+
if (!property_exists(Illuminate\Container\Container::class, $property)) {
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
$reflection = new ReflectionProperty(Illuminate\Container\Container::class, $property);
|
|
576
|
+
$reflection->setAccessible(true);
|
|
577
|
+
if ($reflection->isInitialized($current)) {
|
|
578
|
+
$reflection->setValue($replacement, $reflection->getValue($current));
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
Illuminate\Container\Container::setInstance($replacement);
|
|
583
|
+
Illuminate\Support\Facades\Facade::setFacadeApplication($replacement);
|
|
584
|
+
}
|
|
585
|
+
|
|
534
586
|
function fleetopsInternalDriverUseHelperDatabase(): SQLiteConnection
|
|
535
587
|
{
|
|
536
588
|
$connection = new SQLiteConnection(new PDO('sqlite::memory:'));
|
|
@@ -993,6 +1045,9 @@ test('internal driver controller login with phone normalizes identity and handle
|
|
|
993
1045
|
|
|
994
1046
|
test('internal driver controller verify code covers missing user invalid code and missing driver branches', function () {
|
|
995
1047
|
app('config')->set('fleetops.navigator.bypass_verification_code', '000000');
|
|
1048
|
+
// The bypass is only honoured for a listed identity. Both forms because
|
|
1049
|
+
// verifyCode normalises a non-email identity through static::phone().
|
|
1050
|
+
app('config')->set('fleetops.navigator.review_accounts', ['15551234567', '+15551234567', 'driver@example.test']);
|
|
996
1051
|
|
|
997
1052
|
FleetOpsInternalDriverAuthControllerProbe::resetProbe();
|
|
998
1053
|
$controller = new FleetOpsInternalDriverAuthControllerProbe();
|
|
@@ -1035,6 +1090,9 @@ test('internal driver controller verify code covers missing user invalid code an
|
|
|
1035
1090
|
|
|
1036
1091
|
test('internal driver controller verify code returns driver resource and handles token errors', function () {
|
|
1037
1092
|
app('config')->set('fleetops.navigator.bypass_verification_code', '000000');
|
|
1093
|
+
// The bypass is only honoured for a listed identity. Both forms because
|
|
1094
|
+
// verifyCode normalises a non-email identity through static::phone().
|
|
1095
|
+
app('config')->set('fleetops.navigator.review_accounts', ['15551234567', '+15551234567', 'driver@example.test']);
|
|
1038
1096
|
|
|
1039
1097
|
FleetOpsInternalDriverAuthControllerProbe::resetProbe();
|
|
1040
1098
|
FleetOpsInternalDriverAuthControllerProbe::$verificationUser = fleetopsInternalDriverAuthUser();
|