From a8a47104dd4b949959b4134d0ad0aba93dc1a0d0 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Mon, 1 Jun 2026 23:26:27 +0200 Subject: [PATCH] Restrict vehicle reference suggestions by department context --- .../order_reference_suggestions_service.php | 43 ++++++++++++++- .../tests/Api/ReferenceSuggestionsApiTest.php | 53 +++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/services/nginx/app/classes/order_reference_suggestions_service.php b/services/nginx/app/classes/order_reference_suggestions_service.php index c0daf1a9..a20bbda5 100644 --- a/services/nginx/app/classes/order_reference_suggestions_service.php +++ b/services/nginx/app/classes/order_reference_suggestions_service.php @@ -46,7 +46,7 @@ class order_reference_suggestions_service $rows = [ ...$this->fetchBookingRows($departmentId, $search), ...$this->fetchOrderRows($departmentId, $search), - ...$this->fetchVehicleRows($customerId, $plates, $search), + ...$this->fetchVehicleRows($departmentId, $customerId, $plates, $search), ]; $suggestions = $this->aggregateRows($rows, $search, $customerId, $plates); @@ -137,7 +137,7 @@ class order_reference_suggestions_service * @param array $plates * @return array> */ - private function fetchVehicleRows(?int $customerId, array $plates, string $search): array + private function fetchVehicleRows(int $departmentId, ?int $customerId, array $plates, string $search): array { $contextWhere = []; $params = []; @@ -171,6 +171,10 @@ class order_reference_suggestions_service $params['search'] = '%' . $this->lower($search) . '%'; } + $where[] = $this->vehicleDepartmentAccessPredicate(); + $params['orders_department_id'] = $departmentId; + $params['bookings_department_id'] = $departmentId; + $sql = "SELECT 'vehicle' AS source, id AS origin_id, @@ -190,6 +194,41 @@ class order_reference_suggestions_service return $this->fetchRows($sql, $params); } + private function vehicleDepartmentAccessPredicate(): string + { + $ordersWhere = [ + 'authorized_orders.department_id = :orders_department_id', + '(authorized_orders.customer_id = customer_vehicles.customer_id' + . " OR UPPER(REPLACE(authorized_orders.reg_1, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', ''))" + . " OR UPPER(REPLACE(authorized_orders.reg_2, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', ''))" + . " OR UPPER(REPLACE(authorized_orders.reg_3, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', '')))" + ]; + if ($this->tableHasColumn('orders', 'deleted_at')) { + $ordersWhere[] = 'authorized_orders.deleted_at IS NULL'; + } + + $bookingsWhere = [ + 'authorized_bookings.department = :bookings_department_id', + '(authorized_bookings.customer_number = customer_vehicles.customer_id' + . " OR UPPER(REPLACE(authorized_bookings.reg_1, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', ''))" + . " OR UPPER(REPLACE(authorized_bookings.reg_2, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', ''))" + . " OR UPPER(REPLACE(authorized_bookings.reg_3, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', '')))" + ]; + if ($this->tableHasColumn('order_bookings', 'deleted_at')) { + $bookingsWhere[] = 'authorized_bookings.deleted_at IS NULL'; + } + + return '(EXISTS ( + SELECT 1 + FROM orders authorized_orders + WHERE ' . implode(' AND ', $ordersWhere) . ' + ) OR EXISTS ( + SELECT 1 + FROM order_bookings authorized_bookings + WHERE ' . implode(' AND ', $bookingsWhere) . ' + ))'; + } + /** * @param array $params * @return array> diff --git a/services/nginx/app/tests/Api/ReferenceSuggestionsApiTest.php b/services/nginx/app/tests/Api/ReferenceSuggestionsApiTest.php index 3ffd449e..76ed6b89 100644 --- a/services/nginx/app/tests/Api/ReferenceSuggestionsApiTest.php +++ b/services/nginx/app/tests/Api/ReferenceSuggestionsApiTest.php @@ -179,6 +179,59 @@ it('orders reference suggestions by match relevance before context and frequency expect(array_slice($references, 0, 3))->toBe(['ABC', 'ABC-PREFIX', 'X-ABC-CONTAINS']); }); +it('does not return vehicle reference suggestions outside the authorized department context', function (): void { + api_test_covers('GET /orders/reference-suggestions', 'security'); + + $authorizedDepartment = api_fixtures()->createDepartment(); + $otherDepartment = api_fixtures()->createDepartment(); + $authorizedCustomer = api_fixtures()->createUser(['display_name' => 'Authorized Reference Customer']); + $otherCustomer = api_fixtures()->createUser(['display_name' => 'Other Tenant Reference Customer']); + $cashier = api_fixtures()->createUser(['display_name' => 'Reference Security Cashier']); + + api_fixtures()->createOrder([ + 'customer_id' => $authorizedCustomer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $authorizedDepartment['id'], + 'reference' => 'SAFE-DEPARTMENT-REF', + 'reg_1' => 'SAFE1', + ]); + api_fixtures()->createOrder([ + 'customer_id' => $otherCustomer['customer_number'], + 'cashier_id' => $cashier['id'], + 'department_id' => $otherDepartment['id'], + 'reference' => 'LEAK-ORDER-REF', + 'reg_1' => 'LEAK1', + ]); + api_fixtures()->createVehicle([ + 'customer_id' => $otherCustomer['customer_number'], + 'type' => 53, + 'reg' => 'LEAK1', + 'reference' => 'LEAK-VEHICLE-REF', + ]); + + $session = api_fixtures()->createUserSession([ + 'list_orders', + 'department_access_' . $authorizedDepartment['id'], + ]); + + $response = api_client()->get('/orders/reference-suggestions?' . http_build_query([ + 'search' => 'LEAK', + 'department_id' => $authorizedDepartment['id'], + 'customer_id' => $otherCustomer['customer_number'], + 'reg_1' => 'LEAK1', + ]), $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $suggestions = $response->data(); + expect($suggestions)->toBeArray(); + expect(reference_suggestion_by_reference($suggestions, 'LEAK-VEHICLE-REF'))->toBeNull(); + expect(reference_suggestion_by_reference($suggestions, 'LEAK-ORDER-REF'))->toBeNull(); +}); + it('enforces authentication, list permission, and department access for reference suggestions', function (): void { api_test_covers('GET /orders/reference-suggestions', 'auth');