Files
api/services/nginx/app/tests/Api/OrderBookingsCreateApiTest.php
T
Jeppe B 677d4700b0 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.
2026-07-20 14:09:20 +02:00

326 lines
12 KiB
PHP

<?php
declare(strict_types=1);
usesApiSuite();
function order_booking_create_payload(array $customer, array $department, array $product, string $reference): array
{
return [
'customer_number' => (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'],
]);
}
function order_booking_create_department_price(int $departmentId, int $productId, int $price): void
{
$statement = api_test_runtime()->db()->prepare(
'INSERT INTO `product_department_prices` (`department_id`, `product_id`, `price`)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE `price` = VALUES(`price`)'
);
$statement->bind_param('iii', $departmentId, $productId, $price);
$statement->execute();
$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');
$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('normalizes booking item prices from server-side customer and department pricing', function (): void {
api_test_covers('POST /order-bookings', 'pricing');
$session = api_fixtures()->createUserSession(['user']);
$department = order_booking_create_department('Own Booking Pricing Department');
$product = api_fixtures()->createProduct([
'name' => 'Booking Price Normalized Product',
'price' => 0,
'is_wash' => 0,
'display_in_booking_form' => 1,
]);
order_booking_create_department_price((int)$department['id'], (int)$product['id'], 425);
$payload = order_booking_create_payload($session['user'], $department, $product, 'PRICEFIX1');
$payload['items'][0]['price'] = 0;
$response = api_client()->post('/order-bookings', $payload, $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$bookingId = (int)($response->data()['id'] ?? 0);
expect($bookingId)->toBeGreaterThan(0);
$responseItems = $response->data()['items'] ?? [];
expect($responseItems)
->toBeArray()
->and((int)($responseItems[0]['price'] ?? 0))->toBe(425);
$row = api_fixtures()->fetchRowById('order_bookings', $bookingId);
$storedItems = json_decode((string)($row['items'] ?? '[]'), true);
expect($storedItems)
->toBeArray()
->and((int)($storedItems[0]['price'] ?? 0))->toBe(425);
api_fixtures()->cleanupDeleteWhere('product_department_prices', [
'department_id' => (int)$department['id'],
'product_id' => (int)$product['id'],
]);
api_fixtures()->cleanupDeleteById('order_bookings', $bookingId);
});
it('blocks subusers creating 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(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['add_own_bookings']);
});
it('lets subusers create own customer order bookings with the bookings add node', function (): void {
api_test_covers('POST /order-bookings', 'auth');
$customer = api_fixtures()->createUser(['display_name' => 'Subuser Booking Customer With Add']);
$session = api_fixtures()->createSubuserSession((int)$customer['customer_number'], ['BOOKINGS_ADD']);
$department = order_booking_create_department('Subuser Booking Add Department');
$product = api_fixtures()->createProduct(['name' => 'Subuser Booking Add Product']);
$response = api_client()->post(
'/order-bookings',
order_booking_create_payload($customer, $department, $product, 'SUBBOOK2'),
$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);
});
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);
});