Enforce customer product restrictions for order bookings (#318)
## What changed - validate every normalized order-booking item against active customer product rules before reservation and persistence - return a structured HTTP 400 response containing the rejected product and matching rule metadata - document the rejection response in both OpenAPI specifications - add API coverage for restricted base products, restricted add-ons, and allowed neighboring products ## Why Frontend rule guidance alone cannot prevent stale or crafted requests from persisting restricted booking products. The booking write boundary must enforce the same customer rules. ## Validation - full backend API suite - focused order-booking API coverage - PHP syntax checks - OpenAPI and diff checks ## Related frontend PR The coordinated frontend PR provides fail-closed selection, recovery, and responsive booking-page behavior.
This commit is contained in:
@@ -6442,6 +6442,14 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Success
|
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:
|
put:
|
||||||
tags:
|
tags:
|
||||||
- Bookings
|
- Bookings
|
||||||
|
|||||||
@@ -6771,6 +6771,14 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Success
|
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:
|
put:
|
||||||
tags:
|
tags:
|
||||||
- Bookings
|
- Bookings
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace routes;
|
namespace routes;
|
||||||
|
|
||||||
use classes\authentication;
|
use classes\authentication;
|
||||||
|
use classes\customer_rule_product_restriction_service;
|
||||||
use classes\email;
|
use classes\email;
|
||||||
use classes\order_bookings_counts_cache;
|
use classes\order_bookings_counts_cache;
|
||||||
use classes\order_bookings_list_cache;
|
use classes\order_bookings_list_cache;
|
||||||
@@ -52,6 +53,7 @@ class orderBookingRoute
|
|||||||
$customer_number,
|
$customer_number,
|
||||||
(int)$department->id
|
(int)$department->id
|
||||||
); // Array of order_items_o objects
|
); // Array of order_items_o objects
|
||||||
|
$this->requireBookingItemsAllowedForCustomer($customer_number, $items);
|
||||||
/**
|
/**
|
||||||
* Input data
|
* Input data
|
||||||
*/
|
*/
|
||||||
@@ -835,6 +837,39 @@ class orderBookingRoute
|
|||||||
return $items;
|
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<array<string, mixed>> $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
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -49,6 +49,33 @@ function order_booking_create_department_price(int $departmentId, int $productId
|
|||||||
$statement->close();
|
$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 {
|
it('lets customers create their own order bookings without booking permissions', function (): void {
|
||||||
api_test_covers('POST /order-bookings', 'auth');
|
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);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user