From 233133365d06f3e69266d789ef68d61811a4fba2 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 13 Jul 2026 20:03:50 +0200 Subject: [PATCH] Guard chauffeur vehicles on legacy schemas --- services/nginx/app/routes/subusersRoute.php | 14 ++- .../nginx/app/tests/Api/SubusersApiTest.php | 95 +++++++++++++++++++ 2 files changed, 106 insertions(+), 3 deletions(-) diff --git a/services/nginx/app/routes/subusersRoute.php b/services/nginx/app/routes/subusersRoute.php index c8042569..651915f2 100644 --- a/services/nginx/app/routes/subusersRoute.php +++ b/services/nginx/app/routes/subusersRoute.php @@ -249,7 +249,7 @@ class subusersRoute } $vehicle->getObjectProperties(); - if ($vehicle->deleted_at->value() !== null) { + if ($this->customerVehiclesHaveDeletedAtColumn() && $vehicle->deleted_at->value() !== null) { if ($strict) { $response->error('Assigned vehicle not found', 404); } @@ -271,6 +271,11 @@ class subusersRoute ]; } + private function customerVehiclesHaveDeletedAtColumn(): bool + { + return (new customer_vehicles_o())->columnsExist(['deleted_at']); + } + private function validateAssignedVehicleIdForCustomer(mixed $value, int $customerNumber): ?int { $vehicleId = $this->normalizeAssignedVehicleId($value); @@ -1267,10 +1272,13 @@ class subusersRoute } $whereSql = 'WHERE ' . implode(' AND ', $where); + $vehicleDeletedAtJoinSql = $this->customerVehiclesHaveDeletedAtColumn() + ? ' AND cv.`deleted_at` IS NULL' + : ''; $fromSql = " FROM `subuser_grants` g INNER JOIN `subusers` s ON s.`id` = g.`subuser` - LEFT JOIN `customer_vehicles` cv ON cv.`id` = g.`assigned_vehicle_id` AND cv.`deleted_at` IS NULL + LEFT JOIN `customer_vehicles` cv ON cv.`id` = g.`assigned_vehicle_id`{$vehicleDeletedAtJoinSql} "; $countSql = "SELECT COUNT(DISTINCT s.`id`) AS `count` $fromSql $whereSql"; @@ -1374,7 +1382,7 @@ class subusersRoute g.`created_at` AS `grant_created_at`, g.`updated_at` AS `grant_updated_at` FROM `subuser_grants` g - LEFT JOIN `customer_vehicles` cv ON cv.`id` = g.`assigned_vehicle_id` AND cv.`deleted_at` IS NULL + LEFT JOIN `customer_vehicles` cv ON cv.`id` = g.`assigned_vehicle_id`{$vehicleDeletedAtJoinSql} WHERE " . implode(' AND ', $grantWhere) . " ORDER BY g.`enabled` DESC, g.`billing_customer_number` ASC, g.`id` DESC "; diff --git a/services/nginx/app/tests/Api/SubusersApiTest.php b/services/nginx/app/tests/Api/SubusersApiTest.php index 84260d75..7a9a4c43 100644 --- a/services/nginx/app/tests/Api/SubusersApiTest.php +++ b/services/nginx/app/tests/Api/SubusersApiTest.php @@ -4,6 +4,28 @@ declare(strict_types=1); usesApiSuite(); +function subusers_without_customer_vehicles_deleted_at(callable $callback): void +{ + $db = api_test_runtime()->db(); + $column = $db->query("SHOW COLUMNS FROM `customer_vehicles` LIKE 'deleted_at'"); + if ($column === false) { + throw new RuntimeException('Unable to inspect customer_vehicles.deleted_at test column.'); + } + + $hadColumn = (int)$column->num_rows > 0; + if ($hadColumn) { + $db->query('ALTER TABLE `customer_vehicles` DROP COLUMN `deleted_at`'); + } + + try { + $callback(); + } finally { + if ($hadColumn) { + $db->query('ALTER TABLE `customer_vehicles` ADD COLUMN `deleted_at` DATETIME NULL'); + } + } +} + const SUBUSER_PASSWORD_POLICY_MESSAGE = 'Password must contain at least one uppercase letter, one lowercase letter, and one number'; it('lists subusers when an existing grant has legacy zero permissions', function (): void { @@ -411,6 +433,79 @@ it('lists chauffeurs once with grouped grants across customers for superusers', expect($secondRow['grants'][0]['customer_name'] ?? null)->toBe('Fleet Customer Beta'); }); +it('lists superuser chauffeurs with assigned vehicles when customer vehicles has no deleted at column', function (): void { + subusers_without_customer_vehicles_deleted_at(function (): void { + api_test_covers('GET /superuser/subusers', 'happy'); + api_test_covers('GET /superuser/users/{user_id}/subusers', 'happy'); + + $customerVehiclesDeletedAtColumn = api_test_runtime()->db()->query( + "SHOW COLUMNS FROM `customer_vehicles` LIKE 'deleted_at'" + ); + expect($customerVehiclesDeletedAtColumn)->not->toBeFalse(); + expect((int)$customerVehiclesDeletedAtColumn->num_rows)->toBe(0); + + $session = api_fixtures()->createUserSession(['list_subusers']); + $suffix = (string)random_int(100000, 999999); + $customer = api_fixtures()->createUser([ + 'display_name' => 'Legacy Subuser Vehicle Customer ' . $suffix, + 'economic_customer_name' => 'Legacy Subuser Vehicle Customer ' . $suffix, + ]); + $subuser = api_fixtures()->createSubuser([ + 'name' => 'Legacy Vehicle Driver ' . $suffix, + ]); + $vehicle = api_fixtures()->createVehicle([ + 'customer_id' => (int)$customer['customer_number'], + 'type' => 1, + 'reg' => 'legacy' . $suffix, + ]); + $grantId = api_fixtures()->grantSubuser( + (int)$subuser['id'], + (int)$customer['customer_number'], + ['VEHICLES_LIST'], + ['assigned_vehicle_id' => (int)$vehicle['id']] + ); + + $list = api_client()->get('/superuser/subusers?page=1&limit=20', $session['headers']); + $search = api_client()->get( + '/superuser/subusers?page=1&limit=20&search=' . rawurlencode((string)$vehicle['reg']), + $session['headers'] + ); + $scoped = api_client()->get( + '/superuser/users/' . $customer['id'] . '/subusers?page=1&limit=20', + $session['headers'] + ); + + foreach ([$list, $search, $scoped] as $response) { + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + } + + $matchingSearchRows = array_values(array_filter( + is_array($search->data()) ? $search->data() : [], + static fn (mixed $item): bool => is_array($item) && (int)($item['id'] ?? 0) === (int)$subuser['id'] + )); + expect($matchingSearchRows)->toHaveCount(1); + + $matchingScopedRows = array_values(array_filter( + is_array($scoped->data()) ? $scoped->data() : [], + static fn (mixed $item): bool => is_array($item) && (int)($item['id'] ?? 0) === (int)$subuser['id'] + )); + expect($matchingScopedRows)->toHaveCount(1); + + $grantsById = []; + foreach ($matchingScopedRows[0]['grants'] ?? [] as $grant) { + $grantsById[(int)$grant['grant_id']] = $grant; + } + + expect($grantsById)->toHaveKey($grantId); + expect($grantsById[$grantId]['assigned_vehicle_id'])->toBe((int)$vehicle['id']); + expect($grantsById[$grantId]['assigned_vehicle_reg'])->toBe(strtoupper((string)$vehicle['reg'])); + expect($scoped->meta()['pagination']['total'] ?? null)->toBeGreaterThanOrEqual(1); + }); +}); + it('paginates superuser chauffeur lists by unique chauffeur instead of grant count', function (): void { $session = api_fixtures()->createUserSession(['list_subusers']); $suffix = (string)random_int(100000, 999999);