From 8bbdf9daf5b6e917aa6f553dee40503aaf071544 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 6 Jul 2026 16:48:07 +0200 Subject: [PATCH 1/2] Require booking add node for subuser booking creation --- .../nginx/app/routes/orderBookingRoute.php | 19 +++++++++++++-- .../tests/Api/OrderBookingsCreateApiTest.php | 23 ++++++++++++++++++- ...OrderBookingCreatePermissionWiringTest.php | 12 ++++++++++ 3 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 services/nginx/app/tests/Unit/Bookings/OrderBookingCreatePermissionWiringTest.php diff --git a/services/nginx/app/routes/orderBookingRoute.php b/services/nginx/app/routes/orderBookingRoute.php index 264749d6..67573e68 100644 --- a/services/nginx/app/routes/orderBookingRoute.php +++ b/services/nginx/app/routes/orderBookingRoute.php @@ -87,7 +87,8 @@ class orderBookingRoute $response->success($order_bookings_o->asArray()); }, [ - 'add_bookings' => 'Permission to create order bookings for another customer or department scope.' + 'add_bookings' => 'Permission to create order bookings for another customer or department scope.', + 'add_own_bookings' => 'Permission to create own order bookings. Subusers require node: BOOKINGS_ADD.' ] ); @@ -651,7 +652,21 @@ class orderBookingRoute private function requireOrderBookingCreateAccess(int $targetCustomerNumber, int $departmentId): void { - if ($this->isOrderBookingCustomerSession() && $this->isOwnCustomerContext($targetCustomerNumber)) { + $auth = new authentication(); + + if ($auth->get_subuser() !== false && $this->isOwnCustomerContext($targetCustomerNumber)) { + $permissionOwn = self::definePermission('add_own_bookings', subusers_permission_node_key::BOOKINGS_ADD); + if (!self::hasPermission($permissionOwn)) { + $this->emitForbidden([$permissionOwn]); + } + return; + } + + if ( + $auth->get_user() !== false + && self::hasPermission('user') + && $this->isOwnCustomerContext($targetCustomerNumber) + ) { return; } diff --git a/services/nginx/app/tests/Api/OrderBookingsCreateApiTest.php b/services/nginx/app/tests/Api/OrderBookingsCreateApiTest.php index 1960ed65..bb236dbf 100644 --- a/services/nginx/app/tests/Api/OrderBookingsCreateApiTest.php +++ b/services/nginx/app/tests/Api/OrderBookingsCreateApiTest.php @@ -65,7 +65,7 @@ it('lets customers create their own order bookings without booking permissions', api_fixtures()->cleanupDeleteById('order_bookings', $bookingId); }); -it('lets subusers create own customer order bookings without the bookings add node', function (): void { +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']); @@ -79,6 +79,27 @@ it('lets subusers create own customer order bookings without the bookings add no $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() diff --git a/services/nginx/app/tests/Unit/Bookings/OrderBookingCreatePermissionWiringTest.php b/services/nginx/app/tests/Unit/Bookings/OrderBookingCreatePermissionWiringTest.php new file mode 100644 index 00000000..defd8e99 --- /dev/null +++ b/services/nginx/app/tests/Unit/Bookings/OrderBookingCreatePermissionWiringTest.php @@ -0,0 +1,12 @@ +toBeTrue(); + + $code = (string)file_get_contents($routeFile); + $normalized = preg_replace('/\s+/', ' ', $code); + + expect($normalized)->toContain("definePermission('add_own_bookings', subusers_permission_node_key::BOOKINGS_ADD)"); + expect($normalized)->toContain("'add_own_bookings' => 'Permission to create own order bookings. Subusers require node: BOOKINGS_ADD.'"); +}); From 08dc803b3e66d2ff35203f150248b4eb0b9ed3ae Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 6 Jul 2026 16:59:32 +0200 Subject: [PATCH 2/2] Make limited backoffice employees regular employees --- .../classes/limited_backoffice_service.php | 51 ++++- services/nginx/app/objects/users_o.php | 56 ++++++ .../app/routes/limitedBackofficeRoute.php | 4 +- services/nginx/app/routes/usersRoute.php | 90 +++++++-- .../tests/Api/LimitedBackofficeApiTest.php | 189 +++++++++++++++++- 5 files changed, 369 insertions(+), 21 deletions(-) diff --git a/services/nginx/app/classes/limited_backoffice_service.php b/services/nginx/app/classes/limited_backoffice_service.php index ab92625e..0f0f28a1 100644 --- a/services/nginx/app/classes/limited_backoffice_service.php +++ b/services/nginx/app/classes/limited_backoffice_service.php @@ -12,6 +12,18 @@ class limited_backoffice_service public const PERMISSION_MANAGE_PRICES = 'limited_backoffice_prices_manage'; public const PERMISSION_MANAGE_EMPLOYEES = 'limited_backoffice_employees_manage'; + private const PERMISSION_PUBLIC_EMPLOYEE_DATA = 'employee_public_data'; + + /** + * Permissions required for managed employees to sign in and appear in the employee login picker. + * + * @var array + */ + private const MANAGED_EMPLOYEE_BASE_PERMISSIONS = [ + 'user', + self::PERMISSION_PUBLIC_EMPLOYEE_DATA, + ]; + /** * @var array}> */ @@ -255,15 +267,19 @@ class limited_backoffice_service /** * @return array}>}> */ - public function rolePresets(): array + public function rolePresets(?users_o $manager = null): array { $roles = []; foreach (self::ROLE_PRESETS as $key => $preset) { + $permissions = $manager === null + ? $preset['permissions'] + : $this->effectiveRolePermissionsForManager($manager, $key, false); + $roles[] = [ 'key' => $key, 'label' => $preset['label'], 'description' => $preset['description'], - 'permission_groups' => $this->rolePermissionGroups($preset['permissions']), + 'permission_groups' => $this->rolePermissionGroups($permissions), ]; } return $roles; @@ -677,7 +693,7 @@ class limited_backoffice_service try { if ($active) { - $this->replaceGroupPermissions($managedGroupId, $this->permissionsForRoleAndDepartments($roleKey, $newDepartmentIds)); + $this->replaceGroupPermissions($managedGroupId, $this->permissionsForRoleAndDepartments($manager, $roleKey, $newDepartmentIds)); } $userUpdates = []; @@ -1226,18 +1242,40 @@ class limited_backoffice_service $groupId = (int)$this->mysqli()->insert_id; $statement->close(); - $this->replaceGroupPermissions($groupId, $this->permissionsForRoleAndDepartments($roleKey, $departmentIds)); + $this->replaceGroupPermissions($groupId, $this->permissionsForRoleAndDepartments($manager, $roleKey, $departmentIds)); return $groupId; } + /** + * @return array + */ + private function effectiveRolePermissionsForManager(users_o $manager, string $roleKey, bool $includePublicVisibility): array + { + $permissions = $includePublicVisibility ? self::MANAGED_EMPLOYEE_BASE_PERMISSIONS : ['user']; + + foreach (self::ROLE_PRESETS[$roleKey]['permissions'] ?? [] as $permission) { + if (in_array($permission, self::MANAGED_EMPLOYEE_BASE_PERMISSIONS, true)) { + $permissions[] = $permission; + continue; + } + + if ($manager->hasPermission($permission)) { + $permissions[] = $permission; + } + } + + sort($permissions); + return array_values(array_unique($permissions)); + } + /** * @param array $departmentIds * @return array */ - private function permissionsForRoleAndDepartments(string $roleKey, array $departmentIds): array + private function permissionsForRoleAndDepartments(users_o $manager, string $roleKey, array $departmentIds): array { - $permissions = self::ROLE_PRESETS[$roleKey]['permissions'] ?? []; + $permissions = $this->effectiveRolePermissionsForManager($manager, $roleKey, true); foreach ($departmentIds as $departmentId) { $permissions[] = 'department_access_' . $departmentId; } @@ -1356,6 +1394,7 @@ class limited_backoffice_service { return [ 'id' => (int)$row['user_id'], + 'user_id' => (int)$row['user_id'], 'customer_number' => (int)$row['customer_number'], 'display_name' => (string)($row['display_name'] ?? ''), 'email' => $row['email'] === null ? null : (string)$row['email'], diff --git a/services/nginx/app/objects/users_o.php b/services/nginx/app/objects/users_o.php index b9c4b677..5f919f7f 100644 --- a/services/nginx/app/objects/users_o.php +++ b/services/nginx/app/objects/users_o.php @@ -1077,9 +1077,65 @@ class users_o extends db return $tmp; } + /** + * @param array> $users + * @return array> + */ + public function markLimitedBackofficeManagedUsers(array $users): array + { + $userIds = []; + foreach ($users as $user) { + $userId = (int)($user['id'] ?? 0); + if ($userId > 0) { + $userIds[$userId] = true; + } + } + + if ($userIds === []) { + return $users; + } + + global $db; + $rows = $db->fetch_all($db->query( + 'SELECT `user_id` FROM `limited_backoffice_employees` WHERE `user_id` IN (' . + implode(',', array_map('intval', array_keys($userIds))) . + ')' + )); + + $managedUserIds = []; + foreach ($rows as $row) { + $managedUserIds[(int)$row['user_id']] = true; + } + + foreach ($users as $key => $user) { + $users[$key]['limited_backoffice_managed'] = isset($managedUserIds[(int)($user['id'] ?? 0)]); + } + + return $users; + } + + public function isLimitedBackofficeManagedUser(int $userId): bool + { + if ($userId <= 0) { + return false; + } + + global $db; + $result = $db->query( + 'SELECT `user_id` FROM `limited_backoffice_employees` WHERE `user_id` = ' . (int)$userId . ' LIMIT 1' + ); + + return $result !== false && $result->num_rows > 0; + } + public function parseCustomerNumbers(array $listObjectsWithPaginationIfSet): array { foreach ( $listObjectsWithPaginationIfSet as $key => $value ) { + if ((bool)($value['limited_backoffice_managed'] ?? false) || (int)($value['customer_number'] ?? -1) === 0) { + $listObjectsWithPaginationIfSet[$key]['customer_name'] = $value['display_name'] ?? null; + continue; + } + $listObjectsWithPaginationIfSet[$key]['customer_name'] = $this->getCustomerNameById($value['id']); } return $listObjectsWithPaginationIfSet; diff --git a/services/nginx/app/routes/limitedBackofficeRoute.php b/services/nginx/app/routes/limitedBackofficeRoute.php index 88be1389..29566f98 100644 --- a/services/nginx/app/routes/limitedBackofficeRoute.php +++ b/services/nginx/app/routes/limitedBackofficeRoute.php @@ -45,10 +45,10 @@ class limitedBackofficeRoute ]); $this->get('/limited-backoffice/roles', function () { - $this->withLimitedBackoffice(function (limited_backoffice_service $service): array { + $this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array { $this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS); $this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES); - return $service->rolePresets(); + return $service->rolePresets($user); }); }, [ limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice', diff --git a/services/nginx/app/routes/usersRoute.php b/services/nginx/app/routes/usersRoute.php index dc3aa324..03dbe28f 100644 --- a/services/nginx/app/routes/usersRoute.php +++ b/services/nginx/app/routes/usersRoute.php @@ -27,19 +27,28 @@ class usersRoute (new logs_o())->add('users', 'global', 1, $user->id, 'LIST_USERS', 'Successfully listed users'); // Return the list of users $users_o = new users_o(); - $response->success( - $users_o->parseUsers( - $users_o - ->setSearchableFields([ - // The fields that can be searched. This would otherwise make it possible to get secret information from the database, simply by searching for it and getting the result count back - 'id', - 'customer_number', - 'group_id', - 'display_name', - ]) - ->listObjectsWithPaginationIfSet() - ) + $limitedEmployeeListMode = $this->limitedBackofficeEmployeeListMode($users_o); + $users = $users_o + ->setSearchableFields([ + // The fields that can be searched. This would otherwise make it possible to get secret information from the database, simply by searching for it and getting the result count back + 'id', + 'customer_number', + 'group_id', + 'display_name', + ]) + ->listObjectsWithPaginationIfSet( + null, + $limitedEmployeeListMode['filters'], + [], + $limitedEmployeeListMode['additional_where'] + ); + if ($limitedEmployeeListMode['enabled']) { + $users = $users_o->markLimitedBackofficeManagedUsers($users); + } + $users = $users_o->parseUsers( + $users ); + $response->success($users); } else { // Log the incident (new logs_o())->add('users', 'global', 1, 0, 'LIST_USERS', 'No user found, or invalid session'); @@ -153,6 +162,23 @@ class usersRoute if (!isset($data['display_name']) || $data['display_name'] === 'null' || $data['display_name'] === '') { $data['display_name'] = null; } + $targetUser = (new users_o())->getUserById((int)$data['id']); + if (!$targetUser->exists()) { + $response->error('User not found', 404); + } + + if ((new users_o())->isLimitedBackofficeManagedUser((int)$data['id'])) { + $currentCustomerNumber = (string)$targetUser->customer_number->value(); + if ((string)$data['customer_number'] !== $currentCustomerNumber) { + $response->error('Limited backoffice managed users cannot change customer number.', 403); + } + + if ($data['role'] !== null && (int)$data['role'] !== (int)$targetUser->group_id->value()) { + $response->error('Limited backoffice managed users cannot change role.', 403); + } + + $data['role'] = null; + } // If the role is set, require the edit_user_role permission if ($data['role']) { $this->requirePermission('edit_user_role'); @@ -207,4 +233,44 @@ class usersRoute ] ); } + + /** + * @return array{enabled:bool,filters:string|null,additional_where:string|null} + */ + private function limitedBackofficeEmployeeListMode(users_o $users): array + { + $enabled = strtolower((string)($this->fromQuery('include_limited_backoffice_employees') ?? 'false')) === 'true'; + $filters = $this->fromQuery('filters'); + + if (!$enabled || $filters === null || $filters === '') { + return [ + 'enabled' => false, + 'filters' => null, + 'additional_where' => null, + ]; + } + + $filterArray = $users->filter_string_to_array($filters); + $customerNumberFilter = $filterArray['customer_number'] ?? null; + $isEmployeeFilter = $customerNumberFilter === '0' + || $customerNumberFilter === 0 + || (is_array($customerNumberFilter) && in_array('0', $customerNumberFilter, true)); + + if (!$isEmployeeFilter) { + return [ + 'enabled' => false, + 'filters' => $filters, + 'additional_where' => null, + ]; + } + + unset($filterArray['customer_number']); + $activeLimitedEmployeeSubquery = 'SELECT `user_id` FROM `limited_backoffice_employees` WHERE `deactivated_at` IS NULL'; + + return [ + 'enabled' => true, + 'filters' => $filterArray === [] ? null : $users->array_to_filters($filterArray), + 'additional_where' => '(`customer_number` = 0 OR `id` IN (' . $activeLimitedEmployeeSubquery . '))', + ]; + } } diff --git a/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php b/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php index b9c4d6d0..8cc78d63 100644 --- a/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php +++ b/services/nginx/app/tests/Api/LimitedBackofficeApiTest.php @@ -20,6 +20,34 @@ function limited_backoffice_manager_session(array $departmentIds, array $extraPe return api_fixtures()->createUserSession(array_values(array_unique(array_merge($permissions, $extraPermissions)))); } +function limited_backoffice_all_role_permissions(): array +{ + return [ + 'user', + 'permissions_list_own', + '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', + ]; +} + function limited_backoffice_price_insert(int $departmentId, int $productId, int $price): void { $statement = api_test_runtime()->db()->prepare( @@ -495,7 +523,7 @@ it('creates updates lists and deactivates scoped employees without exposing raw expect((int)$usersDeletedAtColumn->num_rows)->toBe(0); $department = api_fixtures()->createDepartment(['name' => 'Limited Employee Department']); - $session = limited_backoffice_manager_session([(int)$department['id']]); + $session = limited_backoffice_manager_session([(int)$department['id']], limited_backoffice_all_role_permissions()); $roles = api_client()->get('/limited-backoffice/roles', $session['headers']); $roles @@ -571,6 +599,7 @@ it('creates updates lists and deactivates scoped employees without exposing raw $employeeId = (int)($created->data()['id'] ?? 0); expect($employeeId)->toBeGreaterThan(0); limited_backoffice_cleanup_created_employee($employeeId); + expect($created->data()['user_id'] ?? null)->toBe($employeeId); expect($created->data()['email'] ?? null)->toBe('limited-cashier@example.test'); expect($created->data()['phone_country_code'] ?? null)->toBe(45); expect($created->data()['phone'] ?? null)->toBe(12345678); @@ -587,9 +616,14 @@ it('creates updates lists and deactivates scoped employees without exposing raw $permissions = array_column($permissionRows, 'permission'); expect($permissions) ->toContain('department_access_' . (int)$department['id']) + ->toContain('employee_public_data') ->toContain('add_order') ->not->toContain('superuser'); + $publicEmployees = api_client()->get('/public/employees'); + $publicEmployeeIds = array_map('intval', array_column($publicEmployees->data(), 'id')); + expect($publicEmployeeIds)->toContain($employeeId); + $updated = api_client()->put('/limited-backoffice/employees/' . $employeeId, [ 'display_name' => 'Limited Lead', 'email' => 'limited-lead@example.test', @@ -620,6 +654,7 @@ it('creates updates lists and deactivates scoped employees without exposing raw } } expect($listedEmployee)->not->toBeNull(); + expect($listedEmployee['user_id'] ?? null)->toBe($employeeId); expect($listedEmployee['email'] ?? null)->toBe('limited-lead@example.test'); expect($listedEmployee['phone_country_code'] ?? null)->toBe(358); expect($listedEmployee['phone'] ?? null)->toBe(87654321); @@ -637,6 +672,9 @@ it('creates updates lists and deactivates scoped employees without exposing raw expect(array_key_exists('password', $userRow ?? []))->toBeTrue(); expect($userRow['password'])->toBeNull(); expect((int)($userRow['group_id'] ?? -1))->toBe(0); + $publicEmployeesAfterDeactivation = api_client()->get('/public/employees'); + $publicEmployeeIdsAfterDeactivation = array_map('intval', array_column($publicEmployeesAfterDeactivation->data(), 'id')); + expect($publicEmployeeIdsAfterDeactivation)->not->toContain($employeeId); $employeeRow = api_test_runtime()->queryOne( 'SELECT `deactivated_at` FROM `limited_backoffice_employees` WHERE `user_id` = ' . $employeeId . ' LIMIT 1' ); @@ -644,6 +682,155 @@ it('creates updates lists and deactivates scoped employees without exposing raw }); }); +it('caps limited employee permissions to the manager permissions and selected departments', function (): void { + api_test_covers('POST /limited-backoffice/employees', 'auth'); + api_test_covers('GET /limited-backoffice/roles', 'auth'); + + $department = api_fixtures()->createDepartment(['name' => 'Limited Permission Cap']); + $session = limited_backoffice_manager_session([(int)$department['id']], [ + 'list_orders', + ]); + + $roles = api_client()->get('/limited-backoffice/roles', $session['headers']); + $rolesByKey = array_column($roles->data(), null, 'key'); + $operationsLeadGroups = array_column($rolesByKey['operations_lead']['permission_groups'] ?? [], 'capabilities', 'key'); + expect($operationsLeadGroups['account'] ?? null)->toBe(['sign_in']); + expect($operationsLeadGroups['orders'] ?? null)->toBe(['view_orders']); + expect($roles->body)->not->toContain('create_orders'); + expect($roles->body)->not->toContain('view_order_statistics'); + + $created = api_client()->post('/limited-backoffice/employees', [ + 'display_name' => 'Limited Capped Lead', + 'email' => 'limited-capped@example.test', + 'password' => 'Secret123!', + 'role_key' => 'operations_lead', + '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); + + $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('user') + ->toContain('employee_public_data') + ->toContain('list_orders') + ->toContain('department_access_' . (int)$department['id']) + ->not->toContain('add_order') + ->not->toContain('delete_order') + ->not->toContain('statistics_orders_new') + ->not->toContain(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES); +}); + +it('includes limited employees in the regular employee list and protects raw user edits', function (): void { + api_test_covers('GET /users', 'limited backoffice employee list'); + api_test_covers('PUT /users', 'limited backoffice guard'); + + $department = api_fixtures()->createDepartment(['name' => 'Limited Regular List']); + $managerSession = limited_backoffice_manager_session([(int)$department['id']], [ + 'permissions_list_own', + ]); + + $regularEmployee = api_fixtures()->createUser([ + 'customer_number' => 0, + 'display_name' => 'Regular Backoffice Employee', + ], ['employee_public_data']); + + $created = api_client()->post('/limited-backoffice/employees', [ + 'display_name' => 'Limited Listed Employee', + 'email' => 'limited-listed@example.test', + 'password' => 'Secret123!', + 'role_key' => 'viewer', + 'department_ids' => [(int)$department['id']], + ], $managerSession['headers']); + $created + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $employeeId = (int)($created->data()['id'] ?? 0); + expect($employeeId)->toBeGreaterThan(0); + limited_backoffice_cleanup_created_employee($employeeId); + + $adminSession = api_fixtures()->createUserSession([ + 'list_users', + 'edit_user', + ]); + + $withoutLimited = api_client()->get('/users?page=1&limit=50&filters=customer_number:0', $adminSession['headers']); + $withoutLimitedIds = array_map('intval', array_column($withoutLimited->data(), 'id')); + expect($withoutLimitedIds)->toContain((int)$regularEmployee['id']); + expect($withoutLimitedIds)->not->toContain($employeeId); + + $withLimited = api_client()->get( + '/users?page=1&limit=50&filters=customer_number:0&include_limited_backoffice_employees=true', + $adminSession['headers'] + ); + $usersById = array_column($withLimited->data(), null, 'id'); + expect(array_keys($usersById))->toContain((int)$regularEmployee['id']); + expect(array_keys($usersById))->toContain($employeeId); + expect($usersById[$employeeId]['limited_backoffice_managed'] ?? null)->toBeTrue(); + expect($usersById[(int)$regularEmployee['id']]['limited_backoffice_managed'] ?? null)->toBeFalse(); + + $userRow = api_test_runtime()->queryOne( + 'SELECT `customer_number`, `group_id` FROM `users` WHERE `id` = ' . $employeeId . ' LIMIT 1' + ); + $customerNumber = (int)($userRow['customer_number'] ?? 0); + $groupId = (int)($userRow['group_id'] ?? 0); + + api_client()->put('/users', [ + 'id' => $employeeId, + 'customer_number' => $customerNumber + 1, + 'role' => $groupId, + 'display_name' => 'Blocked Customer Change', + ], $adminSession['headers']) + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Limited backoffice managed users cannot change customer number.'); + + api_client()->put('/users', [ + 'id' => $employeeId, + 'customer_number' => $customerNumber, + 'role' => 0, + 'display_name' => 'Blocked Role Change', + ], $adminSession['headers']) + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Limited backoffice managed users cannot change role.'); + + api_client()->put('/users', [ + 'id' => $employeeId, + 'customer_number' => $customerNumber, + 'role' => $groupId, + 'display_name' => 'Edited Limited Listed Employee', + ], $adminSession['headers']) + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $updatedUserRow = api_test_runtime()->queryOne( + 'SELECT `customer_number`, `group_id`, `display_name` FROM `users` WHERE `id` = ' . $employeeId . ' LIMIT 1' + ); + expect((int)($updatedUserRow['customer_number'] ?? 0))->toBe($customerNumber); + expect((int)($updatedUserRow['group_id'] ?? 0))->toBe($groupId); + expect($updatedUserRow['display_name'] ?? null)->toBe('Edited Limited Listed Employee'); +}); + it('accepts employees without optional phone details', function (): void { api_test_covers('POST /limited-backoffice/employees', 'happy');