diff --git a/services/nginx/app/routes/departmentsRoute.php b/services/nginx/app/routes/departmentsRoute.php index 5cb5692f..d28d128e 100644 --- a/services/nginx/app/routes/departmentsRoute.php +++ b/services/nginx/app/routes/departmentsRoute.php @@ -250,11 +250,17 @@ class departmentsRoute $this->get('/departments/categories', function () { // Require the user to be logged in global $response; - self::requirePermission('list_department_categories'); - // Get the user object - $user = (new authentication())->get_user(); + $auth = new authentication(); + $user = $auth->get_user(); + $subuser = $auth->get_subuser(); // Check if the request was successful - if ($user) { + if ($user || $subuser) { + $isCustomerBookingSession = ($user && self::hasPermission('user')) || $subuser; + if (!$isCustomerBookingSession && !self::hasPermission('list_department_categories')) { + $this->emitForbidden(['list_department_categories']); + } + + $responsibleUserId = $user ? (int)$user->id : 0; // Require the department id self::requireParameters(['id']); self::requireType((int)self::getParameter('id'), self::TYPE_INT()); @@ -263,14 +269,14 @@ class departmentsRoute // Validate the department categories object if (!$department->exists()) { // Log the incident - (new logs_o())->add('departments', 'global', 1, $user->id, 'LIST_DEPARTMENT_CATEGORIES', 'Department categories not found'); + (new logs_o())->add('departments', 'global', 1, $responsibleUserId, 'LIST_DEPARTMENT_CATEGORIES', 'Department categories not found'); // Return an error $response->error('Department categories not found', 400); } // Get the department categories $department_categories = new department_categories_o(); // Log the incident - (new logs_o())->add('departments', 'global', 1, $user->id, 'LIST_DEPARTMENT_CATEGORIES', 'Successfully listed department categories'); + (new logs_o())->add('departments', 'global', 1, $responsibleUserId, 'LIST_DEPARTMENT_CATEGORIES', 'Successfully listed department categories'); // Return the list of department categories $response->success( $department_categories @@ -295,7 +301,7 @@ class departmentsRoute } }, [ - 'list_department_categories' => 'List all department categories' + 'list_department_categories' => 'List all department categories. Authenticated customer booking sessions may read this endpoint without the permission.' ] ); diff --git a/services/nginx/app/routes/orderBookingRoute.php b/services/nginx/app/routes/orderBookingRoute.php index 9e3ba47d..264749d6 100644 --- a/services/nginx/app/routes/orderBookingRoute.php +++ b/services/nginx/app/routes/orderBookingRoute.php @@ -41,18 +41,9 @@ class orderBookingRoute $po = self::getTargetPo(); // String | Null $pickup = self::getTargetPickup(); // Bool | Null $items = self::getTargetItems(); // Array of order_items_o objects - /** - * Permissions (clean helper) - */ - $permission_own = self::definePermission('add_own_bookings', subusers_permission_node_key::BOOKINGS_ADD); - $permission_other = self::definePermission('add_bookings'); - self::allowOwnOrDepartmentAccess( - $permission_own, - $permission_other, + $this->requireOrderBookingCreateAccess( (int)$customer_number->customer_number->value(), - (int)$department->id, - null, - 'You do not have permission to create this order booking.' + (int)$department->id ); /** * Input data @@ -96,8 +87,7 @@ class orderBookingRoute $response->success($order_bookings_o->asArray()); }, [ - 'add_own_bookings' => 'Permission to create own order bookings. Subusers require node: BOOKINGS_ADD and X-Customer-Number header.', - 'add_bookings' => 'Permission to create department order bookings.' + 'add_bookings' => 'Permission to create order bookings for another customer or department scope.' ] ); @@ -659,6 +649,34 @@ class orderBookingRoute return $object; } + private function requireOrderBookingCreateAccess(int $targetCustomerNumber, int $departmentId): void + { + if ($this->isOrderBookingCustomerSession() && $this->isOwnCustomerContext($targetCustomerNumber)) { + return; + } + + $permissionOther = self::definePermission('add_bookings'); + if (!self::hasPermission($permissionOther)) { + $this->emitForbidden([$permissionOther]); + } + + self::requireDepartmentAccess((string)$departmentId); + } + + private function isOrderBookingCustomerSession(): bool + { + try { + $auth = new authentication(); + if ($auth->get_subuser() !== false) { + return true; + } + + return $auth->get_user() !== false && self::hasPermission('user'); + } catch (Exception) { + return false; + } + } + /** * @throws Exception If the Department is invalid. */ diff --git a/services/nginx/app/tests/Api/DepartmentsApiTest.php b/services/nginx/app/tests/Api/DepartmentsApiTest.php index 968e82dc..9cde9e41 100644 --- a/services/nginx/app/tests/Api/DepartmentsApiTest.php +++ b/services/nginx/app/tests/Api/DepartmentsApiTest.php @@ -301,6 +301,42 @@ it('lists department categories for a department', function (): void { ->and($response->data()[0]['category']['id'] ?? null)->toBe($category['id']); }); +it('lets customer booking sessions list department categories without the management permission', function (): void { + api_test_covers('GET /departments/categories', 'auth'); + + $customerSession = api_fixtures()->createUserSession(['user']); + $department = api_fixtures()->createDepartment(); + $category = api_fixtures()->createCategory([ + 'name' => 'Customer Department Category', + ]); + api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']); + + $customerResponse = api_client()->get('/departments/categories?id=' . $department['id'], $customerSession['headers']); + + $customerResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($customerResponse->data()) + ->toBeArray() + ->toHaveCount(1) + ->and($customerResponse->data()[0]['category']['id'] ?? null)->toBe($category['id']); + + $subuserSession = api_fixtures()->createSubuserSession((int)$customerSession['user']['customer_number'], []); + $subuserResponse = api_client()->get('/departments/categories?id=' . $department['id'], $subuserSession['headers']); + + $subuserResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($subuserResponse->data()) + ->toBeArray() + ->toHaveCount(1) + ->and($subuserResponse->data()[0]['category']['id'] ?? null)->toBe($category['id']); +}); + it('rejects invalid department category requests', function (): void { api_test_covers('GET /departments/categories', 'failure'); diff --git a/services/nginx/app/tests/Api/OrderBookingsCreateApiTest.php b/services/nginx/app/tests/Api/OrderBookingsCreateApiTest.php new file mode 100644 index 00000000..1960ed65 --- /dev/null +++ b/services/nginx/app/tests/Api/OrderBookingsCreateApiTest.php @@ -0,0 +1,144 @@ + (int)$customer['customer_number'], + 'department' => (int)$department['id'], + 'reg_1' => $reference, + 'datetime' => '2026-07-07 10:00:00', + 'note' => '', + 'reference' => $reference, + 'po' => '', + 'pickup' => false, + 'items' => [ + [ + 'id' => (int)$product['id'], + 'quantity' => 1, + ], + ], + ]; +} + +function order_booking_create_department(string $name): array +{ + $branding = api_fixtures()->createBranding([ + 'name' => $name . ' Brand', + 'address' => 'API Booking Street 1', + ]); + + return api_fixtures()->createDepartment([ + 'name' => $name, + 'branding' => (int)$branding['id'], + ]); +} + +it('lets customers create their own order bookings without booking permissions', function (): void { + api_test_covers('POST /order-bookings', 'auth'); + + $session = api_fixtures()->createUserSession(['user']); + $department = order_booking_create_department('Own Booking Department'); + $product = api_fixtures()->createProduct(['name' => 'Own Booking Product']); + + $response = api_client()->post( + '/order-bookings', + order_booking_create_payload($session['user'], $department, $product, 'OWNBOOK1'), + $session['headers'] + ); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $bookingId = (int)($response->data()['id'] ?? 0); + expect($bookingId)->toBeGreaterThan(0); + + $row = api_fixtures()->fetchRowById('order_bookings', $bookingId); + expect($row)->not->toBeNull(); + expect((int)($row['customer_number'] ?? 0))->toBe((int)$session['user']['customer_number']); + + api_fixtures()->cleanupDeleteById('order_bookings', $bookingId); +}); + +it('lets subusers create own customer order bookings without the bookings add node', function (): void { + api_test_covers('POST /order-bookings', 'auth'); + + $customer = api_fixtures()->createUser(['display_name' => 'Subuser Booking Customer']); + $session = api_fixtures()->createSubuserSession((int)$customer['customer_number'], []); + $department = order_booking_create_department('Subuser Booking Department'); + $product = api_fixtures()->createProduct(['name' => 'Subuser Booking Product']); + + $response = api_client()->post( + '/order-bookings', + order_booking_create_payload($customer, $department, $product, 'SUBBOOK1'), + $session['headers'] + ); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $bookingId = (int)($response->data()['id'] ?? 0); + expect($bookingId)->toBeGreaterThan(0); + + $row = api_fixtures()->fetchRowById('order_bookings', $bookingId); + expect($row)->not->toBeNull(); + expect((int)($row['customer_number'] ?? 0))->toBe((int)$customer['customer_number']); + + api_fixtures()->cleanupDeleteById('order_bookings', $bookingId); +}); + +it('still requires elevated access for creating another customer order booking', function (): void { + api_test_covers('POST /order-bookings', 'auth'); + + $session = api_fixtures()->createUserSession(['user']); + $otherCustomer = api_fixtures()->createUser(['display_name' => 'Other Booking Customer']); + $department = api_fixtures()->createDepartment(['name' => 'Other Booking Department']); + $product = api_fixtures()->createProduct(['name' => 'Other Booking Product']); + + $response = api_client()->post( + '/order-bookings', + order_booking_create_payload($otherCustomer, $department, $product, 'OTHBOOK1'), + $session['headers'] + ); + + $response + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMissingPermissions(['add_bookings']); +}); + +it('lets department-scoped users create order bookings for another customer', function (): void { + api_test_covers('POST /order-bookings', 'happy'); + + $customer = api_fixtures()->createUser(['display_name' => 'Department Booking Customer']); + $department = order_booking_create_department('Department Scoped Booking Department'); + $product = api_fixtures()->createProduct(['name' => 'Department Scoped Booking Product']); + $session = api_fixtures()->createUserSession([ + 'add_bookings', + 'department_access_' . $department['id'], + ]); + + $response = api_client()->post( + '/order-bookings', + order_booking_create_payload($customer, $department, $product, 'DEPTBOOK'), + $session['headers'] + ); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $bookingId = (int)($response->data()['id'] ?? 0); + expect($bookingId)->toBeGreaterThan(0); + + api_fixtures()->cleanupDeleteById('order_bookings', $bookingId); +}); diff --git a/services/nginx/app/tests/Api/OrderItemsApiTest.php b/services/nginx/app/tests/Api/OrderItemsApiTest.php index 869ff044..edf1be02 100644 --- a/services/nginx/app/tests/Api/OrderItemsApiTest.php +++ b/services/nginx/app/tests/Api/OrderItemsApiTest.php @@ -147,7 +147,7 @@ it('only allows tankcleaning products for only tankcleaning customers', function ->assertStatus(400) ->assertEnvelope() ->assertSuccess(false) - ->assertMessage(\classes\customer_order_product_policy::ONLY_TANKCLEANING_MESSAGE); + ->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE); $response = api_client()->post('/order/items', [ 'order_id' => $order['id'], @@ -274,7 +274,9 @@ it('blocks addon products added as standalone additional order items for custome ->assertEnvelope() ->assertSuccess(); - post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers']) + post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers'], [ + 'notes' => 'Addon customer rule check', + ]) ->assertStatus(400) ->assertEnvelope() ->assertSuccess(false)