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:
Jeppe B
2026-07-20 14:09:20 +02:00
committed by GitHub
parent abde54c898
commit 677d4700b0
4 changed files with 155 additions and 0 deletions
+8
View File
@@ -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
@@ -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<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
*/
@@ -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);
});