@fleetbase/fleetops-engine 0.6.68 → 0.6.69
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 +11 -9
- package/addon/components/contact/form.hbs +13 -2
- package/addon/components/customer/details.hbs +17 -17
- package/addon/components/customer/form.hbs +13 -2
- package/addon/components/customer/form.js +0 -56
- package/addon/components/driver/details.hbs +13 -19
- package/addon/components/driver/form.hbs +20 -35
- package/addon/components/driver/form.js +0 -58
- package/addon/components/modals/driver-form.hbs +2 -2
- package/addon/components/modals/reset-profile-credentials.hbs +23 -0
- package/addon/components/modals/reset-profile-credentials.js +70 -0
- package/addon/components/profile-identity-hint.hbs +13 -0
- package/addon/components/profile-identity-hint.js +49 -0
- package/addon/controllers/management/drivers/index.js +4 -0
- package/addon/extension.js +4 -2
- package/addon/services/contact-actions.js +50 -89
- package/addon/services/driver-actions.js +98 -0
- package/addon/utils/profile-login-actions.js +172 -0
- package/app/components/modals/{reset-customer-credentials.js → reset-profile-credentials.js} +1 -1
- package/app/components/profile-identity-hint.js +1 -0
- package/composer.json +1 -1
- package/extension.json +1 -1
- package/package.json +3 -3
- package/scripts/pest-bootstrap.php +12 -0
- package/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php +132 -0
- package/server/resources/views/mail/driver-credentials.blade.php +21 -0
- package/server/src/Console/Commands/FixCustomerCompanies.php +1 -1
- package/server/src/Console/Commands/FixDriverCompanies.php +1 -1
- package/server/src/Exceptions/ProfileIdentityConflictException.php +34 -0
- package/server/src/Http/Controllers/Api/v1/CustomerController.php +14 -0
- package/server/src/Http/Controllers/Api/v1/DriverController.php +56 -39
- package/server/src/Http/Controllers/Internal/v1/ContactController.php +5 -17
- package/server/src/Http/Controllers/Internal/v1/CustomerController.php +40 -4
- package/server/src/Http/Controllers/Internal/v1/DriverController.php +182 -162
- package/server/src/Http/Controllers/Internal/v1/FleetOpsLookupController.php +47 -0
- package/server/src/Http/Controllers/Internal/v1/OrderController.php +156 -27
- package/server/src/Http/Requests/CreateDriverRequest.php +6 -2
- package/server/src/Http/Requests/Internal/CreateDriverRequest.php +9 -18
- package/server/src/Http/Resources/v1/Contact.php +3 -0
- package/server/src/Http/Resources/v1/Driver.php +3 -0
- package/server/src/Mail/DriverCredentialsMail.php +55 -0
- package/server/src/Models/Contact.php +55 -90
- package/server/src/Models/Sensor.php +3 -5
- package/server/src/Observers/ContactObserver.php +2 -22
- package/server/src/Observers/DriverObserver.php +5 -6
- package/server/src/Providers/FleetOpsServiceProvider.php +15 -0
- package/server/src/Support/Ai/Capabilities/AbstractFleetOpsAICapability.php +62 -5
- package/server/src/Support/Ai/Capabilities/AssetStatusCapability.php +24 -5
- package/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php +6 -2
- package/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php +40 -7
- package/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php +86 -24
- package/server/src/Support/Ai/FleetOpsAiConsoleCommands.php +122 -0
- package/server/src/Support/Ai/Tools/CreateOrderTool.php +86 -0
- package/server/src/Support/Ai/Tools/ImportOrdersTool.php +53 -0
- package/server/src/Support/Ai/Tools/OptimizeOrderRouteTool.php +70 -0
- package/server/src/Support/Ai/Tools/SearchResourcesTool.php +73 -0
- package/server/src/Support/ProfileAccountManager.php +405 -0
- package/server/src/routes.php +5 -0
- package/server/tests/AiOperationalQueryCapabilityTest.php +1 -0
- package/server/tests/ApiCustomerControllerContractsTest.php +21 -0
- package/server/tests/ApiDriverControllerContractsTest.php +102 -20
- package/server/tests/CommandContractsTest.php +4 -4
- package/server/tests/ControllerHelperContractsTest.php +3 -11
- package/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +5 -0
- package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +36 -0
- package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +19 -6
- package/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php +201 -0
- package/server/tests/Feature/Http/Internal/ContactControllerEndpointsTest.php +6 -4
- package/server/tests/Feature/Http/Internal/CustomerControllerCredentialsTest.php +6 -2
- package/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +23 -0
- package/server/tests/Feature/Http/Internal/DriverControllerCreateRecordTest.php +42 -17
- package/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php +168 -277
- package/server/tests/Feature/Http/Internal/DriverControllerLoginManagementTest.php +716 -0
- package/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php +10 -3
- package/server/tests/Feature/Http/Internal/OrderControllerImportFromFilesTest.php +18 -0
- package/server/tests/Feature/Http/Internal/OrderControllerTenantScopingTest.php +411 -0
- package/server/tests/Feature/Http/Internal/OrderControllerUpstreamNotFoundTest.php +8 -20
- package/server/tests/InternalCustomerControllerContractsTest.php +32 -1
- package/server/tests/ModelAccessorContractsTest.php +82 -20
- package/server/tests/ObserverContractsTest.php +27 -2
- package/server/tests/RequestContractsTest.php +19 -11
- package/server/tests/SupportJobAndAiCoverageTest.php +3 -3
- package/server/tests/TelematicControllerContractsTest.php +22 -4
- package/server/tests/Unit/Models/ContactCustomerUserTest.php +90 -14
- package/server/tests/Unit/Models/ContactTest.php +48 -5
- package/server/tests/Unit/Models/SensorTest.php +2 -2
- package/server/tests/Unit/Models/SmallModelSurfacesTest.php +31 -32
- package/server/tests/Unit/Observers/ObserverListenerSeamsTest.php +7 -2
- package/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php +15 -2
- package/server/tests/Unit/Support/Ai/AssetStatusCapabilityQueriesTest.php +42 -0
- package/server/tests/Unit/Support/Ai/Capabilities/OrderInsightsCapabilityTest.php +19 -1
- package/server/tests/Unit/Support/Ai/Capabilities/SearchResourcesCapabilityTest.php +85 -1
- package/server/tests/Unit/Support/Ai/FleetOpsAiConsoleCommandsTest.php +31 -0
- package/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php +246 -0
- package/server/tests/Unit/Support/AiCapabilityPermissionsTest.php +3 -3
- package/server/tests/Unit/Support/ProfileAccountManagerTest.php +643 -0
- package/translations/en-us.yaml +37 -2
- package/translations/uk-ua.yaml +3475 -0
- package/addon/components/modals/reset-customer-credentials.hbs +0 -18
- package/addon/components/modals/reset-customer-credentials.js +0 -55
|
@@ -884,7 +884,7 @@ class FleetOpsUserCommandFake
|
|
|
884
884
|
return true;
|
|
885
885
|
}
|
|
886
886
|
|
|
887
|
-
public function assignCompany(Company $company, string $role =
|
|
887
|
+
public function assignCompany(Company $company, ?string $role = null): self
|
|
888
888
|
{
|
|
889
889
|
$this->assigned[] = [$company->uuid, $role];
|
|
890
890
|
|
|
@@ -2208,8 +2208,8 @@ test('fix customer companies command creates or links users and assigns missing
|
|
|
2208
2208
|
['email', FleetOpsCustomerCommandFake::class],
|
|
2209
2209
|
['phone', FleetOpsCustomerCommandFake::class],
|
|
2210
2210
|
])
|
|
2211
|
-
->and($createdUser->assigned)->toBe([['company-created', '
|
|
2212
|
-
->and($existingUser->assigned)->toBe([['company-existing', '
|
|
2211
|
+
->and($createdUser->assigned)->toBe([['company-created', 'Fleet-Ops Customer']])
|
|
2212
|
+
->and($existingUser->assigned)->toBe([['company-existing', 'Fleet-Ops Customer']]);
|
|
2213
2213
|
});
|
|
2214
2214
|
|
|
2215
2215
|
test('fix driver companies command syncs assigned users and missing company assignments', function () {
|
|
@@ -2236,7 +2236,7 @@ test('fix driver companies command syncs assigned users and missing company assi
|
|
|
2236
2236
|
['email', Driver::class],
|
|
2237
2237
|
['phone', Driver::class],
|
|
2238
2238
|
])
|
|
2239
|
-
->and($user->assigned)->toBe([['driver-company', '
|
|
2239
|
+
->and($user->assigned)->toBe([['driver-company', 'Driver']])
|
|
2240
2240
|
->and($command->messages)->toContain(['line', 'Found driver Driver User (driver@example.test) which doesnt have correct company assignment.'])
|
|
2241
2241
|
->and($command->messages)->toContain(['line', 'Driver driver@example.test was assigned to company: Driver Company']);
|
|
2242
2242
|
});
|
|
@@ -254,7 +254,6 @@ class FleetOpsZoneControllerProbe extends ZoneController
|
|
|
254
254
|
class FleetOpsInternalContactControllerProbe extends InternalContactController
|
|
255
255
|
{
|
|
256
256
|
public array $installedPackages = [];
|
|
257
|
-
public array $resolvedUsers = [];
|
|
258
257
|
public array $sentCredentials = [];
|
|
259
258
|
public array $savedMetas = [];
|
|
260
259
|
public ?User $contactUser = null;
|
|
@@ -269,13 +268,6 @@ class FleetOpsInternalContactControllerProbe extends InternalContactController
|
|
|
269
268
|
return $reflection->invoke($this, ...$arguments);
|
|
270
269
|
}
|
|
271
270
|
|
|
272
|
-
protected function resolveUserUuid(string $user): string
|
|
273
|
-
{
|
|
274
|
-
$this->resolvedUsers[] = $user;
|
|
275
|
-
|
|
276
|
-
return 'resolved-' . $user;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
271
|
protected function installedFleetbaseExtensions(): array
|
|
280
272
|
{
|
|
281
273
|
return $this->installedPackages;
|
|
@@ -1607,7 +1599,7 @@ test('internal contact controller create update and after save hooks protect cus
|
|
|
1607
1599
|
],
|
|
1608
1600
|
],
|
|
1609
1601
|
]);
|
|
1610
|
-
$input = ['type' => 'contact', 'user' => ['id' => 'user_public']];
|
|
1602
|
+
$input = ['type' => 'contact', 'user' => ['id' => 'user_public'], 'user_uuid' => 'user-uuid'];
|
|
1611
1603
|
|
|
1612
1604
|
$controller->onBeforeCreate($request, $input);
|
|
1613
1605
|
|
|
@@ -1623,8 +1615,8 @@ test('internal contact controller create update and after save hooks protect cus
|
|
|
1623
1615
|
|
|
1624
1616
|
$controller->afterSave($request, $contact);
|
|
1625
1617
|
|
|
1626
|
-
|
|
1627
|
-
|
|
1618
|
+
// The login account is managed by the profile, so a user can't be picked from the console
|
|
1619
|
+
expect($input)->toBe(['type' => 'contact'])
|
|
1628
1620
|
->and($contact->normalized)->toBeTrue()
|
|
1629
1621
|
->and($contact->syncedCustomFields)->toBe([
|
|
1630
1622
|
['key' => 'tier', 'value' => 'gold'],
|
|
@@ -596,6 +596,11 @@ test('inbox completion waits for pending deliveries and aggregates quarantined f
|
|
|
596
596
|
DB::table('telematic_deliveries')->where('uuid', $id)->update(['status' => 'quarantined', 'applied' => 2, 'failed' => 1]);
|
|
597
597
|
Inbox::finishRun('run-pending');
|
|
598
598
|
expect((array) DB::table('telematic_sync_runs')->first())->toMatchArray(['status' => 'partial', 'applied' => 2, 'failed' => 1]);
|
|
599
|
+
|
|
600
|
+
// A finished run whose connection was removed completes without touching connection meta
|
|
601
|
+
DB::table('telematic_sync_runs')->insert(['uuid' => 'run-orphan', 'telematic_uuid' => 'missing-connection', 'status' => 'ingesting', 'created_at' => now(), 'updated_at' => now()]);
|
|
602
|
+
Inbox::finishRun('run-orphan');
|
|
603
|
+
expect(DB::table('telematic_sync_runs')->where('uuid', 'run-orphan')->value('status'))->not->toBe('ingesting');
|
|
599
604
|
});
|
|
600
605
|
|
|
601
606
|
test('telemetry diagnostics report setup receipt backlog and degraded delivery states', function () {
|
|
@@ -230,6 +230,42 @@ test('password login rejects bad credentials and issues driver tokens', function
|
|
|
230
230
|
->and($connection->table('personal_access_tokens')->value('name'))->toBe('driver-1');
|
|
231
231
|
});
|
|
232
232
|
|
|
233
|
+
test('deactivated driver logins are refused on every sign-in path', function () {
|
|
234
|
+
$connection = fleetopsDriverAuthBoot();
|
|
235
|
+
$controller = new DriverController();
|
|
236
|
+
$connection->table('users')->where('uuid', 'user-1')->update(['status' => 'inactive']);
|
|
237
|
+
app('hash')->checks = true;
|
|
238
|
+
|
|
239
|
+
// An unknown identity fails authentication rather than erroring on a null user
|
|
240
|
+
$unknown = $controller->login(Request::create('/x', 'POST', ['identity' => 'ghost@example.test', 'password' => 'secret']));
|
|
241
|
+
expect($unknown->getStatusCode())->toBe(401);
|
|
242
|
+
|
|
243
|
+
$password = $controller->login(Request::create('/x', 'POST', ['identity' => 'driver@example.test', 'password' => 'secret']));
|
|
244
|
+
|
|
245
|
+
app()->instance('request', Request::create('/x', 'POST', ['phone' => '6591234567']));
|
|
246
|
+
$phone = $controller->loginWithPhone();
|
|
247
|
+
|
|
248
|
+
$connection->table('verification_codes')->insert(['uuid' => 'vc-1', 'subject_uuid' => 'user-1', 'code' => '424242', 'for' => 'driver_login']);
|
|
249
|
+
$code = $controller->verifyCode(Request::create('/x', 'POST', ['identity' => '6591234567', 'code' => '424242']));
|
|
250
|
+
|
|
251
|
+
foreach ([$password, $phone, $code] as $response) {
|
|
252
|
+
expect($response->getStatusCode())->toBe(403)
|
|
253
|
+
->and($response->getData(true)['error'])->toBe(DriverController::DEACTIVATED_LOGIN_MESSAGE);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Password recovery answers as if the identity were unknown
|
|
257
|
+
$forgot = $controller->forgotPassword(Request::create('/x', 'POST', ['identity' => 'driver@example.test']));
|
|
258
|
+
$reset = $controller->resetPassword(Request::create('/x', 'POST', ['identity' => 'driver@example.test', 'code' => '424242', 'password' => 'new-secret-123']));
|
|
259
|
+
|
|
260
|
+
expect($forgot->getData(true))->toBe(['status' => 'ok'])
|
|
261
|
+
->and($reset->getStatusCode())->toBe(422)
|
|
262
|
+
->and($reset->getData(true)['error'])->toBe('Invalid or expired reset code.');
|
|
263
|
+
|
|
264
|
+
expect(DriverController::isLoginDeactivated(User::where('uuid', 'user-1')->first()))->toBeTrue()
|
|
265
|
+
->and($connection->table('personal_access_tokens')->count())->toBe(0)
|
|
266
|
+
->and($connection->table('verification_codes')->count())->toBe(1);
|
|
267
|
+
});
|
|
268
|
+
|
|
233
269
|
test('phone login falls back to email verification and errors without channels', function () {
|
|
234
270
|
$connection = fleetopsDriverAuthBoot();
|
|
235
271
|
$controller = new DriverController();
|
|
@@ -106,7 +106,7 @@ function fleetopsDriverSessionHelpersBoot(): SQLiteConnection
|
|
|
106
106
|
$schema = $connection->getSchemaBuilder();
|
|
107
107
|
app()->instance('db.schema', $schema);
|
|
108
108
|
$tables = [
|
|
109
|
-
'users' => ['uuid', 'public_id', 'company_uuid', 'name', 'email', 'phone', 'status', 'type', 'username', 'password'],
|
|
109
|
+
'users' => ['uuid', 'public_id', 'company_uuid', 'name', 'email', 'phone', 'status', 'type', 'username', 'password', 'timezone', 'slug', 'meta'],
|
|
110
110
|
'drivers' => ['uuid', 'public_id', 'internal_id', 'company_uuid', 'user_uuid', 'vehicle_uuid', 'vendor_uuid', 'status', 'online', 'location', 'meta'],
|
|
111
111
|
'companies' => ['uuid', 'public_id', 'name', 'status', 'owner_uuid'],
|
|
112
112
|
'user_devices' => ['uuid', 'public_id', 'user_uuid', 'token', 'platform', 'status'],
|
|
@@ -189,22 +189,35 @@ test('user and driver persistence helpers write records', function () {
|
|
|
189
189
|
$userDetails = $probe->callHelper('applyUserInfoFromRequest', Request::create('/x', 'POST', []), ['name' => 'Applicant']);
|
|
190
190
|
expect($userDetails['name'])->toBe('Applicant');
|
|
191
191
|
|
|
192
|
-
|
|
192
|
+
// A company that isn't in the table skips the organization membership seam,
|
|
193
|
+
// which needs spatie roles; the managed account itself is still created.
|
|
194
|
+
$missingCompany = '33333333-3333-4333-8333-333333333333';
|
|
195
|
+
|
|
196
|
+
$user = $probe->callHelper('resolveDriverAccount', $missingCompany, ['name' => 'New User']);
|
|
193
197
|
expect($user)->toBeInstanceOf(User::class)
|
|
194
|
-
->and($connection->table('users')->where('name', 'New User')->count())->toBe(1)
|
|
198
|
+
->and($connection->table('users')->where('name', 'New User')->count())->toBe(1)
|
|
199
|
+
->and($connection->table('users')->where('name', 'New User')->value('type'))->toBe('driver')
|
|
200
|
+
->and($connection->table('users')->where('name', 'New User')->value('status'))->toBe('active');
|
|
195
201
|
|
|
196
202
|
/*
|
|
197
203
|
* `password` is guarded on User, so mass assignment drops it without a
|
|
198
204
|
* word. The create endpoint has always documented and validated one, so
|
|
199
|
-
* unless
|
|
200
|
-
*
|
|
205
|
+
* unless it is set explicitly a driver created through the API can never
|
|
206
|
+
* sign in with the password chosen for them.
|
|
201
207
|
*/
|
|
202
|
-
$withPassword = $probe->callHelper('
|
|
208
|
+
$withPassword = $probe->callHelper('resolveDriverAccount', $missingCompany, ['name' => 'Password User', 'password' => 'seeded-password']);
|
|
203
209
|
$stored = $connection->table('users')->where('name', 'Password User')->value('password');
|
|
204
210
|
expect(Hash::check('seeded-password', $withPassword->password))->toBeTrue()
|
|
205
211
|
->and($stored)->not->toBeNull()
|
|
206
212
|
->and($stored)->not->toBe('seeded-password');
|
|
207
213
|
|
|
214
|
+
// A team member of the company with the email is linked instead of duplicated
|
|
215
|
+
$connection->table('users')->insert(['uuid' => 'staff-1', 'company_uuid' => '22222222-2222-4222-8222-222222222222', 'name' => 'Staff', 'email' => 'staff@example.test', 'type' => 'user']);
|
|
216
|
+
$linked = $probe->callHelper('resolveDriverAccount', '22222222-2222-4222-8222-222222222222', ['name' => 'Staff Driver', 'email' => 'STAFF@example.test']);
|
|
217
|
+
expect($linked->uuid)->toBe('staff-1')
|
|
218
|
+
->and($connection->table('users')->where('email', 'staff@example.test')->count())->toBe(1)
|
|
219
|
+
->and($connection->table('users')->where('uuid', 'staff-1')->value('type'))->toBe('user');
|
|
220
|
+
|
|
208
221
|
$driver = $probe->callHelper('createDriver', ['company_uuid' => '22222222-2222-4222-8222-222222222222', 'user_uuid' => 'user-1']);
|
|
209
222
|
expect($driver)->toBeInstanceOf(Driver::class);
|
|
210
223
|
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
use Illuminate\Database\ConnectionResolver;
|
|
4
|
+
use Illuminate\Database\Eloquent\Model as EloquentModel;
|
|
5
|
+
use Illuminate\Database\SQLiteConnection;
|
|
6
|
+
use Illuminate\Support\Facades\DB;
|
|
7
|
+
use Illuminate\Support\Facades\Schema;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The migration that converts profile-only `user` accounts into managed
|
|
11
|
+
* driver/customer accounts, run up and down against in-memory SQLite.
|
|
12
|
+
*
|
|
13
|
+
* An account is converted only when it clearly is a profile login and nothing
|
|
14
|
+
* else; anything that looks like a team member is left alone, and down()
|
|
15
|
+
* reverts only the rows up() converted.
|
|
16
|
+
*/
|
|
17
|
+
function fleetopsProfileConversionBoot(bool $withPolicies = true): SQLiteConnection
|
|
18
|
+
{
|
|
19
|
+
$connection = new SQLiteConnection(new PDO('sqlite::memory:'));
|
|
20
|
+
$resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
|
|
21
|
+
$resolver->setDefaultConnection('mysql');
|
|
22
|
+
EloquentModel::setConnectionResolver($resolver);
|
|
23
|
+
|
|
24
|
+
app()->instance('db', new class($connection) {
|
|
25
|
+
public function __construct(public SQLiteConnection $c)
|
|
26
|
+
{
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
public function connection($name = null): SQLiteConnection
|
|
30
|
+
{
|
|
31
|
+
return $this->c;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
public function table($table, $as = null)
|
|
35
|
+
{
|
|
36
|
+
return $this->c->table($table, $as);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
public function __call($method, $arguments)
|
|
40
|
+
{
|
|
41
|
+
return $this->c->{$method}(...$arguments);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
app()->instance('db.schema', $connection->getSchemaBuilder());
|
|
45
|
+
DB::clearResolvedInstance('db');
|
|
46
|
+
Schema::clearResolvedInstance('db.schema');
|
|
47
|
+
|
|
48
|
+
$schema = $connection->getSchemaBuilder();
|
|
49
|
+
$tables = [
|
|
50
|
+
'users' => ['uuid', 'type', 'meta'],
|
|
51
|
+
'drivers' => ['uuid', 'user_uuid'],
|
|
52
|
+
'contacts' => ['uuid', 'user_uuid', 'type'],
|
|
53
|
+
'companies' => ['uuid', 'owner_uuid'],
|
|
54
|
+
'company_users' => ['uuid', 'user_uuid', 'company_uuid'],
|
|
55
|
+
'roles' => ['name'],
|
|
56
|
+
'model_has_roles' => ['role_id', 'model_uuid'],
|
|
57
|
+
'model_has_permissions' => ['permission_id', 'model_uuid'],
|
|
58
|
+
];
|
|
59
|
+
if ($withPolicies) {
|
|
60
|
+
$tables['model_has_policies'] = ['policy_id', 'model_uuid'];
|
|
61
|
+
}
|
|
62
|
+
foreach ($tables as $table => $columns) {
|
|
63
|
+
$schema->create($table, function ($blueprint) use ($columns) {
|
|
64
|
+
$blueprint->increments('id');
|
|
65
|
+
foreach ($columns as $column) {
|
|
66
|
+
$blueprint->string($column)->nullable();
|
|
67
|
+
}
|
|
68
|
+
$blueprint->timestamp('deleted_at')->nullable();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
foreach (['Driver', 'Fleet-Ops Customer', 'Administrator'] as $role) {
|
|
73
|
+
$connection->table('roles')->insert(['name' => $role]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return $connection;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function fleetopsProfileConversionMigration(): object
|
|
80
|
+
{
|
|
81
|
+
return require dirname(__DIR__, 4) . '/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Seed a user holding the given profile(s), roles on its company membership
|
|
86
|
+
* (or on the user itself), and optional direct permission or policy.
|
|
87
|
+
*/
|
|
88
|
+
function fleetopsProfileConversionUser(SQLiteConnection $connection, string $uuid, array $options = []): void
|
|
89
|
+
{
|
|
90
|
+
$connection->table('users')->insert(['uuid' => $uuid, 'type' => $options['type'] ?? 'user', 'meta' => $options['meta'] ?? null, 'deleted_at' => $options['deleted_at'] ?? null]);
|
|
91
|
+
$connection->table('company_users')->insert(['uuid' => 'cu-' . $uuid, 'user_uuid' => $uuid, 'company_uuid' => 'company-1']);
|
|
92
|
+
|
|
93
|
+
if ($options['driver'] ?? false) {
|
|
94
|
+
$connection->table('drivers')->insert(['uuid' => 'driver-' . $uuid, 'user_uuid' => $uuid]);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if ($options['customer'] ?? false) {
|
|
98
|
+
$connection->table('contacts')->insert(['uuid' => 'contact-' . $uuid, 'user_uuid' => $uuid, 'type' => 'customer']);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
foreach ($options['roles'] ?? [] as $role) {
|
|
102
|
+
$roleId = $connection->table('roles')->where('name', $role)->value('id');
|
|
103
|
+
$connection->table('model_has_roles')->insert(['role_id' => $roleId, 'model_uuid' => ($options['role_on_user'] ?? false) ? $uuid : 'cu-' . $uuid]);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if ($options['permission'] ?? false) {
|
|
107
|
+
$connection->table('model_has_permissions')->insert(['permission_id' => 1, 'model_uuid' => 'cu-' . $uuid]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if ($options['policy'] ?? false) {
|
|
111
|
+
$connection->table('model_has_policies')->insert(['policy_id' => 1, 'model_uuid' => $uuid]);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function fleetopsProfileConversionTypes(SQLiteConnection $connection): array
|
|
116
|
+
{
|
|
117
|
+
return $connection->table('users')->orderBy('uuid')->pluck('type', 'uuid')->all();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
test('up converts profile-only accounts and leaves anything that looks like a team member', function () {
|
|
121
|
+
$connection = fleetopsProfileConversionBoot();
|
|
122
|
+
|
|
123
|
+
fleetopsProfileConversionUser($connection, 'a-driver', ['driver' => true, 'roles' => ['Driver'], 'meta' => json_encode(['source' => 'console'])]);
|
|
124
|
+
fleetopsProfileConversionUser($connection, 'b-customer', ['customer' => true, 'roles' => ['Fleet-Ops Customer'], 'role_on_user' => true]);
|
|
125
|
+
fleetopsProfileConversionUser($connection, 'c-owner', ['driver' => true, 'roles' => ['Driver']]);
|
|
126
|
+
$connection->table('companies')->insert(['uuid' => 'company-1', 'owner_uuid' => 'c-owner']);
|
|
127
|
+
fleetopsProfileConversionUser($connection, 'd-extra-role', ['driver' => true, 'roles' => ['Driver', 'Administrator']]);
|
|
128
|
+
fleetopsProfileConversionUser($connection, 'e-permission', ['driver' => true, 'roles' => ['Driver'], 'permission' => true]);
|
|
129
|
+
fleetopsProfileConversionUser($connection, 'f-policy', ['customer' => true, 'roles' => ['Fleet-Ops Customer'], 'policy' => true]);
|
|
130
|
+
fleetopsProfileConversionUser($connection, 'g-both-profiles', ['driver' => true, 'customer' => true, 'roles' => ['Driver']]);
|
|
131
|
+
fleetopsProfileConversionUser($connection, 'h-no-roles', ['driver' => true]);
|
|
132
|
+
fleetopsProfileConversionUser($connection, 'i-wrong-role', ['customer' => true, 'roles' => ['Driver']]);
|
|
133
|
+
fleetopsProfileConversionUser($connection, 'j-no-profile', ['roles' => ['Driver']]);
|
|
134
|
+
fleetopsProfileConversionUser($connection, 'k-admin', ['type' => 'admin', 'driver' => true, 'roles' => ['Driver']]);
|
|
135
|
+
fleetopsProfileConversionUser($connection, 'l-deleted', ['driver' => true, 'roles' => ['Driver'], 'deleted_at' => '2026-01-01 00:00:00']);
|
|
136
|
+
fleetopsProfileConversionUser($connection, 'm-bad-meta', ['driver' => true, 'roles' => ['Driver'], 'meta' => 'not-json']);
|
|
137
|
+
|
|
138
|
+
fleetopsProfileConversionMigration()->up();
|
|
139
|
+
|
|
140
|
+
expect(fleetopsProfileConversionTypes($connection))->toBe([
|
|
141
|
+
'a-driver' => 'driver',
|
|
142
|
+
'b-customer' => 'customer',
|
|
143
|
+
'c-owner' => 'user',
|
|
144
|
+
'd-extra-role' => 'user',
|
|
145
|
+
'e-permission' => 'user',
|
|
146
|
+
'f-policy' => 'user',
|
|
147
|
+
'g-both-profiles' => 'user',
|
|
148
|
+
'h-no-roles' => 'user',
|
|
149
|
+
'i-wrong-role' => 'user',
|
|
150
|
+
'j-no-profile' => 'user',
|
|
151
|
+
'k-admin' => 'admin',
|
|
152
|
+
'l-deleted' => 'user',
|
|
153
|
+
'm-bad-meta' => 'driver',
|
|
154
|
+
])
|
|
155
|
+
// The previous type is recorded next to any existing meta
|
|
156
|
+
->and(json_decode($connection->table('users')->where('uuid', 'a-driver')->value('meta'), true))->toBe(['source' => 'console', 'previous_type' => 'user'])
|
|
157
|
+
->and(json_decode($connection->table('users')->where('uuid', 'm-bad-meta')->value('meta'), true))->toBe(['previous_type' => 'user']);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('down reverts only the accounts up converted', function () {
|
|
161
|
+
$connection = fleetopsProfileConversionBoot();
|
|
162
|
+
|
|
163
|
+
fleetopsProfileConversionUser($connection, 'a-driver', ['driver' => true, 'roles' => ['Driver'], 'meta' => json_encode(['source' => 'console'])]);
|
|
164
|
+
fleetopsProfileConversionUser($connection, 'b-customer', ['customer' => true, 'roles' => ['Fleet-Ops Customer']]);
|
|
165
|
+
// Managed accounts created after the change carry no previous type
|
|
166
|
+
fleetopsProfileConversionUser($connection, 'c-native-driver', ['type' => 'driver', 'driver' => true, 'meta' => json_encode(['source' => 'app'])]);
|
|
167
|
+
// A previous type other than `user` is not this migration's
|
|
168
|
+
fleetopsProfileConversionUser($connection, 'd-other-previous', ['type' => 'customer', 'customer' => true, 'meta' => json_encode(['previous_type' => 'contact'])]);
|
|
169
|
+
|
|
170
|
+
$migration = fleetopsProfileConversionMigration();
|
|
171
|
+
$migration->up();
|
|
172
|
+
$migration->down();
|
|
173
|
+
|
|
174
|
+
expect(fleetopsProfileConversionTypes($connection))->toBe([
|
|
175
|
+
'a-driver' => 'user',
|
|
176
|
+
'b-customer' => 'user',
|
|
177
|
+
'c-native-driver' => 'driver',
|
|
178
|
+
'd-other-previous' => 'customer',
|
|
179
|
+
])
|
|
180
|
+
->and(json_decode($connection->table('users')->where('uuid', 'a-driver')->value('meta'), true))->toBe(['source' => 'console'])
|
|
181
|
+
->and(json_decode($connection->table('users')->where('uuid', 'b-customer')->value('meta'), true))->toBe([])
|
|
182
|
+
->and(json_decode($connection->table('users')->where('uuid', 'd-other-previous')->value('meta'), true))->toBe(['previous_type' => 'contact']);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('up and down do nothing when the tables they read are missing', function () {
|
|
186
|
+
$connection = fleetopsProfileConversionBoot(false);
|
|
187
|
+
fleetopsProfileConversionUser($connection, 'a-driver', ['driver' => true, 'roles' => ['Driver']]);
|
|
188
|
+
|
|
189
|
+
$migration = fleetopsProfileConversionMigration();
|
|
190
|
+
$migration->up();
|
|
191
|
+
|
|
192
|
+
expect(fleetopsProfileConversionTypes($connection))->toBe(['a-driver' => 'user']);
|
|
193
|
+
|
|
194
|
+
$connection->table('users')->where('uuid', 'a-driver')->update(['type' => 'driver', 'meta' => json_encode(['previous_type' => 'user'])]);
|
|
195
|
+
$connection->getSchemaBuilder()->drop('users');
|
|
196
|
+
|
|
197
|
+
// Without a users table there is nothing to revert
|
|
198
|
+
$migration->down();
|
|
199
|
+
|
|
200
|
+
expect($connection->getSchemaBuilder()->hasTable('users'))->toBeFalse();
|
|
201
|
+
});
|
|
@@ -252,10 +252,12 @@ test('contact update guards types resolve users and portal seams persist', funct
|
|
|
252
252
|
$probe->callProtected('assertContactInputIsValid', [$request, &$input, $contact]);
|
|
253
253
|
})->toThrow(Exception::class);
|
|
254
254
|
|
|
255
|
-
//
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
255
|
+
// The login account is managed by the profile, so user references are dropped
|
|
256
|
+
$userInput = ['name' => 'Seam Contact', 'user_uuid' => '88888888-8888-4888-8888-888888888881', 'user' => 'user_contactseam'];
|
|
257
|
+
Closure::bind(function (array &$input) {
|
|
258
|
+
$this->resolveUserInput(Request::create('/x', 'PUT', []), $input);
|
|
259
|
+
}, $probe, ContactController::class)($userInput);
|
|
260
|
+
expect($userInput)->toBe(['name' => 'Seam Contact']);
|
|
259
261
|
|
|
260
262
|
// Portal seams read users, mint passwords, and persist meta quietly
|
|
261
263
|
expect($probe->callProtected('contactUser', [$contact])?->uuid)->toBe('88888888-8888-4888-8888-888888888881')
|
|
@@ -125,7 +125,9 @@ function fleetopsCustomerCredentialsBoot(): SQLiteConnection
|
|
|
125
125
|
'contacts' => ['uuid', 'public_id', 'company_uuid', 'user_uuid', 'name', 'email', 'phone', 'type', 'title', 'meta', 'photo_uuid', 'place_uuid', 'slug', '_key'],
|
|
126
126
|
'users' => ['uuid', 'public_id', 'company_uuid', 'name', 'email', 'phone', 'password', 'type', 'status', 'timezone', 'slug', 'username', 'avatar_uuid', 'meta', '_key'],
|
|
127
127
|
'companies' => ['uuid', 'public_id', 'name', 'country'],
|
|
128
|
-
|
|
128
|
+
// session_status reads the user's membership of the session company
|
|
129
|
+
'company_users' => ['uuid', 'company_uuid', 'user_uuid', 'status'],
|
|
130
|
+
'files' => ['uuid', 'public_id', 'company_uuid', 'subject_uuid', 'subject_type', 'name', 'path', 'disk', 'type', '_key'],
|
|
129
131
|
];
|
|
130
132
|
foreach ($tables as $table => $columns) {
|
|
131
133
|
$schema->create($table, function ($blueprint) use ($columns) {
|
|
@@ -192,5 +194,7 @@ test('customer resolution and payload helpers expose user details', function ()
|
|
|
192
194
|
|
|
193
195
|
$payload = $probe->callHelper('customerPayload', $customer);
|
|
194
196
|
expect($payload['uuid'])->toBe('55555555-5555-4555-8555-555555555555')
|
|
195
|
-
->and($payload)->
|
|
197
|
+
->and($payload['user']['uuid'])->toBe('user-1')
|
|
198
|
+
->and($payload['is_staff_linked'])->toBeFalse()
|
|
199
|
+
->and($payload['login_status'])->toBe('active');
|
|
196
200
|
});
|
|
@@ -1131,6 +1131,29 @@ test('internal driver controller verify code returns driver resource and handles
|
|
|
1131
1131
|
]);
|
|
1132
1132
|
});
|
|
1133
1133
|
|
|
1134
|
+
test('internal driver controller refuses phone login and code verification for deactivated drivers', function () {
|
|
1135
|
+
FleetOpsInternalDriverAuthControllerProbe::resetProbe();
|
|
1136
|
+
app()->instance('request', Request::create('/', 'POST', ['phone' => '15551234567']));
|
|
1137
|
+
|
|
1138
|
+
$deactivated = fleetopsInternalDriverAuthUser();
|
|
1139
|
+
$deactivated->setAttribute('status', 'inactive');
|
|
1140
|
+
FleetOpsInternalDriverAuthControllerProbe::$loginUser = $deactivated;
|
|
1141
|
+
FleetOpsInternalDriverAuthControllerProbe::$verificationUser = $deactivated;
|
|
1142
|
+
FleetOpsInternalDriverAuthControllerProbe::$verificationExists = true;
|
|
1143
|
+
|
|
1144
|
+
$controller = new FleetOpsInternalDriverAuthControllerProbe();
|
|
1145
|
+
$login = $controller->loginWithPhone();
|
|
1146
|
+
$verify = $controller->verifyCode(new Request(['identity' => '+15551234567', 'code' => '111111']));
|
|
1147
|
+
|
|
1148
|
+
expect($login->getStatusCode())->toBe(403)
|
|
1149
|
+
->and($login->getData(true))->toBe(['error' => 'This driver login has been deactivated.'])
|
|
1150
|
+
->and($verify->getStatusCode())->toBe(403)
|
|
1151
|
+
->and($verify->getData(true))->toBe(['error' => 'This driver login has been deactivated.'])
|
|
1152
|
+
// No code is sent and no token is issued
|
|
1153
|
+
->and(FleetOpsInternalDriverAuthControllerProbe::$verifications)->toBe([])
|
|
1154
|
+
->and(FleetOpsInternalDriverAuthControllerProbe::$tokens)->toBe([]);
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1134
1157
|
test('internal driver controller delegates login and create driver verification flows to api controller', function () {
|
|
1135
1158
|
$request = new Request(['for' => 'create_driver']);
|
|
1136
1159
|
|
|
@@ -10,11 +10,12 @@ use Illuminate\Http\Request;
|
|
|
10
10
|
/**
|
|
11
11
|
* Covers the internal DriverController createRecord and updateRecord flows
|
|
12
12
|
* against an in-memory SQLite fixture. The creation closure executes end to
|
|
13
|
-
* end: company resolution,
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* end: company resolution, managed account creation with driver typing
|
|
14
|
+
* through ProfileAccountManager, and the exception-to-error-response
|
|
15
|
+
* branches. Role assignment requires the full spatie permission boot, which
|
|
16
|
+
* this fixture leaves out, so flows settle in the documented error branch
|
|
17
|
+
* after the user record is persisted (see
|
|
18
|
+
* DriverControllerExistingUserAdoptionTest for the full flow).
|
|
18
19
|
*/
|
|
19
20
|
if (!function_exists('Fleetbase\Support\session')) {
|
|
20
21
|
eval('namespace Fleetbase\Support; function session($key = null, $default = null) { if ($key === null) { return new class { public function has($k) { return \session($k) !== null; } public function get($k, $d = null) { return \session($k, $d); } }; } return \session($key, $default); }');
|
|
@@ -101,6 +102,28 @@ function fleetopsInternalDriverCreateBoot(): SQLiteConnection
|
|
|
101
102
|
}
|
|
102
103
|
});
|
|
103
104
|
Illuminate\Support\Facades\DB::clearResolvedInstance('db');
|
|
105
|
+
app()->instance('hash', new class implements Illuminate\Contracts\Hashing\Hasher {
|
|
106
|
+
public function info($hashedValue): array
|
|
107
|
+
{
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
public function make($value, array $options = []): string
|
|
112
|
+
{
|
|
113
|
+
return 'hashed:' . $value;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
public function check($value, $hashedValue, array $options = []): bool
|
|
117
|
+
{
|
|
118
|
+
return 'hashed:' . $value === $hashedValue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
public function needsRehash($hashedValue, array $options = []): bool
|
|
122
|
+
{
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
Illuminate\Support\Facades\Hash::clearResolvedInstance('hash');
|
|
104
127
|
|
|
105
128
|
$schema = $connection->getSchemaBuilder();
|
|
106
129
|
$tables = [
|
|
@@ -158,31 +181,33 @@ test('create record provisions a driver-typed user before role assignment', func
|
|
|
158
181
|
->and($connection->table('company_users')->count())->toBeGreaterThanOrEqual(0);
|
|
159
182
|
});
|
|
160
183
|
|
|
161
|
-
test('create record
|
|
184
|
+
test('create record ignores a picked user account and resolves the login from the email', function () {
|
|
162
185
|
$connection = fleetopsInternalDriverCreateBoot();
|
|
163
|
-
$connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'name' => 'Existing', 'type' => 'driver']);
|
|
164
|
-
$connection->table('drivers')->insert(['uuid' => 'driver-1', 'company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111111']);
|
|
186
|
+
$connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'name' => 'Existing', 'email' => 'existing@example.com', 'type' => 'driver']);
|
|
165
187
|
|
|
166
|
-
|
|
167
|
-
'name' => '
|
|
188
|
+
(new DriverController())->createRecord(fleetopsInternalDriverCreateRequest([
|
|
189
|
+
'name' => 'Someone Else',
|
|
190
|
+
'email' => 'someone@example.com',
|
|
168
191
|
'user_uuid' => '11111111-1111-4111-8111-111111111111',
|
|
169
192
|
]));
|
|
170
193
|
|
|
171
|
-
|
|
172
|
-
|
|
194
|
+
// The login account is managed by the profile: a new driver account is
|
|
195
|
+
// created for the email rather than the picked account being taken over
|
|
196
|
+
expect($connection->table('users')->count())->toBe(2)
|
|
197
|
+
->and($connection->table('users')->where('email', 'someone@example.com')->value('type'))->toBe('driver')
|
|
198
|
+
->and($connection->table('users')->where('uuid', '11111111-1111-4111-8111-111111111111')->value('name'))->toBe('Existing');
|
|
173
199
|
});
|
|
174
200
|
|
|
175
|
-
test('create record
|
|
201
|
+
test('create record applies photo avatars to the new driver account', function () {
|
|
176
202
|
$connection = fleetopsInternalDriverCreateBoot();
|
|
177
|
-
$connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'name' => 'Adoptable', 'type' => 'driver']);
|
|
178
203
|
|
|
179
204
|
$result = (new DriverController())->createRecord(fleetopsInternalDriverCreateRequest([
|
|
180
|
-
'name' => '
|
|
181
|
-
'user_uuid' => '11111111-1111-4111-8111-111111111111',
|
|
205
|
+
'name' => 'Photo Driver',
|
|
182
206
|
'photo_uuid' => '22222222-2222-4222-8222-222222222222',
|
|
183
207
|
]));
|
|
184
208
|
|
|
185
|
-
// Avatar
|
|
209
|
+
// Avatar is set when the account is created, before the harness
|
|
210
|
+
// role-assignment limitation.
|
|
186
211
|
expect($connection->table('users')->value('avatar_uuid'))->toBe('22222222-2222-4222-8222-222222222222')
|
|
187
212
|
->and($result)->toBeInstanceOf(JsonResponse::class)
|
|
188
213
|
->and($result->getData(true))->toHaveKey('error');
|