Files
api/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php
T

507 lines
22 KiB
PHP

<?php
declare(strict_types=1);
use classes\limited_backoffice_service;
usesApiSuite();
function limited_backoffice_manager_session(array $departmentIds, array $extraPermissions = []): array
{
$permissions = [
limited_backoffice_service::PERMISSION_ACCESS,
limited_backoffice_service::PERMISSION_MANAGE_PRICES,
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES,
];
foreach ($departmentIds as $departmentId) {
$permissions[] = 'department_access_' . (int)$departmentId;
}
return api_fixtures()->createUserSession(array_values(array_unique(array_merge($permissions, $extraPermissions))));
}
function limited_backoffice_price_insert(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();
api_fixtures()->cleanupDeleteWhere('product_department_prices', [
'department_id' => $departmentId,
'product_id' => $productId,
]);
}
function limited_backoffice_price_value(int $departmentId, int $productId): ?int
{
$row = api_test_runtime()->queryOne(
'SELECT `price` FROM `product_department_prices` WHERE `department_id` = ' . $departmentId .
' AND `product_id` = ' . $productId . ' LIMIT 1'
);
return $row === null ? null : (int)$row['price'];
}
function limited_backoffice_cleanup_created_employee(int $employeeId): void
{
$row = api_test_runtime()->queryOne(
'SELECT `managed_group_id` FROM `limited_backoffice_employees` WHERE `user_id` = ' . $employeeId . ' LIMIT 1'
);
$groupId = (int)($row['managed_group_id'] ?? 0);
api_fixtures()->cleanupDeleteWhere('limited_backoffice_employees', ['user_id' => $employeeId]);
api_fixtures()->cleanupDeleteWhere('tokens', ['user_id' => $employeeId]);
api_fixtures()->cleanupDeleteById('users', $employeeId);
if ($groupId > 0) {
api_fixtures()->cleanupDeleteWhere('groups_permissions', ['group_id' => $groupId]);
api_fixtures()->cleanupDeleteById('groups', $groupId);
}
}
function limited_backoffice_without_users_deleted_at(callable $callback): void
{
$db = api_test_runtime()->db();
$column = $db->query("SHOW COLUMNS FROM `users` LIKE 'deleted_at'");
if ($column === false) {
throw new RuntimeException('Unable to inspect users.deleted_at test column.');
}
$hadColumn = (int)$column->num_rows > 0;
if ($hadColumn) {
$db->query('ALTER TABLE `users` DROP COLUMN `deleted_at`');
}
try {
$callback();
} finally {
if ($hadColumn) {
$db->query('ALTER TABLE `users` ADD COLUMN `deleted_at` DATETIME NULL');
}
}
}
function limited_backoffice_without_department_prices_updated_at(callable $callback): void
{
$db = api_test_runtime()->db();
$column = $db->query("SHOW COLUMNS FROM `product_department_prices` LIKE 'updated_at'");
if ($column === false) {
throw new RuntimeException('Unable to inspect product_department_prices.updated_at test column.');
}
$hadColumn = (int)$column->num_rows > 0;
if ($hadColumn) {
$db->query('ALTER TABLE `product_department_prices` DROP COLUMN `updated_at`');
}
try {
$callback();
} finally {
if ($hadColumn) {
$db->query(
'ALTER TABLE `product_department_prices`
ADD COLUMN `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
AFTER `created_at`'
);
}
}
}
it('lists and updates explicit prices only for assigned departments', function (): void {
api_test_covers('GET /limited-backoffice/departments', 'happy');
api_test_covers('GET /limited-backoffice/departments/{departmentId}/prices', 'happy');
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'happy');
$productDeletedAtColumn = api_test_runtime()->db()->query("SHOW COLUMNS FROM `products` LIKE 'deleted_at'");
expect($productDeletedAtColumn)->not->toBeFalse();
expect((int)$productDeletedAtColumn->num_rows)->toBe(0);
$department = api_fixtures()->createDepartment(['name' => 'Limited Prices Own']);
$otherDepartment = api_fixtures()->createDepartment(['name' => 'Limited Prices Other']);
$category = api_fixtures()->createCategory(['name' => 'Limited Washes']);
$product = api_fixtures()->createProduct([
'name' => 'Limited Wash Product',
'category' => $category['id'],
'price' => 98765,
]);
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
api_fixtures()->linkDepartmentCategory((int)$otherDepartment['id'], (int)$category['id']);
limited_backoffice_price_insert((int)$department['id'], (int)$product['id'], 1234);
limited_backoffice_price_insert((int)$otherDepartment['id'], (int)$product['id'], 4321);
$session = limited_backoffice_manager_session([(int)$department['id']]);
$departments = api_client()->get('/limited-backoffice/departments', $session['headers']);
$departments
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect(array_column($departments->data(), 'id'))
->toContain((int)$department['id'])
->not->toContain((int)$otherDepartment['id']);
$prices = api_client()->get('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', $session['headers']);
$prices
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($prices->body)->not->toContain('98765');
expect($prices->data()['categories'][0]['products'][0])
->toMatchArray([
'id' => (int)$product['id'],
'name' => 'Limited Wash Product',
'price' => 1234,
]);
$updated = api_client()->put('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', [
'prices' => [
['product_id' => (int)$product['id'], 'price' => '2222'],
],
], $session['headers']);
$updated
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect(limited_backoffice_price_value((int)$department['id'], (int)$product['id']))->toBe(2222);
expect(limited_backoffice_price_value((int)$otherDepartment['id'], (int)$product['id']))->toBe(4321);
});
it('updates department prices when the price table has no updated_at column', function (): void {
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'schema compatibility');
$department = api_fixtures()->createDepartment(['name' => 'Limited Prices Legacy Schema']);
$category = api_fixtures()->createCategory(['name' => 'Limited Prices Legacy Category']);
$products = [
api_fixtures()->createProduct(['name' => 'Legacy Price One', 'category' => $category['id']]),
api_fixtures()->createProduct(['name' => 'Legacy Price Two', 'category' => $category['id']]),
api_fixtures()->createProduct(['name' => 'Legacy Price Three', 'category' => $category['id']]),
];
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
foreach ($products as $index => $product) {
limited_backoffice_price_insert((int)$department['id'], (int)$product['id'], 100 + $index);
}
$session = limited_backoffice_manager_session([(int)$department['id']]);
limited_backoffice_without_department_prices_updated_at(function () use ($department, $products, $session): void {
$updated = api_client()->put('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', [
'prices' => [
['product_id' => (int)$products[0]['id'], 'price' => '999999'],
['product_id' => (int)$products[1]['id'], 'price' => '999999'],
['product_id' => (int)$products[2]['id'], 'price' => '99999'],
],
], $session['headers']);
$updated
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect(limited_backoffice_price_value((int)$department['id'], (int)$products[0]['id']))->toBe(999999);
expect(limited_backoffice_price_value((int)$department['id'], (int)$products[1]['id']))->toBe(999999);
expect(limited_backoffice_price_value((int)$department['id'], (int)$products[2]['id']))->toBe(99999);
});
});
it('rejects cross-department price access, body spoofing, and outside products', function (): void {
api_test_covers('GET /limited-backoffice/departments/{departmentId}/prices', 'auth');
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'auth');
$department = api_fixtures()->createDepartment(['name' => 'Limited Scope Own']);
$otherDepartment = api_fixtures()->createDepartment(['name' => 'Limited Scope Other']);
$category = api_fixtures()->createCategory(['name' => 'Limited Scope Category']);
$otherCategory = api_fixtures()->createCategory(['name' => 'Limited Scope Other Category']);
$product = api_fixtures()->createProduct(['category' => $category['id']]);
$outsideProduct = api_fixtures()->createProduct(['category' => $otherCategory['id']]);
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
api_fixtures()->linkDepartmentCategory((int)$otherDepartment['id'], (int)$otherCategory['id']);
limited_backoffice_price_insert((int)$department['id'], (int)$product['id'], 100);
limited_backoffice_price_insert((int)$otherDepartment['id'], (int)$outsideProduct['id'], 200);
$session = limited_backoffice_manager_session([(int)$department['id']]);
api_client()->get('/limited-backoffice/departments/' . (int)$otherDepartment['id'] . '/prices', $session['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['department_access_' . (int)$otherDepartment['id']]);
api_client()->put('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', [
'department_id' => (int)$otherDepartment['id'],
'prices' => [
['product_id' => (int)$product['id'], 'price' => 111],
],
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Department ID in body does not match the route.');
api_client()->put('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', [
'prices' => [
['product_id' => (int)$product['id'], 'price' => 111],
['product_id' => (int)$outsideProduct['id'], 'price' => 111],
],
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Product is not available for this department.');
expect(limited_backoffice_price_value((int)$department['id'], (int)$product['id']))->toBe(100);
expect(limited_backoffice_price_value((int)$otherDepartment['id'], (int)$outsideProduct['id']))->toBe(200);
});
it('fails price setup gaps without exposing product defaults', function (): void {
api_test_covers('GET /limited-backoffice/departments/{departmentId}/prices', 'failure');
$department = api_fixtures()->createDepartment(['name' => 'Limited Setup Gap']);
$category = api_fixtures()->createCategory(['name' => 'Limited Setup Gap Category']);
$product = api_fixtures()->createProduct([
'name' => 'Setup Gap Product',
'category' => $category['id'],
'price' => 87654,
]);
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
$session = limited_backoffice_manager_session([(int)$department['id']]);
$response = api_client()->get('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', $session['headers']);
$response
->assertStatus(409)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Department price setup is incomplete.');
expect($response->body)->not->toContain('87654');
expect($response->data()['missing_products'][0]['id'] ?? null)->toBe((int)$product['id']);
});
it('rejects invalid price batches and leaves existing prices unchanged', function (): void {
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'validation');
$department = api_fixtures()->createDepartment(['name' => 'Limited Invalid Prices']);
$category = api_fixtures()->createCategory(['name' => 'Limited Invalid Prices Category']);
$firstProduct = api_fixtures()->createProduct(['category' => $category['id']]);
$secondProduct = api_fixtures()->createProduct(['category' => $category['id']]);
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
limited_backoffice_price_insert((int)$department['id'], (int)$firstProduct['id'], 100);
limited_backoffice_price_insert((int)$department['id'], (int)$secondProduct['id'], 200);
$session = limited_backoffice_manager_session([(int)$department['id']]);
foreach ([null, '', 'abc', -1] as $invalidPrice) {
api_client()->put('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', [
'prices' => [
['product_id' => (int)$firstProduct['id'], 'price' => 999],
['product_id' => (int)$secondProduct['id'], 'price' => $invalidPrice],
],
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false);
expect(limited_backoffice_price_value((int)$department['id'], (int)$firstProduct['id']))->toBe(100);
expect(limited_backoffice_price_value((int)$department['id'], (int)$secondProduct['id']))->toBe(200);
}
api_client()->put('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', [
'prices' => [
['product_id' => (int)$firstProduct['id'], 'price' => 999],
],
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Price is required for every department product.');
expect(limited_backoffice_price_value((int)$department['id'], (int)$firstProduct['id']))->toBe(100);
});
it('creates updates lists and deactivates scoped employees without exposing raw permissions', function (): void {
limited_backoffice_without_users_deleted_at(function (): void {
api_test_covers('GET /limited-backoffice/roles', 'happy');
api_test_covers('GET /limited-backoffice/employees', 'happy');
api_test_covers('POST /limited-backoffice/employees', 'happy');
api_test_covers('PUT /limited-backoffice/employees/{employeeId}', 'happy');
api_test_covers('DELETE /limited-backoffice/employees/{employeeId}', 'happy');
$usersDeletedAtColumn = api_test_runtime()->db()->query("SHOW COLUMNS FROM `users` LIKE 'deleted_at'");
expect($usersDeletedAtColumn)->not->toBeFalse();
expect((int)$usersDeletedAtColumn->num_rows)->toBe(0);
$department = api_fixtures()->createDepartment(['name' => 'Limited Employee Department']);
$session = limited_backoffice_manager_session([(int)$department['id']]);
$roles = api_client()->get('/limited-backoffice/roles', $session['headers']);
$roles
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect(array_column($roles->data(), 'key'))->toBe(['viewer', 'cashier', 'booking_coordinator', 'operations_lead', 'department_admin']);
expect($roles->body)->not->toContain('department_access_');
$created = api_client()->post('/limited-backoffice/employees', [
'display_name' => 'Limited Cashier',
'email' => 'limited-cashier@example.test',
'password' => 'Secret123!',
'role_key' => 'cashier',
'department_ids' => [(int)$department['id']],
], $session['headers']);
$created
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$employeeId = (int)($created->data()['id'] ?? 0);
expect($employeeId)->toBeGreaterThan(0);
limited_backoffice_cleanup_created_employee($employeeId);
expect($created->body)->not->toContain('department_access_');
expect($created->body)->not->toContain('permissions');
$groupRow = api_test_runtime()->queryOne(
'SELECT `managed_group_id` FROM `limited_backoffice_employees` WHERE `user_id` = ' . $employeeId . ' LIMIT 1'
);
$groupId = (int)($groupRow['managed_group_id'] ?? 0);
$permissionRows = api_test_runtime()->db()->query(
'SELECT `permission` FROM `groups_permissions` WHERE `group_id` = ' . $groupId
)->fetch_all(MYSQLI_ASSOC);
$permissions = array_column($permissionRows, 'permission');
expect($permissions)
->toContain('department_access_' . (int)$department['id'])
->toContain('add_order')
->not->toContain('superuser');
$updated = api_client()->put('/limited-backoffice/employees/' . $employeeId, [
'display_name' => 'Limited Lead',
'role_key' => 'operations_lead',
'department_ids' => [(int)$department['id']],
], $session['headers']);
$updated
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($updated->data()['display_name'] ?? null)->toBe('Limited Lead');
expect($updated->data()['role']['key'] ?? null)->toBe('operations_lead');
$list = api_client()->get('/limited-backoffice/employees', $session['headers']);
$ids = array_column($list->data(), 'id');
expect($ids)->toContain($employeeId);
expect($list->body)->not->toContain('department_access_');
$deactivated = api_client()->delete('/limited-backoffice/employees/' . $employeeId, null, $session['headers']);
$deactivated
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($deactivated->data()['active'] ?? true)->toBeFalse();
$userRow = api_test_runtime()->queryOne('SELECT `password`, `group_id` FROM `users` WHERE `id` = ' . $employeeId);
expect($userRow)->not->toBeNull();
expect(array_key_exists('password', $userRow ?? []))->toBeTrue();
expect($userRow['password'])->toBeNull();
expect((int)($userRow['group_id'] ?? -1))->toBe(0);
$employeeRow = api_test_runtime()->queryOne(
'SELECT `deactivated_at` FROM `limited_backoffice_employees` WHERE `user_id` = ' . $employeeId . ' LIMIT 1'
);
expect($employeeRow['deactivated_at'] ?? null)->not->toBeNull();
});
});
it('rejects employee scopes roles raw permissions self edits superusers and shared groups', function (): void {
api_test_covers('POST /limited-backoffice/employees', 'validation');
api_test_covers('PUT /limited-backoffice/employees/{employeeId}', 'validation');
$department = api_fixtures()->createDepartment(['name' => 'Limited Employee Own']);
$otherDepartment = api_fixtures()->createDepartment(['name' => 'Limited Employee Other']);
$session = limited_backoffice_manager_session([(int)$department['id']]);
api_client()->post('/limited-backoffice/employees', [
'display_name' => 'Outside Employee',
'password' => 'Secret123!',
'role_key' => 'cashier',
'department_ids' => [(int)$otherDepartment['id']],
], $session['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['department_access_' . (int)$otherDepartment['id']]);
api_client()->post('/limited-backoffice/employees', [
'display_name' => 'Raw Employee',
'password' => 'Secret123!',
'role_key' => 'cashier',
'department_ids' => [(int)$department['id']],
'permissions' => ['superuser'],
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Raw permission and group assignment is not allowed.');
api_client()->post('/limited-backoffice/employees', [
'display_name' => 'Unknown Role Employee',
'password' => 'Secret123!',
'role_key' => 'superuser',
'department_ids' => [(int)$department['id']],
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Unknown role.');
api_client()->put('/limited-backoffice/employees/' . (int)$session['user']['id'], [
'display_name' => 'Self Edit',
], $session['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Managers cannot edit themselves.');
$superuser = api_fixtures()->createUser(['group_id' => 1]);
api_test_runtime()->db()->query(
'INSERT INTO `limited_backoffice_employees`
(`user_id`, `managed_group_id`, `role_key`, `department_ids`, `created_by_user_id`)
VALUES (' . (int)$superuser['id'] . ", 1, 'department_admin', '[" . (int)$department['id'] . "]', " . (int)$session['user']['id'] . ')'
);
api_fixtures()->cleanupDeleteWhere('limited_backoffice_employees', ['user_id' => (int)$superuser['id']]);
api_client()->put('/limited-backoffice/employees/' . (int)$superuser['id'], [
'display_name' => 'Edited Superuser',
], $session['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Cannot manage superuser accounts.');
$sharedGroup = api_fixtures()->createGroup();
$firstSharedUser = api_fixtures()->createUser(['group_id' => $sharedGroup['id']]);
api_fixtures()->createUser(['group_id' => $sharedGroup['id']]);
$departmentJson = '[' . (int)$department['id'] . ']';
api_test_runtime()->db()->query(
'INSERT INTO `limited_backoffice_employees`
(`user_id`, `managed_group_id`, `role_key`, `department_ids`, `created_by_user_id`)
VALUES (' . (int)$firstSharedUser['id'] . ', ' . (int)$sharedGroup['id'] . ", 'viewer', '" . $departmentJson . "', " . (int)$session['user']['id'] . ')'
);
api_fixtures()->cleanupDeleteWhere('limited_backoffice_employees', ['user_id' => (int)$firstSharedUser['id']]);
api_client()->put('/limited-backoffice/employees/' . (int)$firstSharedUser['id'], [
'display_name' => 'Edited Shared Group',
], $session['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Cannot manage shared groups.');
});