@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
|
@@ -960,3 +960,171 @@ test('api customer controller handles verification delivery fallbacks and profil
|
|
|
960
960
|
'name' => 'explode',
|
|
961
961
|
]))))->toBe(['error' => 'update failed']);
|
|
962
962
|
});
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* Run a callback with an app container that supports environment().
|
|
966
|
+
*
|
|
967
|
+
* The harness binds a bare Illuminate\Container\Container, which has no
|
|
968
|
+
* environment() — so CustomerController::verificationBypassMatches fatals with
|
|
969
|
+
* "Call to undefined method" without this. Mirrors the swap in
|
|
970
|
+
* NotificationAndMailContractsTest, but carries every existing binding across so
|
|
971
|
+
* the controller can still resolve config/request/db.
|
|
972
|
+
*/
|
|
973
|
+
function fleetopsApiCustomerWithEnvironment(string $environment, callable $callback): mixed
|
|
974
|
+
{
|
|
975
|
+
$previousApp = Illuminate\Container\Container::getInstance();
|
|
976
|
+
$app = new class extends Illuminate\Container\Container {
|
|
977
|
+
public string $fleetopsEnvironment = 'testing';
|
|
978
|
+
|
|
979
|
+
public function environment(...$environments)
|
|
980
|
+
{
|
|
981
|
+
if (empty($environments)) {
|
|
982
|
+
return $this->fleetopsEnvironment;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
$environments = is_array($environments[0]) ? $environments[0] : $environments;
|
|
986
|
+
|
|
987
|
+
return in_array($this->fleetopsEnvironment, $environments, true);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
public function hasDebugModeEnabled()
|
|
991
|
+
{
|
|
992
|
+
return false;
|
|
993
|
+
}
|
|
994
|
+
};
|
|
995
|
+
$app->fleetopsEnvironment = $environment;
|
|
996
|
+
|
|
997
|
+
$reflection = new ReflectionClass(Illuminate\Container\Container::class);
|
|
998
|
+
foreach (['bindings', 'instances', 'aliases', 'abstractAliases', 'resolved', 'scopedInstances'] as $property) {
|
|
999
|
+
if (!$reflection->hasProperty($property)) {
|
|
1000
|
+
continue;
|
|
1001
|
+
}
|
|
1002
|
+
$handle = $reflection->getProperty($property);
|
|
1003
|
+
$handle->setAccessible(true);
|
|
1004
|
+
$handle->setValue($app, $handle->getValue($previousApp));
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
Illuminate\Container\Container::setInstance($app);
|
|
1008
|
+
|
|
1009
|
+
try {
|
|
1010
|
+
return $callback();
|
|
1011
|
+
} finally {
|
|
1012
|
+
Illuminate\Container\Container::setInstance($previousApp);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
test('api customer controller ignores the verification bypass unless it is configured', function () {
|
|
1017
|
+
// Unset and empty-string are both inert. Without this the bypass would be a
|
|
1018
|
+
// standing hole in every default install, which is the entire risk of shipping one.
|
|
1019
|
+
foreach ([null, ''] as $bypassCode) {
|
|
1020
|
+
config(['fleetops.customers.verification_bypass_code' => $bypassCode]);
|
|
1021
|
+
|
|
1022
|
+
fleetopsApiCustomerWithEnvironment('local', function () {
|
|
1023
|
+
$create = fleetopsApiCustomerController();
|
|
1024
|
+
$create->verificationExists = false;
|
|
1025
|
+
$verify = fleetopsApiCustomerController();
|
|
1026
|
+
$verify->verificationExists = false;
|
|
1027
|
+
$reset = fleetopsApiCustomerController();
|
|
1028
|
+
$reset->verificationCode = null;
|
|
1029
|
+
|
|
1030
|
+
expect(fleetopsApiCustomerJson($create->create(new CreateCustomerRequest([
|
|
1031
|
+
'code' => '000000',
|
|
1032
|
+
'identity' => 'jane@example.test',
|
|
1033
|
+
]))))->toBe(['error' => 'Invalid verification code provided.'])
|
|
1034
|
+
->and(fleetopsApiCustomerJson($verify->verifyCode(Request::create('/v1/customers/verify-code', 'POST', [
|
|
1035
|
+
'identity' => 'jane@example.test',
|
|
1036
|
+
'code' => '000000',
|
|
1037
|
+
]))))->toBe(['error' => 'Invalid verification code.'])
|
|
1038
|
+
->and(fleetopsApiCustomerJson($reset->resetPassword(Request::create('/v1/customers/reset-password', 'POST', [
|
|
1039
|
+
'identity' => 'jane@example.test',
|
|
1040
|
+
'code' => '000000',
|
|
1041
|
+
'password' => 'password-secret',
|
|
1042
|
+
]))))->toBe(['error' => 'Invalid reset code.']);
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
config(['fleetops.customers.verification_bypass_code' => null]);
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
test('api customer controller accepts a configured verification bypass for a listed review account', function () {
|
|
1050
|
+
config([
|
|
1051
|
+
'fleetops.customers.verification_bypass_code' => '000000',
|
|
1052
|
+
'fleetops.customers.review_accounts' => ['jane@example.test'],
|
|
1053
|
+
]);
|
|
1054
|
+
|
|
1055
|
+
// Deliberately production. App store reviewers test a release build against
|
|
1056
|
+
// production, so refusing the bypass there made review impossible — that is the
|
|
1057
|
+
// behaviour this replaces. Safety now comes from the allowlist, not the environment.
|
|
1058
|
+
fleetopsApiCustomerWithEnvironment('production', function () {
|
|
1059
|
+
$create = fleetopsApiCustomerController();
|
|
1060
|
+
$create->verificationExists = false;
|
|
1061
|
+
$verify = fleetopsApiCustomerController();
|
|
1062
|
+
$verify->verificationExists = false;
|
|
1063
|
+
// No VerificationCode row on the bypass path — resetPassword must not fatal
|
|
1064
|
+
// calling ->delete() on null, and must still revoke existing sessions.
|
|
1065
|
+
$reset = fleetopsApiCustomerController();
|
|
1066
|
+
$reset->verificationCode = null;
|
|
1067
|
+
// A non-matching code is still rejected while the bypass is live.
|
|
1068
|
+
$wrong = fleetopsApiCustomerController();
|
|
1069
|
+
$wrong->verificationExists = false;
|
|
1070
|
+
|
|
1071
|
+
expect($create->create(new CreateCustomerRequest([
|
|
1072
|
+
'code' => '000000',
|
|
1073
|
+
'identity' => 'jane@example.test',
|
|
1074
|
+
'name' => 'Jane',
|
|
1075
|
+
'password' => 'password-secret',
|
|
1076
|
+
])))->toMatchArray(['resource' => 'customer', 'token' => 'plain-token'])
|
|
1077
|
+
->and($verify->verifyCode(Request::create('/v1/customers/verify-code', 'POST', [
|
|
1078
|
+
'identity' => 'jane@example.test',
|
|
1079
|
+
'code' => '000000',
|
|
1080
|
+
])))->toMatchArray(['resource' => 'customer', 'token' => 'plain-token'])
|
|
1081
|
+
->and(fleetopsApiCustomerJson($reset->resetPassword(Request::create('/v1/customers/reset-password', 'POST', [
|
|
1082
|
+
'identity' => 'jane@example.test',
|
|
1083
|
+
'code' => '000000',
|
|
1084
|
+
'password' => 'password-secret',
|
|
1085
|
+
]))))->toBe(['status' => 'ok'])
|
|
1086
|
+
->and($reset->genericUser->tokensDeleted)->toBeTrue()
|
|
1087
|
+
->and(fleetopsApiCustomerJson($wrong->create(new CreateCustomerRequest([
|
|
1088
|
+
'code' => '999999',
|
|
1089
|
+
'identity' => 'jane@example.test',
|
|
1090
|
+
]))))->toBe(['error' => 'Invalid verification code provided.']);
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
config(['fleetops.customers.verification_bypass_code' => null, 'fleetops.customers.review_accounts' => []]);
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
test('api customer controller refuses the verification bypass for an identity that is not listed', function () {
|
|
1097
|
+
// The point of the allowlist: holding the code is not sufficient. Previously
|
|
1098
|
+
// anyone who learned it could authenticate as any customer.
|
|
1099
|
+
foreach ([['someone-else@example.test'], []] as $reviewAccounts) {
|
|
1100
|
+
config([
|
|
1101
|
+
'fleetops.customers.verification_bypass_code' => '000000',
|
|
1102
|
+
'fleetops.customers.review_accounts' => $reviewAccounts,
|
|
1103
|
+
]);
|
|
1104
|
+
|
|
1105
|
+
fleetopsApiCustomerWithEnvironment('local', function () {
|
|
1106
|
+
$create = fleetopsApiCustomerController();
|
|
1107
|
+
$create->verificationExists = false;
|
|
1108
|
+
$verify = fleetopsApiCustomerController();
|
|
1109
|
+
$verify->verificationExists = false;
|
|
1110
|
+
$reset = fleetopsApiCustomerController();
|
|
1111
|
+
$reset->verificationCode = null;
|
|
1112
|
+
|
|
1113
|
+
expect(fleetopsApiCustomerJson($create->create(new CreateCustomerRequest([
|
|
1114
|
+
'code' => '000000',
|
|
1115
|
+
'identity' => 'jane@example.test',
|
|
1116
|
+
]))))->toBe(['error' => 'Invalid verification code provided.'])
|
|
1117
|
+
->and(fleetopsApiCustomerJson($verify->verifyCode(Request::create('/v1/customers/verify-code', 'POST', [
|
|
1118
|
+
'identity' => 'jane@example.test',
|
|
1119
|
+
'code' => '000000',
|
|
1120
|
+
]))))->toBe(['error' => 'Invalid verification code.'])
|
|
1121
|
+
->and(fleetopsApiCustomerJson($reset->resetPassword(Request::create('/v1/customers/reset-password', 'POST', [
|
|
1122
|
+
'identity' => 'jane@example.test',
|
|
1123
|
+
'code' => '000000',
|
|
1124
|
+
'password' => 'password-secret',
|
|
1125
|
+
]))))->toBe(['error' => 'Invalid reset code.']);
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
config(['fleetops.customers.verification_bypass_code' => null, 'fleetops.customers.review_accounts' => []]);
|
|
1130
|
+
});
|
|
@@ -550,6 +550,38 @@ test('api driver controller registers a device for the authenticated driver with
|
|
|
550
550
|
]);
|
|
551
551
|
});
|
|
552
552
|
|
|
553
|
+
test('api driver controller falls back to the container request when the router injects null', function () {
|
|
554
|
+
// Laravel's ResolvesRouteDependencies::transformDependency() returns null — it does
|
|
555
|
+
// NOT resolve from the container — for any class-typed parameter that declares a
|
|
556
|
+
// default value. registerDevice() has to declare one, because the internal controller
|
|
557
|
+
// delegates with an id only, so every routed call arrived with $request === null.
|
|
558
|
+
// Every other test here passes a request explicitly, which is why they stayed green
|
|
559
|
+
// while POST /v1/drivers/{id}/register-device answered 500.
|
|
560
|
+
$driver = new FleetOpsApiDriverFake();
|
|
561
|
+
$driver->setRawAttributes([
|
|
562
|
+
'uuid' => 'driver-uuid',
|
|
563
|
+
'public_id' => 'driver_public',
|
|
564
|
+
'user_uuid' => 'user-uuid',
|
|
565
|
+
], true);
|
|
566
|
+
|
|
567
|
+
$controller = new FleetOpsApiDriverControllerProbe();
|
|
568
|
+
$controller->driver = $driver;
|
|
569
|
+
|
|
570
|
+
$bound = new FleetOpsApiDriverRegisterDeviceRequest(['token' => 'push-token', 'platform' => 'android']);
|
|
571
|
+
app()->instance('request', $bound);
|
|
572
|
+
|
|
573
|
+
expect($controller->registerDevice('driver_public', null))->toBe([
|
|
574
|
+
'json' => ['device' => 'device_public'],
|
|
575
|
+
'status' => 200,
|
|
576
|
+
])
|
|
577
|
+
->and($controller->deviceCreates)->toBe([
|
|
578
|
+
[
|
|
579
|
+
['token' => 'push-token', 'platform' => 'android'],
|
|
580
|
+
['user_uuid' => 'user-uuid', 'platform' => 'android', 'token' => 'push-token', 'status' => 'active'],
|
|
581
|
+
],
|
|
582
|
+
]);
|
|
583
|
+
});
|
|
584
|
+
|
|
553
585
|
test('api driver controller reports missing driver branches', function () {
|
|
554
586
|
$controller = new FleetOpsApiDriverControllerProbe();
|
|
555
587
|
$controller->driverNotFound = true;
|
|
@@ -2697,6 +2697,10 @@ test('tracking number and waypoint webhook resources serialize tracking contract
|
|
|
2697
2697
|
'scheduled_at' => '2026-07-30 09:00:00',
|
|
2698
2698
|
]);
|
|
2699
2699
|
|
|
2700
|
+
// The decoded QR content is a debug-only field and must never ride along on a webhook,
|
|
2701
|
+
// which is a separate literal payload rather than a filtered toArray().
|
|
2702
|
+
expect(array_key_exists('qr_code_content', (new TrackingNumberResource($trackingNumber))->toWebhookPayload()))->toBeFalse();
|
|
2703
|
+
|
|
2700
2704
|
expect((new TrackingNumberResource($trackingNumber))->toWebhookPayload())->toMatchArray([
|
|
2701
2705
|
'id' => 'fixture_public',
|
|
2702
2706
|
'tracking_number' => 'TN-ZERO',
|
|
@@ -2743,3 +2747,92 @@ test('tracking number and waypoint webhook resources serialize tracking contract
|
|
|
2743
2747
|
'meta' => ['sequence' => 2],
|
|
2744
2748
|
]);
|
|
2745
2749
|
});
|
|
2750
|
+
|
|
2751
|
+
test('tracking number resource publishes the qr code content only in debug mode', function () {
|
|
2752
|
+
// qr_code is a base64 PNG generated from owner_uuid, and the endpoints that consume a
|
|
2753
|
+
// scanned code match on that uuid. Debug builds publish the value beside the image so
|
|
2754
|
+
// an automated client can follow the flow without decoding a PNG; production must not.
|
|
2755
|
+
$trackingNumber = fleetopsCompactResourceFixture([
|
|
2756
|
+
'tracking_number' => 'TN-DEBUG',
|
|
2757
|
+
'owner_uuid' => 'owner-uuid-under-the-qr',
|
|
2758
|
+
'owner_type' => 'Fleetbase\\FleetOps\\Models\\Order',
|
|
2759
|
+
'region' => 'sg',
|
|
2760
|
+
'qr_code' => 'qr-data',
|
|
2761
|
+
'barcode' => 'barcode-data',
|
|
2762
|
+
'last_status' => 'created',
|
|
2763
|
+
'last_status_code' => 'CREATED',
|
|
2764
|
+
]);
|
|
2765
|
+
$request = Request::create('/v1/tracking-numbers', 'GET');
|
|
2766
|
+
|
|
2767
|
+
// The full resource builds a console url, which needs app()->environment(). The bare
|
|
2768
|
+
// container this suite runs on has no such method, which is why the tests above only
|
|
2769
|
+
// cover the index resource and the webhook payload.
|
|
2770
|
+
$previousContainer = Illuminate\Container\Container::getInstance();
|
|
2771
|
+
$container = new class extends Illuminate\Container\Container {
|
|
2772
|
+
public bool $debugMode = false;
|
|
2773
|
+
|
|
2774
|
+
public function environment(...$environments)
|
|
2775
|
+
{
|
|
2776
|
+
return in_array('testing', $environments, true) || $environments === [] ? 'testing' : false;
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
// Stands in for the framework's Application::hasDebugModeEnabled().
|
|
2780
|
+
public function hasDebugModeEnabled()
|
|
2781
|
+
{
|
|
2782
|
+
return $this->debugMode;
|
|
2783
|
+
}
|
|
2784
|
+
};
|
|
2785
|
+
$container->instance('config', new Illuminate\Config\Repository([
|
|
2786
|
+
'fleetbase' => ['console' => ['host' => 'console.fleetbase.test', 'subdomain' => null, 'secure' => true]],
|
|
2787
|
+
]));
|
|
2788
|
+
Illuminate\Container\Container::setInstance($container);
|
|
2789
|
+
|
|
2790
|
+
// Drive the REAL accessor through the container rather than overriding it, so the
|
|
2791
|
+
// debug check itself is exercised and not just the resource's use of it.
|
|
2792
|
+
$container->debugMode = true;
|
|
2793
|
+
$withDebug = new TrackingNumberResource($trackingNumber);
|
|
2794
|
+
|
|
2795
|
+
try {
|
|
2796
|
+
$debugPayload = $withDebug->resolve($request);
|
|
2797
|
+
|
|
2798
|
+
$container->debugMode = false;
|
|
2799
|
+
$productionPayload = (new TrackingNumberResource($trackingNumber))->resolve($request);
|
|
2800
|
+
} finally {
|
|
2801
|
+
Illuminate\Container\Container::setInstance($previousContainer);
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
// Present and exactly the value the QR was generated from — not a public id.
|
|
2805
|
+
expect($debugPayload['qr_code_content'])->toBe('owner-uuid-under-the-qr')
|
|
2806
|
+
->and($debugPayload['qr_code'])->toBe('qr-data')
|
|
2807
|
+
// Absent, not null: `when()` drops the key entirely, so a production response is
|
|
2808
|
+
// byte-identical to one from before the field existed.
|
|
2809
|
+
->and(array_key_exists('qr_code_content', $productionPayload))->toBeFalse()
|
|
2810
|
+
->and($productionPayload['qr_code'])->toBe('qr-data');
|
|
2811
|
+
});
|
|
2812
|
+
|
|
2813
|
+
test('tracking number resource fails closed when the debug state cannot be determined', function () {
|
|
2814
|
+
// Any failure to resolve the debug state must answer false rather than defaulting to
|
|
2815
|
+
// exposure. The real accessor is exercised here with no usable application bound.
|
|
2816
|
+
$expose = new ReflectionMethod(TrackingNumberResource::class, 'exposesQrCodeContent');
|
|
2817
|
+
$expose->setAccessible(true);
|
|
2818
|
+
|
|
2819
|
+
$previous = Illuminate\Container\Container::getInstance();
|
|
2820
|
+
|
|
2821
|
+
try {
|
|
2822
|
+
// A container that is not an Application has no hasDebugModeEnabled().
|
|
2823
|
+
Illuminate\Container\Container::setInstance(new Illuminate\Container\Container());
|
|
2824
|
+
expect($expose->invoke(null))->toBeFalse();
|
|
2825
|
+
|
|
2826
|
+
// And an accessor that throws must also answer false rather than propagating —
|
|
2827
|
+
// a resource must not be able to fail serialization over a debug check.
|
|
2828
|
+
Illuminate\Container\Container::setInstance(new class extends Illuminate\Container\Container {
|
|
2829
|
+
public function hasDebugModeEnabled()
|
|
2830
|
+
{
|
|
2831
|
+
throw new RuntimeException('debug state unavailable');
|
|
2832
|
+
}
|
|
2833
|
+
});
|
|
2834
|
+
expect($expose->invoke(null))->toBeFalse();
|
|
2835
|
+
} finally {
|
|
2836
|
+
Illuminate\Container\Container::setInstance($previous);
|
|
2837
|
+
}
|
|
2838
|
+
});
|
|
@@ -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);
|
|
@@ -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
|
});
|