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']); $rolesByKey = array_column($roles->data(), null, 'key'); expect($rolesByKey['viewer']['permission_groups'] ?? null)->toBe([ [ 'key' => 'account', 'capabilities' => ['sign_in', 'view_own_permissions'], ], ]); $departmentAdminGroups = array_column($rolesByKey['department_admin']['permission_groups'] ?? [], 'capabilities', 'key'); expect($departmentAdminGroups['limited_backoffice'] ?? null)->toBe([ 'open_limited_backoffice', 'manage_department_prices', 'manage_employee_access', ]); expect($roles->body)->not->toContain('department_access_'); $rolePayload = $roles->data(); $rolePayloadStrings = []; array_walk_recursive($rolePayload, static function ($value) use (&$rolePayloadStrings): void { if (is_string($value)) { $rolePayloadStrings[] = $value; } }); foreach ([ 'list_orders', 'add_order', 'edit_order', 'delete_order', 'list_order_items', 'add_order_items', 'edit_order_items', 'delete_order_items', 'charge_order', 'list_bookings', 'list_own_bookings', 'edit_bookings', 'add_booking', 'complete_bookings', 'resend_booking_confirmations', 'department_timebookings_entries_get', 'department_timebookings_entries_post', 'department_timebookings_entries_put', 'statistics_orders_new', 'statistics_bookings_new', 'limited_backoffice_access', 'limited_backoffice_prices_manage', 'limited_backoffice_employees_manage', ] as $rawPermission) { expect($rolePayloadStrings)->not->toContain($rawPermission); } $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.'); });