diff --git a/openapi.yaml b/openapi.yaml index 84c1844a..d0b519ac 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -6442,6 +6442,14 @@ paths: responses: '200': description: Success + '400': + description: Invalid booking input or a product blocked by active customer rules + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/CustomerRuleProductRestrictedResponse' + - type: object put: tags: - Bookings diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index e6042201..2b3c0d70 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -6771,6 +6771,14 @@ paths: responses: '200': description: Success + '400': + description: Invalid booking input or a product blocked by active customer rules + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/CustomerRuleProductRestrictedResponse' + - type: object put: tags: - Bookings diff --git a/services/nginx/app/routes/orderBookingRoute.php b/services/nginx/app/routes/orderBookingRoute.php index 8dff903a..b3c54a96 100644 --- a/services/nginx/app/routes/orderBookingRoute.php +++ b/services/nginx/app/routes/orderBookingRoute.php @@ -3,6 +3,7 @@ namespace routes; use classes\authentication; +use classes\customer_rule_product_restriction_service; use classes\email; use classes\order_bookings_counts_cache; use classes\order_bookings_list_cache; @@ -52,6 +53,7 @@ class orderBookingRoute $customer_number, (int)$department->id ); // Array of order_items_o objects + $this->requireBookingItemsAllowedForCustomer($customer_number, $items); /** * Input data */ @@ -835,6 +837,39 @@ class orderBookingRoute return $items; } + /** + * Enforce customer product rules again at the booking write boundary. + * + * The browser uses the same rules for guidance, but a stale or crafted + * request must not be able to persist a restricted base product or add-on. + * + * @param list> $items + */ + private function requireBookingItemsAllowedForCustomer(users_o $customer, array $items): void + { + global $response; + + $customerNumber = (int)$customer->customer_number->value(); + $restrictionService = new customer_rule_product_restriction_service(); + foreach ($items as $item) { + $violation = $restrictionService->violationForCustomerProduct( + $customerNumber, + (int)($item['id'] ?? 0) + ); + if ($violation === null) { + continue; + } + + $response->error([ + 'code' => $violation['code'], + 'message' => $violation['message'], + 'product_id' => $violation['product_id'], + 'rules' => $violation['rules'], + 'collections' => $violation['collections'], + ], 400); + } + } + /** * @throws Exception */ diff --git a/services/nginx/app/tests/Api/OrderBookingsCreateApiTest.php b/services/nginx/app/tests/Api/OrderBookingsCreateApiTest.php index 3adaefcb..c9d959da 100644 --- a/services/nginx/app/tests/Api/OrderBookingsCreateApiTest.php +++ b/services/nginx/app/tests/Api/OrderBookingsCreateApiTest.php @@ -49,6 +49,33 @@ function order_booking_create_department_price(int $departmentId, int $productId $statement->close(); } +function order_booking_configure_customer_rule_product(string $attribute, int $productId): void +{ + new \classes\customer_rule_product_restriction_service(); + $db = api_test_runtime()->db(); + $safeAttribute = $db->real_escape_string($attribute); + $result = $db->query( + "SELECT id FROM customer_rule_product_collections + WHERE attribute = '{$safeAttribute}' ORDER BY sort_order, id LIMIT 1" + ); + $collectionId = $result && $result->num_rows > 0 ? (int)$result->fetch_assoc()['id'] : 0; + if ($collectionId < 1) { + $db->query( + "INSERT INTO customer_rule_product_collections (attribute, name, sort_order) + VALUES ('{$safeAttribute}', 'Booking API exact restriction', 0)" + ); + $collectionId = (int)$db->insert_id; + } + $db->query( + "INSERT IGNORE INTO customer_rule_product_collection_products (collection_id, product_id) + VALUES ({$collectionId}, {$productId})" + ); + api_fixtures()->cleanupDeleteWhere('customer_rule_product_collection_products', [ + 'collection_id' => $collectionId, + 'product_id' => $productId, + ]); +} + it('lets customers create their own order bookings without booking permissions', function (): void { api_test_covers('POST /order-bookings', 'auth'); @@ -219,3 +246,80 @@ it('lets department-scoped users create order bookings for another customer', fu api_fixtures()->cleanupDeleteById('order_bookings', $bookingId); }); + +it('rejects a restricted base product when creating an order booking', function (): void { + api_test_covers('POST /order-bookings', 'customer-rule-validation'); + + $session = api_fixtures()->createUserSession(['user']); + $department = order_booking_create_department('Restricted Booking Product Department'); + $product = api_fixtures()->createProduct(['name' => 'Restricted Booking Base Product']); + api_fixtures()->addCustomerAttribute((int)$session['user']['id'], 'restrictSpotFree'); + order_booking_configure_customer_rule_product('restrictSpotFree', (int)$product['id']); + + $response = api_client()->post( + '/order-bookings', + order_booking_create_payload($session['user'], $department, $product, 'RULEBASE1'), + $session['headers'] + ); + + $response + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE); + expect($response->data()) + ->toMatchArray([ + 'code' => 'CUSTOMER_RULE_PRODUCT_RESTRICTED', + 'product_id' => (int)$product['id'], + ]); +}); + +it('rejects a booking when an add-on item is restricted', function (): void { + api_test_covers('POST /order-bookings', 'customer-rule-validation'); + + $session = api_fixtures()->createUserSession(['user']); + $department = order_booking_create_department('Restricted Booking Addon Department'); + $baseProduct = api_fixtures()->createProduct(['name' => 'Allowed Booking Base Product']); + $addonProduct = api_fixtures()->createProduct(['name' => 'Restricted Booking Add-on']); + api_fixtures()->addCustomerAttribute((int)$session['user']['id'], 'restrictAdditionalServices'); + order_booking_configure_customer_rule_product('restrictAdditionalServices', (int)$addonProduct['id']); + $payload = order_booking_create_payload($session['user'], $department, $baseProduct, 'RULEADD1'); + $payload['items'][] = ['id' => (int)$addonProduct['id'], 'quantity' => 1]; + + $response = api_client()->post('/order-bookings', $payload, $session['headers']); + + $response + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false); + expect($response->data()) + ->toMatchArray([ + 'code' => 'CUSTOMER_RULE_PRODUCT_RESTRICTED', + 'product_id' => (int)$addonProduct['id'], + ]); +}); + +it('allows an exact product that is not included in the active customer restriction', function (): void { + api_test_covers('POST /order-bookings', 'customer-rule-validation'); + + $session = api_fixtures()->createUserSession(['user']); + $department = order_booking_create_department('Allowed Booking Rule Department'); + $allowedProduct = api_fixtures()->createProduct(['name' => 'Allowed Booking Exact Product']); + $restrictedProduct = api_fixtures()->createProduct(['name' => 'Other Restricted Booking Product']); + api_fixtures()->addCustomerAttribute((int)$session['user']['id'], 'restrictSpotFree'); + order_booking_configure_customer_rule_product('restrictSpotFree', (int)$restrictedProduct['id']); + + $response = api_client()->post( + '/order-bookings', + order_booking_create_payload($session['user'], $department, $allowedProduct, 'RULEOK1'), + $session['headers'] + ); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + $bookingId = (int)($response->data()['id'] ?? 0); + expect($bookingId)->toBeGreaterThan(0); + api_fixtures()->cleanupDeleteById('order_bookings', $bookingId); +});