createUserSession(array_values(array_unique(array_merge($permissions, $extraPermissions)))); } function limited_backoffice_all_role_permissions(): array { return [ 'user', 'permissions_list_own', 'list_departments', 'list_orders', 'fetch_order', 'add_order', 'edit_order', 'delete_order', 'mark_order_as_completed', 'list_order_items', 'add_order_items', 'edit_order_items', 'delete_order_items', 'list_order_attachments', 'add_order_attachments', 'download_order_attachments', 'list_products', 'list_categories', 'list_department_categories', 'list_department_order_recommended', 'vehicle_product_suggestions', 'search_customers', 'get_user_from_customer_number', 'list_customer_notes', 'add_customer_note', 'list_customer_attributes', 'search_vehicles', 'view_vehicle_status', 'list_unknown_customer_vehicles', 'list_vehicle_customer_suggestions', 'department_license_plate_lookup', 'department_vehicle_order_last_five', 'list_number_plate_scans', 'list_department_number_plate_scanners', 'charge_order', 'get_payment_intent', 'confirm_payment_intent', 'modules_stripe_department_terminal_readers_list', 'modules_stripe_invoice_send', 'list_bookings', 'list_own_bookings', 'edit_bookings', 'add_booking', 'add_bookings', 'complete_bookings', 'resend_booking_confirmations', 'department_timebookings_entries_get', 'department_timebookings_entries_post', 'department_timebookings_entries_put', 'list_department_daily_reports', 'list_notifications', 'list_own_notifications', 'statistics_orders_new', 'statistics_bookings_new', 'limited_backoffice_customer_pricing_view', 'limited_backoffice_customer_pricing_manage', ]; } function limited_backoffice_role_preset_permissions(string $roleKey): array { static $rolePresets = null; if ($rolePresets === null) { $reflection = new ReflectionClass(limited_backoffice_service::class); $constant = $reflection->getReflectionConstant('ROLE_PRESETS'); if (!$constant instanceof ReflectionClassConstant) { throw new RuntimeException('Limited backoffice role presets are unavailable.'); } $rolePresets = $constant->getValue(); } return $rolePresets[$roleKey]['permissions'] ?? []; } 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_price_rows(int $departmentId, int $productId): array { return api_test_runtime()->db()->query( 'SELECT `id`, `price` FROM `product_department_prices` WHERE `department_id` = ' . $departmentId . ' AND `product_id` = ' . $productId . ' ORDER BY `id` ASC' )->fetch_all(MYSQLI_ASSOC); } 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'); $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); expect($updated->data()['categories'][0]['products'][0]['price'] ?? null)->toBe(2222); }); it('returns saved prices and collapses legacy duplicate department price rows', function (): void { api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'legacy duplicates'); $department = api_fixtures()->createDepartment(['name' => 'Limited Legacy Duplicate Prices']); $category = api_fixtures()->createCategory(['name' => 'Limited Legacy Duplicate Category']); $product = api_fixtures()->createProduct([ 'name' => 'Legacy Duplicate Price', 'category' => $category['id'], ]); api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']); $db = api_test_runtime()->db(); $index = $db->query("SHOW INDEX FROM `product_department_prices` WHERE `Key_name` = 'uniq_product_department_prices_lookup'"); if ($index === false) { throw new RuntimeException('Unable to inspect product_department_prices lookup index.'); } $hadIndex = (int)$index->num_rows > 0; if ($hadIndex) { $db->query('ALTER TABLE `product_department_prices` DROP INDEX `uniq_product_department_prices_lookup`'); } try { limited_backoffice_price_insert((int)$department['id'], (int)$product['id'], 111); limited_backoffice_price_insert((int)$department['id'], (int)$product['id'], 222); expect(limited_backoffice_price_rows((int)$department['id'], (int)$product['id']))->toHaveCount(2); $session = limited_backoffice_manager_session([(int)$department['id']]); $updated = api_client()->put('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', [ 'prices' => [ ['product_id' => (int)$product['id'], 'price' => 333], ], ], $session['headers']); $updated ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); $rows = limited_backoffice_price_rows((int)$department['id'], (int)$product['id']); expect($rows)->toHaveCount(1); expect((int)$rows[0]['price'])->toBe(333); expect($updated->data()['categories'][0]['products'][0]['price'] ?? null)->toBe(333); } finally { $db->query( 'DELETE FROM `product_department_prices` WHERE `department_id` = ' . (int)$department['id'] . ' AND `product_id` = ' . (int)$product['id'] ); if ($hadIndex) { $db->query( 'ALTER TABLE `product_department_prices` ADD UNIQUE KEY `uniq_product_department_prices_lookup` (`department_id`, `product_id`)' ); } } }); 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('deduplicates products from duplicate department category links', function (): void { api_test_covers('GET /limited-backoffice/departments/{departmentId}/prices', 'dedupe'); $department = api_fixtures()->createDepartment(['name' => 'Limited Duplicate Products']); $category = api_fixtures()->createCategory(['name' => 'Limited Duplicate Category']); $firstProduct = api_fixtures()->createProduct([ 'name' => 'Duplicate Price A', 'category' => $category['id'], ]); $secondProduct = api_fixtures()->createProduct([ 'name' => 'Duplicate Price B', 'category' => $category['id'], ]); api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']); api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']); limited_backoffice_price_insert((int)$department['id'], (int)$firstProduct['id'], 111); limited_backoffice_price_insert((int)$department['id'], (int)$secondProduct['id'], 222); $session = limited_backoffice_manager_session([(int)$department['id']]); $response = api_client()->get('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', $session['headers']); $response ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); $productIds = []; foreach ($response->data()['categories'] as $departmentCategory) { foreach ($departmentCategory['products'] as $departmentProduct) { $productIds[] = (int)$departmentProduct['id']; } } expect($productIds)->toBe([(int)$firstProduct['id'], (int)$secondProduct['id']]); }); it('deduplicates missing product setup gaps from duplicate department category links', function (): void { api_test_covers('GET /limited-backoffice/departments/{departmentId}/prices', 'dedupe failure'); $department = api_fixtures()->createDepartment(['name' => 'Limited Duplicate Setup Gap']); $category = api_fixtures()->createCategory(['name' => 'Limited Duplicate Setup Category']); $product = api_fixtures()->createProduct([ 'name' => 'Duplicate Missing Product', 'category' => $category['id'], 'price' => 88888, ]); api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']); 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(array_column($response->data()['missing_products'], 'id'))->toBe([(int)$product['id']]); expect($response->body)->not->toContain('88888'); }); 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('defaults missing custom-only department prices to sentinel without exposing fallback prices', 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'); $department = api_fixtures()->createDepartment([ 'name' => 'Limited Custom Pricing Only', 'custom_pricing_only' => 1, ]); $otherDepartment = api_fixtures()->createDepartment(['name' => 'Limited Other Pricing']); $category = api_fixtures()->createCategory(['name' => 'Limited Custom Pricing Category']); $product = api_fixtures()->createProduct([ 'name' => 'Custom Missing Product', 'category' => $category['id'], 'price' => 87654, ]); $otherProduct = api_fixtures()->createProduct([ 'name' => 'Custom Missing Other Product', 'category' => $category['id'], 'price' => 76543, ]); api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']); api_fixtures()->linkDepartmentCategory((int)$otherDepartment['id'], (int)$category['id']); limited_backoffice_price_insert((int)$otherDepartment['id'], (int)$product['id'], 4321); limited_backoffice_price_insert((int)$otherDepartment['id'], (int)$otherProduct['id'], 5432); $session = limited_backoffice_manager_session([(int)$department['id']]); $departments = api_client()->get('/limited-backoffice/departments', $session['headers']); $departments ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); expect($departments->data()[0]['custom_pricing_only'] ?? null)->toBeTrue(); $response = api_client()->get('/limited-backoffice/departments/' . (int)$department['id'] . '/prices', $session['headers']); $response ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); expect($response->body)->not->toContain('87654'); expect($response->body)->not->toContain('76543'); expect($response->body)->not->toContain('4321'); expect($response->body)->not->toContain('5432'); expect($response->data()['department']['custom_pricing_only'] ?? null)->toBeTrue(); $products = []; foreach ($response->data()['categories'] as $departmentCategory) { foreach ($departmentCategory['products'] as $departmentProduct) { $products[(int)$departmentProduct['id']] = $departmentProduct; } } expect($products[(int)$product['id']]['price'] ?? null)->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE); expect($products[(int)$otherProduct['id']]['price'] ?? null)->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE); $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(); $updatedProducts = []; foreach ($updated->data()['categories'] as $departmentCategory) { foreach ($departmentCategory['products'] as $departmentProduct) { $updatedProducts[(int)$departmentProduct['id']] = $departmentProduct; } } expect($updated->body)->not->toContain('87654'); expect($updated->body)->not->toContain('76543'); expect($updated->body)->not->toContain('4321'); expect($updated->body)->not->toContain('5432'); expect($updatedProducts[(int)$product['id']]['price'] ?? null)->toBe(2222); expect($updatedProducts[(int)$otherProduct['id']]['price'] ?? null)->toBe(\objects\products_o::CUSTOM_PRICING_MISSING_PRICE); }); 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], ['product_id' => (int)$firstProduct['id'], 'price' => 888], ['product_id' => (int)$secondProduct['id'], 'price' => 777], ], ], $session['headers']) ->assertStatus(400) ->assertEnvelope() ->assertSuccess(false) ->assertMessage('Duplicate product price rows are not allowed.'); 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('keeps higher limited backoffice roles as cashier permission supersets', function (): void { $cashierPermissions = limited_backoffice_role_preset_permissions('cashier'); expect($cashierPermissions)->not->toBeEmpty(); foreach (['operations_lead', 'department_admin'] as $roleKey) { $rolePermissions = limited_backoffice_role_preset_permissions($roleKey); $missingPermissions = array_values(array_diff($cashierPermissions, $rolePermissions)); if ($missingPermissions !== []) { throw new RuntimeException( $roleKey . ' must include all cashier permissions; missing: ' . implode(', ', $missingPermissions) ); } } }); it('lets superusers migrate existing employee accounts to limited backoffice employees', function (): void { api_test_covers('POST /limited-backoffice/employees/{employeeId}/migrate', 'happy'); $department = api_fixtures()->createDepartment(['name' => 'Limited Migration Department']); $legacyGroup = api_fixtures()->createGroup(['name' => 'Legacy Employee Group'], [ 'employee_public_data', ]); $legacyEmployee = api_fixtures()->createUser([ 'customer_number' => 0, 'display_name' => 'Legacy Counter Employee', 'email' => 'legacy-counter@example.test', 'group_id' => $legacyGroup['id'], ]); $superuserSession = api_fixtures()->createUserSession([], ['group_id' => 1]); $departments = api_client()->get('/limited-backoffice/departments', $superuserSession['headers']); $departments ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); expect(array_map('intval', array_column($departments->data(), 'id')))->toContain((int)$department['id']); $migrated = api_client()->post('/limited-backoffice/employees/' . (int)$legacyEmployee['id'] . '/migrate', [ 'role_key' => 'cashier', 'department_ids' => [(int)$department['id']], ], $superuserSession['headers']); $migrated ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); limited_backoffice_cleanup_created_employee((int)$legacyEmployee['id']); expect($migrated->data()['id'] ?? null)->toBe((int)$legacyEmployee['id']); expect($migrated->data()['customer_number'] ?? null)->toBe(0); expect($migrated->data()['display_name'] ?? null)->toBe('Legacy Counter Employee'); expect($migrated->data()['email'] ?? null)->toBe('legacy-counter@example.test'); expect($migrated->data()['role']['key'] ?? null)->toBe('cashier'); expect(array_map('intval', array_column($migrated->data()['departments'] ?? [], 'id')))->toBe([(int)$department['id']]); $metadata = api_test_runtime()->queryOne( 'SELECT `managed_group_id`, `role_key`, `department_ids` FROM `limited_backoffice_employees` WHERE `user_id` = ' . (int)$legacyEmployee['id'] . ' LIMIT 1' ); expect($metadata)->not->toBeNull(); $managedGroupId = (int)($metadata['managed_group_id'] ?? 0); expect($managedGroupId)->toBeGreaterThan(0); expect($managedGroupId)->not->toBe((int)$legacyGroup['id']); expect($metadata['role_key'] ?? null)->toBe('cashier'); expect(json_decode((string)($metadata['department_ids'] ?? '[]'), true))->toBe([(int)$department['id']]); $userRow = api_test_runtime()->queryOne( 'SELECT `customer_number`, `group_id`, `display_name`, `email` FROM `users` WHERE `id` = ' . (int)$legacyEmployee['id'] . ' LIMIT 1' ); expect((int)($userRow['customer_number'] ?? -1))->toBe(0); expect((int)($userRow['group_id'] ?? 0))->toBe($managedGroupId); expect($userRow['display_name'] ?? null)->toBe('Legacy Counter Employee'); expect($userRow['email'] ?? null)->toBe('legacy-counter@example.test'); $permissionRows = api_test_runtime()->db()->query( 'SELECT `permission` FROM `groups_permissions` WHERE `group_id` = ' . $managedGroupId )->fetch_all(MYSQLI_ASSOC); $permissions = array_column($permissionRows, 'permission'); expect($permissions) ->toContain('admin') ->toContain('user') ->toContain('permissions_list_own') ->toContain('employee_public_data') ->toContain('department_access_' . (int)$department['id']) ->toContain('fetch_order') ->toContain('search_customers') ->toContain('add_order_attachments') ->toContain('list_number_plate_scans') ->toContain('add_bookings') ->not->toContain('superuser'); $listed = api_client()->get('/limited-backoffice/employees', $superuserSession['headers']); $listedIds = array_map('intval', array_column($listed->data(), 'id')); expect($listedIds)->toContain((int)$legacyEmployee['id']); }); it('rejects unsafe limited backoffice employee migrations', function (): void { api_test_covers('POST /limited-backoffice/employees/{employeeId}/migrate', 'auth'); api_test_covers('POST /limited-backoffice/employees/{employeeId}/migrate', 'validation'); $department = api_fixtures()->createDepartment(['name' => 'Limited Migration Rejections']); $limitedManagerSession = limited_backoffice_manager_session([(int)$department['id']]); $superuserSession = api_fixtures()->createUserSession([], ['group_id' => 1]); $legacyEmployee = api_fixtures()->createUser([ 'customer_number' => 0, 'display_name' => 'Unsafe Migration Employee', 'email' => 'unsafe-migration-employee@example.test', ]); api_client()->post('/limited-backoffice/employees/' . (int)$legacyEmployee['id'] . '/migrate', [ 'role_key' => 'viewer', 'department_ids' => [(int)$department['id']], ], $limitedManagerSession['headers']) ->assertStatus(403) ->assertEnvelope() ->assertSuccess(false) ->assertMissingPermissions(['superuser']); api_client()->post('/limited-backoffice/employees/' . (int)$superuserSession['user']['id'] . '/migrate', [ 'role_key' => 'viewer', 'department_ids' => [(int)$department['id']], ], $superuserSession['headers']) ->assertStatus(403) ->assertEnvelope() ->assertSuccess(false) ->assertMessage('Managers cannot edit themselves.'); $customerUser = api_fixtures()->createUser(['customer_number' => 99112233]); api_client()->post('/limited-backoffice/employees/' . (int)$customerUser['id'] . '/migrate', [ 'role_key' => 'viewer', 'department_ids' => [(int)$department['id']], ], $superuserSession['headers']) ->assertStatus(400) ->assertEnvelope() ->assertSuccess(false) ->assertMessage('Only employee accounts with customer number 0 can be migrated.'); $created = api_client()->post('/limited-backoffice/employees', [ 'display_name' => 'Already Managed Migration', 'email' => 'already-managed-migration@example.test', 'password' => 'Secret123!', 'role_key' => 'viewer', 'department_ids' => [(int)$department['id']], ], $limitedManagerSession['headers']); $alreadyManagedId = (int)($created->data()['id'] ?? 0); expect($alreadyManagedId)->toBeGreaterThan(0); limited_backoffice_cleanup_created_employee($alreadyManagedId); api_client()->post('/limited-backoffice/employees/' . $alreadyManagedId . '/migrate', [ 'role_key' => 'cashier', 'department_ids' => [(int)$department['id']], ], $superuserSession['headers']) ->assertStatus(409) ->assertEnvelope() ->assertSuccess(false) ->assertMessage('User is already a limited backoffice employee.'); $targetSuperuser = api_fixtures()->createUser([ 'customer_number' => 0, 'email' => 'target-superuser-migration@example.test', 'group_id' => 1, ]); api_client()->post('/limited-backoffice/employees/' . (int)$targetSuperuser['id'] . '/migrate', [ 'role_key' => 'viewer', 'department_ids' => [(int)$department['id']], ], $superuserSession['headers']) ->assertStatus(403) ->assertEnvelope() ->assertSuccess(false) ->assertMessage('Cannot migrate superuser accounts.'); }); 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']], limited_backoffice_all_role_permissions()); $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']['label'] ?? null)->toBe('Deactivated'); expect($rolesByKey['viewer']['permission_groups'] ?? null)->toBe([ [ 'key' => 'account', 'capabilities' => ['sign_in', 'view_own_permissions'], ], ]); $cashierGroups = array_column($rolesByKey['cashier']['permission_groups'] ?? [], 'capabilities', 'key'); expect($cashierGroups['orders'] ?? null)->toBe([ 'view_orders', 'create_orders', 'edit_orders', 'complete_orders', 'view_order_items', 'create_order_items', 'update_order_lines', 'remove_order_lines', 'charge_orders', ]); expect($cashierGroups['products'] ?? null)->toBe([ 'view_product_catalog', 'view_product_recommendations', ]); expect($cashierGroups['customers'] ?? null)->toBe([ 'search_customers', 'view_customer_details', 'view_customer_notes', 'add_customer_notes', 'view_customer_flags', ]); expect($cashierGroups['vehicles'] ?? null)->toBe([ 'search_vehicles', 'view_vehicle_matches', 'view_vehicle_history', ]); expect($cashierGroups['attachments'] ?? null)->toBe([ 'view_order_attachments', 'add_order_attachments', 'download_order_attachments', ]); expect($cashierGroups['scanner'] ?? null)->toBe([ 'view_plate_scans', ]); expect($cashierGroups['bookings'] ?? null)->toBe([ 'view_department_bookings', 'view_own_bookings', 'update_bookings', 'create_bookings', 'mark_bookings_complete', 'send_booking_confirmations', ]); $departmentAdminGroups = array_column($rolesByKey['department_admin']['permission_groups'] ?? [], 'capabilities', 'key'); expect($departmentAdminGroups['limited_backoffice'] ?? null)->toBe([ 'open_limited_backoffice', 'manage_department_prices', 'view_customer_pricing', 'manage_customer_pricing', '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', 'list_departments', 'fetch_order', 'add_order', 'edit_order', 'delete_order', 'mark_order_as_completed', 'list_order_items', 'add_order_items', 'edit_order_items', 'delete_order_items', 'list_order_attachments', 'list_products', 'list_categories', 'list_department_categories', 'list_department_order_recommended', 'vehicle_product_suggestions', 'get_user_from_customer_number', 'list_customer_notes', 'add_customer_note', 'list_customer_attributes', 'view_vehicle_status', 'list_unknown_customer_vehicles', 'list_vehicle_customer_suggestions', 'department_license_plate_lookup', 'department_vehicle_order_last_five', 'list_number_plate_scans', 'list_department_number_plate_scanners', 'charge_order', 'get_payment_intent', 'confirm_payment_intent', 'modules_stripe_department_terminal_readers_list', 'modules_stripe_invoice_send', 'list_bookings', 'list_own_bookings', 'edit_bookings', 'add_booking', 'add_bookings', 'complete_bookings', 'resend_booking_confirmations', 'department_timebookings_entries_get', 'department_timebookings_entries_post', 'department_timebookings_entries_put', 'list_department_daily_reports', 'list_notifications', 'list_own_notifications', 'statistics_orders_new', 'statistics_bookings_new', 'limited_backoffice_access', 'limited_backoffice_prices_manage', 'limited_backoffice_customer_pricing_view', 'limited_backoffice_customer_pricing_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', 'phone_country_code' => 45, 'phone' => 12345678, '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->data()['user_id'] ?? null)->toBe($employeeId); expect($created->data()['customer_number'] ?? null)->toBe(0); 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); 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('admin') ->toContain('user') ->toContain('permissions_list_own') ->toContain('department_access_' . (int)$department['id']) ->toContain('employee_public_data') ->toContain('add_order') ->toContain('fetch_order') ->toContain('list_products') ->toContain('search_customers') ->toContain('get_user_from_customer_number') ->toContain('search_vehicles') ->toContain('list_order_attachments') ->toContain('add_order_attachments') ->toContain('download_order_attachments') ->toContain('list_number_plate_scans') ->toContain('modules_stripe_department_terminal_readers_list') ->toContain('list_bookings') ->toContain('edit_bookings') ->toContain('add_bookings') ->not->toContain('superuser'); $employeeToken = api_fixtures()->createAuthToken($employeeId); $employeeSession = api_client()->get('/auth/session', api_fixtures()->bearerHeaders($employeeToken)); $employeeSession ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); expect($employeeSession->data()['permissions'] ?? []) ->toContain('admin') ->toContain('permissions_list_own') ->toContain('department_access_' . (int)$department['id']) ->toContain('add_order') ->toContain('list_products') ->toContain('search_customers') ->toContain('search_vehicles') ->toContain('add_order_attachments') ->toContain('list_bookings') ->toContain('edit_bookings') ->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', 'phone_country_code' => 358, 'phone' => 87654321, '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()['email'] ?? null)->toBe('limited-lead@example.test'); expect($updated->data()['phone_country_code'] ?? null)->toBe(358); expect($updated->data()['phone'] ?? null)->toBe(87654321); 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); $listedEmployee = null; foreach ($list->data() as $employee) { if ((int)($employee['id'] ?? 0) === $employeeId) { $listedEmployee = $employee; break; } } 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); 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 `customer_number`, `password`, `group_id` FROM `users` WHERE `id` = ' . $employeeId); expect($userRow)->not->toBeNull(); expect((int)($userRow['customer_number'] ?? -1))->toBe(0); 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' ); expect($employeeRow['deactivated_at'] ?? null)->not->toBeNull(); }); }); it('caps manager-gated limited employee permissions while keeping baseline role permissions', 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', 'view_own_permissions']); expect($operationsLeadGroups['departments'] ?? null)->toBe(['view_departments', 'view_daily_reports']); expect($operationsLeadGroups['orders'] ?? null)->toBe(['view_orders']); expect($operationsLeadGroups['notifications'] ?? null)->toBe(['view_notifications']); expect($operationsLeadGroups['reports'] ?? null)->toBe(['view_order_statistics', 'view_booking_statistics']); expect($roles->body)->not->toContain('create_orders'); $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('admin') ->toContain('user') ->toContain('permissions_list_own') ->toContain('employee_public_data') ->toContain('list_departments') ->toContain('list_orders') ->toContain('list_department_daily_reports') ->toContain('list_notifications') ->toContain('list_own_notifications') ->toContain('statistics_orders_new') ->toContain('statistics_bookings_new') ->toContain('department_access_' . (int)$department['id']) ->not->toContain('add_order') ->not->toContain('fetch_order') ->not->toContain('list_products') ->not->toContain('search_customers') ->not->toContain('add_bookings') ->not->toContain('delete_order') ->not->toContain(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES); }); it('generates reusable QR login links for active scoped employees', function (): void { api_test_covers('POST /limited-backoffice/employees/{employeeId}/login-link', 'happy'); $department = api_fixtures()->createDepartment(['name' => 'Limited Login Link Department']); $session = limited_backoffice_manager_session([(int)$department['id']]); $created = api_client()->post('/limited-backoffice/employees', [ 'display_name' => 'Limited QR Employee', 'email' => 'limited-qr@example.test', 'password' => 'Secret123!', 'role_key' => 'viewer', '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); $response = api_client()->post( '/limited-backoffice/employees/' . $employeeId . '/login-link', [], $session['headers'] ); $response ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); $loginPath = (string)($response->data()['login_path'] ?? ''); expect($response->data()['employee_id'] ?? null)->toBe($employeeId); expect($loginPath)->toMatch('/^\/login\/qr\?token=[a-f0-9]{64}$/'); parse_str((string)parse_url($loginPath, PHP_URL_QUERY), $query); $token = (string)($query['token'] ?? ''); expect($token)->toMatch('/^[a-f0-9]{64}$/'); $tokenRow = api_test_runtime()->queryOne( "SELECT `user_id`, `type` FROM `tokens` WHERE `token` = '" . api_test_runtime()->db()->real_escape_string($token) . "' LIMIT 1" ); expect($tokenRow)->not->toBeNull(); expect((int)($tokenRow['user_id'] ?? 0))->toBe($employeeId); expect($tokenRow['type'] ?? null)->toBe('AUTH_TOKEN'); $list = api_client()->get('/limited-backoffice/employees', $session['headers']); $list ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); expect($list->body)->not->toContain($token); expect($list->body)->not->toContain('login_path'); }); it('rejects invalid limited backoffice employee QR login link generation', function (): void { api_test_covers('POST /limited-backoffice/employees/{employeeId}/login-link', 'auth'); api_test_covers('POST /limited-backoffice/employees/{employeeId}/login-link', 'validation'); $department = api_fixtures()->createDepartment(['name' => 'Limited Login Link Own']); $otherDepartment = api_fixtures()->createDepartment(['name' => 'Limited Login Link Other']); $session = limited_backoffice_manager_session([(int)$department['id']]); $otherSession = limited_backoffice_manager_session([(int)$otherDepartment['id']]); $created = api_client()->post('/limited-backoffice/employees', [ 'display_name' => 'Limited Link Target', 'email' => 'limited-link-target@example.test', 'password' => 'Secret123!', 'role_key' => 'viewer', 'department_ids' => [(int)$department['id']], ], $session['headers']); $employeeId = (int)($created->data()['id'] ?? 0); expect($employeeId)->toBeGreaterThan(0); limited_backoffice_cleanup_created_employee($employeeId); $withoutManageEmployees = api_fixtures()->createUserSession([ limited_backoffice_service::PERMISSION_ACCESS, 'department_access_' . (int)$department['id'], ]); api_client()->post( '/limited-backoffice/employees/' . $employeeId . '/login-link', [], $withoutManageEmployees['headers'] ) ->assertStatus(403) ->assertEnvelope() ->assertSuccess(false) ->assertMissingPermissions([limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES]); api_client()->post( '/limited-backoffice/employees/' . (int)$session['user']['id'] . '/login-link', [], $session['headers'] ) ->assertStatus(403) ->assertEnvelope() ->assertSuccess(false) ->assertMessage('Managers cannot edit themselves.'); api_client()->post('/limited-backoffice/employees/999999999/login-link', [], $session['headers']) ->assertStatus(404) ->assertEnvelope() ->assertSuccess(false) ->assertMessage('Managed employee not found.'); $otherCreated = api_client()->post('/limited-backoffice/employees', [ 'display_name' => 'Limited Other Department Target', 'email' => 'limited-other-target@example.test', 'password' => 'Secret123!', 'role_key' => 'viewer', 'department_ids' => [(int)$otherDepartment['id']], ], $otherSession['headers']); $otherEmployeeId = (int)($otherCreated->data()['id'] ?? 0); expect($otherEmployeeId)->toBeGreaterThan(0); limited_backoffice_cleanup_created_employee($otherEmployeeId); api_client()->post( '/limited-backoffice/employees/' . $otherEmployeeId . '/login-link', [], $session['headers'] ) ->assertStatus(403) ->assertEnvelope() ->assertSuccess(false) ->assertMissingPermissions(['department_access_' . (int)$otherDepartment['id']]); api_client()->delete('/limited-backoffice/employees/' . $employeeId, null, $session['headers']) ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); api_client()->post('/limited-backoffice/employees/' . $employeeId . '/login-link', [], $session['headers']) ->assertStatus(409) ->assertEnvelope() ->assertSuccess(false) ->assertMessage('Cannot create a login link for an inactive employee.'); $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()->post('/limited-backoffice/employees/' . (int)$superuser['id'] . '/login-link', [], $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()->post('/limited-backoffice/employees/' . (int)$firstSharedUser['id'] . '/login-link', [], $session['headers']) ->assertStatus(403) ->assertEnvelope() ->assertSuccess(false) ->assertMessage('Cannot manage shared groups.'); }); 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'); api_test_covers('GET /superuser/user', 'limited backoffice managed flag'); $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); expect($created->data()['customer_number'] ?? null)->toBe(0); $adminSession = api_fixtures()->createUserSession([ 'get_user', '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(); $superuserDetail = api_client()->get('/superuser/user?user_id=' . $employeeId, $adminSession['headers']); $superuserDetail ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); expect($superuserDetail->data()['limited_backoffice_managed'] ?? null)->toBeTrue(); $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); expect($customerNumber)->toBe(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'); $department = api_fixtures()->createDepartment(['name' => 'Limited Employee No Phone']); $session = limited_backoffice_manager_session([(int)$department['id']]); $created = api_client()->post('/limited-backoffice/employees', [ 'display_name' => 'Limited No Phone', 'email' => 'limited-no-phone@example.test', 'password' => 'Secret123!', 'role_key' => 'viewer', '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->data()['customer_number'] ?? null)->toBe(0); expect(array_key_exists('phone_country_code', $created->data()))->toBeTrue(); expect(array_key_exists('phone', $created->data()))->toBeTrue(); expect($created->data()['phone_country_code'])->toBeNull(); expect($created->data()['phone'])->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', 'email' => 'outside@example.test', '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', 'email' => 'raw@example.test', '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', 'email' => 'unknown-role@example.test', '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.'); }); it('rejects invalid limited backoffice employee contact details', 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 Contact Validation']); $session = limited_backoffice_manager_session([(int)$department['id']]); $basePayload = [ 'display_name' => 'Contact Employee', 'email' => 'contact@example.test', 'password' => 'Secret123!', 'role_key' => 'viewer', 'department_ids' => [(int)$department['id']], ]; api_client()->post('/limited-backoffice/employees', array_diff_key($basePayload, ['email' => true]), $session['headers']) ->assertStatus(400) ->assertEnvelope() ->assertSuccess(false) ->assertMessage('Email is required.'); api_client()->post('/limited-backoffice/employees', [ ...$basePayload, 'email' => 'not-an-email', ], $session['headers']) ->assertStatus(400) ->assertEnvelope() ->assertSuccess(false) ->assertMessage('Email must be a valid email address.'); api_client()->post('/limited-backoffice/employees', [ ...$basePayload, 'phone_country_code' => 45, ], $session['headers']) ->assertStatus(400) ->assertEnvelope() ->assertSuccess(false) ->assertMessage('Phone country code and phone number must be provided together.'); api_client()->post('/limited-backoffice/employees', [ ...$basePayload, 'phone_country_code' => 1, 'phone' => 12345678, ], $session['headers']) ->assertStatus(400) ->assertEnvelope() ->assertSuccess(false) ->assertMessage('Phone country code is not supported.'); api_client()->post('/limited-backoffice/employees', [ ...$basePayload, 'phone_country_code' => 45, 'phone' => '12ab', ], $session['headers']) ->assertStatus(400) ->assertEnvelope() ->assertSuccess(false) ->assertMessage('Phone values must contain digits only.'); $created = api_client()->post('/limited-backoffice/employees', $basePayload, $session['headers']) ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); $employeeId = (int)($created->data()['id'] ?? 0); expect($employeeId)->toBeGreaterThan(0); limited_backoffice_cleanup_created_employee($employeeId); api_client()->put('/limited-backoffice/employees/' . $employeeId, [ 'email' => '', ], $session['headers']) ->assertStatus(400) ->assertEnvelope() ->assertSuccess(false) ->assertMessage('Email is required.'); api_client()->put('/limited-backoffice/employees/' . $employeeId, [ 'phone_country_code' => 45, 'phone' => '123', ], $session['headers']) ->assertStatus(400) ->assertEnvelope() ->assertSuccess(false) ->assertMessage('Phone number must be 4-15 digits.'); }); it('includes list_departments in all active limited backoffice role presets', function (): void { foreach (['cashier', 'booking_coordinator', 'operations_lead', 'department_admin'] as $roleKey) { $permissions = limited_backoffice_role_preset_permissions($roleKey); expect($permissions)->toContain('list_departments'); } }); it('includes list_department_daily_reports (dagsopgørelse) in all active limited backoffice role presets', function (): void { foreach (['cashier', 'booking_coordinator', 'operations_lead', 'department_admin'] as $roleKey) { $permissions = limited_backoffice_role_preset_permissions($roleKey); expect($permissions)->toContain('list_department_daily_reports'); } }); it('includes list_notifications and list_own_notifications (Notifikationer) in all active limited backoffice role presets', function (): void { foreach (['cashier', 'booking_coordinator', 'operations_lead', 'department_admin'] as $roleKey) { $permissions = limited_backoffice_role_preset_permissions($roleKey); expect($permissions) ->toContain('list_notifications') ->toContain('list_own_notifications'); } }); it('includes statistics_orders_new and statistics_bookings_new (Overblik) in all active limited backoffice role presets', function (): void { foreach (['cashier', 'booking_coordinator', 'operations_lead', 'department_admin'] as $roleKey) { $permissions = limited_backoffice_role_preset_permissions($roleKey); expect($permissions) ->toContain('statistics_orders_new') ->toContain('statistics_bookings_new'); } }); it('includes customer pricing permissions in the department admin limited backoffice role preset', function (): void { $permissions = limited_backoffice_role_preset_permissions('department_admin'); expect($permissions) ->toContain(limited_backoffice_service::PERMISSION_VIEW_CUSTOMER_PRICING) ->toContain(limited_backoffice_service::PERMISSION_MANAGE_CUSTOMER_PRICING); }); it('assigns list_departments to managed employees and enforces list_departments permission on GET /departments', function (): void { api_test_covers('GET /departments', 'limited backoffice employee'); $department = api_fixtures()->createDepartment(['name' => 'LB Dept List Test']); $manager = limited_backoffice_manager_session([(int)$department['id']]); $result = api_client()->post('/limited-backoffice/employees', [ 'role_key' => 'cashier', 'department_ids' => [(int)$department['id']], 'display_name' => 'Dept List Test Employee', 'email' => 'dept-list-test@example.test', 'password' => 'Secret123!', ], $manager['headers']); $result->assertStatus(200)->assertEnvelope()->assertSuccess(); $employeeId = (int)($result->data()['id'] ?? 0); expect($employeeId)->toBeGreaterThan(0); limited_backoffice_cleanup_created_employee($employeeId); $stmt = api_test_runtime()->db()->prepare( 'SELECT `managed_group_id` FROM `limited_backoffice_employees` WHERE `user_id` = ? LIMIT 1' ); $stmt->bind_param('i', $employeeId); $stmt->execute(); $employeeRow = $stmt->get_result()->fetch_assoc(); $stmt->close(); $managedGroupId = (int)($employeeRow['managed_group_id'] ?? 0); expect($managedGroupId)->toBeGreaterThan(0); $permStmt = api_test_runtime()->db()->prepare( 'SELECT 1 FROM `groups_permissions` WHERE `group_id` = ? AND `permission` = ? LIMIT 1' ); $listDepts = 'list_departments'; $permStmt->bind_param('is', $managedGroupId, $listDepts); $permStmt->execute(); $permRow = $permStmt->get_result()->fetch_assoc(); $permStmt->close(); expect($permRow)->not->toBeNull('Managed employee must have list_departments permission'); }); it('assigns list_department_daily_reports to managed employees', function (): void { $department = api_fixtures()->createDepartment(['name' => 'LB Daily Report Perm Test']); $manager = limited_backoffice_manager_session([(int)$department['id']]); $result = api_client()->post('/limited-backoffice/employees', [ 'role_key' => 'cashier', 'department_ids' => [(int)$department['id']], 'display_name' => 'Daily Report Perm Employee', 'email' => 'daily-report-perm@example.test', 'password' => 'Secret123!', ], $manager['headers']); $result->assertStatus(200)->assertEnvelope()->assertSuccess(); $employeeId = (int)($result->data()['id'] ?? 0); expect($employeeId)->toBeGreaterThan(0); limited_backoffice_cleanup_created_employee($employeeId); $stmt = api_test_runtime()->db()->prepare( 'SELECT `managed_group_id` FROM `limited_backoffice_employees` WHERE `user_id` = ? LIMIT 1' ); $stmt->bind_param('i', $employeeId); $stmt->execute(); $employeeRow = $stmt->get_result()->fetch_assoc(); $stmt->close(); $managedGroupId = (int)($employeeRow['managed_group_id'] ?? 0); expect($managedGroupId)->toBeGreaterThan(0); $permStmt = api_test_runtime()->db()->prepare( 'SELECT 1 FROM `groups_permissions` WHERE `group_id` = ? AND `permission` = ? LIMIT 1' ); $dailyReports = 'list_department_daily_reports'; $permStmt->bind_param('is', $managedGroupId, $dailyReports); $permStmt->execute(); $permRow = $permStmt->get_result()->fetch_assoc(); $permStmt->close(); expect($permRow)->not->toBeNull('Managed employee must have list_department_daily_reports permission'); }); it('assigns list_notifications and list_own_notifications to managed employees', function (): void { $department = api_fixtures()->createDepartment(['name' => 'LB Notifications Perm Test']); $manager = limited_backoffice_manager_session([(int)$department['id']]); $result = api_client()->post('/limited-backoffice/employees', [ 'role_key' => 'cashier', 'department_ids' => [(int)$department['id']], 'display_name' => 'Notifications Perm Employee', 'email' => 'notifications-perm@example.test', 'password' => 'Secret123!', ], $manager['headers']); $result->assertStatus(200)->assertEnvelope()->assertSuccess(); $employeeId = (int)($result->data()['id'] ?? 0); expect($employeeId)->toBeGreaterThan(0); limited_backoffice_cleanup_created_employee($employeeId); $stmt = api_test_runtime()->db()->prepare( 'SELECT `managed_group_id` FROM `limited_backoffice_employees` WHERE `user_id` = ? LIMIT 1' ); $stmt->bind_param('i', $employeeId); $stmt->execute(); $employeeRow = $stmt->get_result()->fetch_assoc(); $stmt->close(); $managedGroupId = (int)($employeeRow['managed_group_id'] ?? 0); expect($managedGroupId)->toBeGreaterThan(0); foreach (['list_notifications', 'list_own_notifications'] as $perm) { $stmt = api_test_runtime()->db()->prepare( 'SELECT 1 FROM `groups_permissions` WHERE `group_id` = ? AND `permission` = ? LIMIT 1' ); $stmt->bind_param('is', $managedGroupId, $perm); $stmt->execute(); $permRow = $stmt->get_result()->fetch_assoc(); $stmt->close(); expect($permRow)->not->toBeNull("Managed employee must have $perm permission"); } }); it('assigns statistics permissions (Overblik) to all active limited backoffice role presets including cashier and booking_coordinator', function (): void { $department = api_fixtures()->createDepartment(['name' => 'LB Stats Perm Test']); $manager = limited_backoffice_manager_session([(int)$department['id']]); foreach (['cashier', 'booking_coordinator'] as $roleKey) { $result = api_client()->post('/limited-backoffice/employees', [ 'role_key' => $roleKey, 'department_ids' => [(int)$department['id']], 'display_name' => 'Stats Perm ' . $roleKey, 'email' => 'stats-perm-' . $roleKey . '@example.test', 'password' => 'Secret123!', ], $manager['headers']); $result->assertStatus(200)->assertEnvelope()->assertSuccess(); $employeeId = (int)($result->data()['id'] ?? 0); expect($employeeId)->toBeGreaterThan(0); limited_backoffice_cleanup_created_employee($employeeId); $stmtEmp = api_test_runtime()->db()->prepare( 'SELECT `managed_group_id` FROM `limited_backoffice_employees` WHERE `user_id` = ? LIMIT 1' ); $stmtEmp->bind_param('i', $employeeId); $stmtEmp->execute(); $employeeRow = $stmtEmp->get_result()->fetch_assoc(); $stmtEmp->close(); $managedGroupId = (int)($employeeRow['managed_group_id'] ?? 0); expect($managedGroupId)->toBeGreaterThan(0); foreach (['statistics_orders_new', 'statistics_bookings_new'] as $perm) { $stmt = api_test_runtime()->db()->prepare( 'SELECT 1 FROM `groups_permissions` WHERE `group_id` = ? AND `permission` = ? LIMIT 1' ); $stmt->bind_param('is', $managedGroupId, $perm); $stmt->execute(); $permRow = $stmt->get_result()->fetch_assoc(); $stmt->close(); expect($permRow)->not->toBeNull("$roleKey managed employee must have $perm permission"); } } }); it('enforces department access when creating an order via POST /orders', function (): void { api_test_covers('POST /orders', 'department access'); $allowedDepartment = api_fixtures()->createDepartment(['name' => 'Order Create Allowed Dept']); $deniedDepartment = api_fixtures()->createDepartment(['name' => 'Order Create Denied Dept']); $customer = api_fixtures()->createUser(['display_name' => 'Order Create Customer']); $session = api_fixtures()->createUserSession([ 'add_order', 'department_access_' . (int)$allowedDepartment['id'], ]); // Should succeed for accessible department $created = api_client()->post('/orders', [ 'customer_id' => $customer['customer_number'], 'department_id' => (int)$allowedDepartment['id'], 'reference' => 'DEPT-ACCESS-TEST', 'notes' => '', 'reg_1' => 'ABCD111', ], $session['headers']); $created->assertStatus(200)->assertEnvelope()->assertSuccess(); // Should fail for inaccessible department api_client()->post('/orders', [ 'customer_id' => $customer['customer_number'], 'department_id' => (int)$deniedDepartment['id'], 'reference' => 'DEPT-ACCESS-DENIED', 'notes' => '', 'reg_1' => 'ABCD222', ], $session['headers']) ->assertStatus(403) ->assertEnvelope() ->assertSuccess(false) ->assertMissingPermissions(['department_access_' . (int)$deniedDepartment['id']]); }); it('enforces department access on the existing order when editing via PUT /order', function (): void { api_test_covers('PUT /order', 'department access'); $allowedDepartment = api_fixtures()->createDepartment(['name' => 'Order Edit Allowed Dept']); $deniedDepartment = api_fixtures()->createDepartment(['name' => 'Order Edit Denied Dept']); $customer = api_fixtures()->createUser(['display_name' => 'Order Edit Customer']); $orderInAllowed = api_fixtures()->createOrder([ 'customer_id' => $customer['customer_number'], 'department_id' => (int)$allowedDepartment['id'], ]); $orderInDenied = api_fixtures()->createOrder([ 'customer_id' => $customer['customer_number'], 'department_id' => (int)$deniedDepartment['id'], ]); $session = api_fixtures()->createUserSession([ 'edit_order', 'department_access_' . (int)$allowedDepartment['id'], ]); // Should succeed editing order in accessible department api_client()->put('/order', [ 'id' => (int)$orderInAllowed['id'], 'notes' => 'updated', ], $session['headers']) ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); // Should fail editing order in inaccessible department api_client()->put('/order', [ 'id' => (int)$orderInDenied['id'], 'notes' => 'should be denied', ], $session['headers']) ->assertStatus(403) ->assertEnvelope() ->assertSuccess(false) ->assertMissingPermissions(['department_access_' . (int)$deniedDepartment['id']]); }); it('enforces department access when moving an order to a new department via PUT /order', function (): void { api_test_covers('PUT /order', 'department access move'); $allowedDepartment = api_fixtures()->createDepartment(['name' => 'Order Move Allowed Dept']); $deniedDepartment = api_fixtures()->createDepartment(['name' => 'Order Move Denied Dept']); $customer = api_fixtures()->createUser(['display_name' => 'Order Move Customer']); $order = api_fixtures()->createOrder([ 'customer_id' => $customer['customer_number'], 'department_id' => (int)$allowedDepartment['id'], ]); $sessionBothDepts = api_fixtures()->createUserSession([ 'edit_order', 'department_access_' . (int)$allowedDepartment['id'], 'department_access_' . (int)$deniedDepartment['id'], ]); $sessionOnlyAllowed = api_fixtures()->createUserSession([ 'edit_order', 'department_access_' . (int)$allowedDepartment['id'], ]); // Should fail when moving to inaccessible department (user only has access to allowedDepartment) api_client()->put('/order', [ 'id' => (int)$order['id'], 'department_id' => (int)$deniedDepartment['id'], ], $sessionOnlyAllowed['headers']) ->assertStatus(403) ->assertEnvelope() ->assertSuccess(false) ->assertMissingPermissions(['department_access_' . (int)$deniedDepartment['id']]); // Should succeed when user has access to both departments api_client()->put('/order', [ 'id' => (int)$order['id'], 'department_id' => (int)$deniedDepartment['id'], ], $sessionBothDepts['headers']) ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); }); it('enforces department access when marking an order as completed via POST /orders/mark_as_completed', function (): void { api_test_covers('POST /orders/mark_as_completed', 'department access'); $allowedDepartment = api_fixtures()->createDepartment(['name' => 'Order Complete Allowed Dept']); $deniedDepartment = api_fixtures()->createDepartment(['name' => 'Order Complete Denied Dept']); $customer = api_fixtures()->createUser(['display_name' => 'Order Complete Customer']); $orderAllowed = api_fixtures()->createOrder([ 'customer_id' => $customer['customer_number'], 'department_id' => (int)$allowedDepartment['id'], ]); $orderDenied = api_fixtures()->createOrder([ 'customer_id' => $customer['customer_number'], 'department_id' => (int)$deniedDepartment['id'], ]); $session = api_fixtures()->createUserSession([ 'mark_order_as_completed', 'department_access_' . (int)$allowedDepartment['id'], ]); // Should fail for inaccessible department api_client()->post('/orders/mark_as_completed', [ 'id' => (int)$orderDenied['id'], ], $session['headers']) ->assertStatus(403) ->assertEnvelope() ->assertSuccess(false) ->assertMissingPermissions(['department_access_' . (int)$deniedDepartment['id']]); // Should succeed for accessible department api_client()->post('/orders/mark_as_completed', [ 'id' => (int)$orderAllowed['id'], ], $session['headers']) ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); }); it('enforces department access when deleting order attachments via DELETE /orders/attachments', function (): void { api_test_covers('DELETE /orders/attachments', 'department access'); $allowedDepartment = api_fixtures()->createDepartment(['name' => 'Order Del Attach Allowed']); $deniedDepartment = api_fixtures()->createDepartment(['name' => 'Order Del Attach Denied']); $customer = api_fixtures()->createUser(['display_name' => 'Order Del Attach Customer']); $orderAllowed = api_fixtures()->createOrder([ 'customer_id' => $customer['customer_number'], 'department_id' => (int)$allowedDepartment['id'], ]); $orderDenied = api_fixtures()->createOrder([ 'customer_id' => $customer['customer_number'], 'department_id' => (int)$deniedDepartment['id'], ]); $attachmentAllowed = api_fixtures()->createOrderAttachment(['order_id' => (int)$orderAllowed['id']]); $attachmentDenied = api_fixtures()->createOrderAttachment(['order_id' => (int)$orderDenied['id']]); $session = api_fixtures()->createUserSession([ 'delete_order_attachments', 'department_access_' . (int)$allowedDepartment['id'], ]); // Should fail for inaccessible department api_client()->delete('/orders/attachments', [ 'order_id' => (int)$orderDenied['id'], 'attachment_id' => (int)$attachmentDenied['id'], ], $session['headers']) ->assertStatus(403) ->assertEnvelope() ->assertSuccess(false) ->assertMissingPermissions(['department_access_' . (int)$deniedDepartment['id']]); // Should succeed for accessible department api_client()->delete('/orders/attachments', [ 'order_id' => (int)$orderAllowed['id'], 'attachment_id' => (int)$attachmentAllowed['id'], ], $session['headers']) ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); }); it('enforces department access when adding order items via POST /order/items', function (): void { api_test_covers('POST /order/items', 'department access'); $allowedDepartment = api_fixtures()->createDepartment(['name' => 'Order Item Add Allowed']); $deniedDepartment = api_fixtures()->createDepartment(['name' => 'Order Item Add Denied']); $customer = api_fixtures()->createUser(['display_name' => 'Order Item Add Customer']); $category = api_fixtures()->createCategory(['name' => 'Order Item Dept Access Category']); $product = api_fixtures()->createProduct(['name' => 'Order Item Dept Access Product', 'category' => $category['id']]); $orderAllowed = api_fixtures()->createOrder([ 'customer_id' => $customer['customer_number'], 'department_id' => (int)$allowedDepartment['id'], ]); $orderDenied = api_fixtures()->createOrder([ 'customer_id' => $customer['customer_number'], 'department_id' => (int)$deniedDepartment['id'], ]); $session = api_fixtures()->createUserSession([ 'add_order_items', 'department_access_' . (int)$allowedDepartment['id'], ]); // Should fail for inaccessible department api_client()->post('/order/items', [ 'order_id' => (int)$orderDenied['id'], 'product_id' => (int)$product['id'], 'quantity' => 1, ], $session['headers']) ->assertStatus(403) ->assertEnvelope() ->assertSuccess(false) ->assertMissingPermissions(['department_access_' . (int)$deniedDepartment['id']]); // Should succeed for accessible department api_client()->post('/order/items', [ 'order_id' => (int)$orderAllowed['id'], 'product_id' => (int)$product['id'], 'quantity' => 1, ], $session['headers']) ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); }); it('enforces department access when deleting order items via DELETE /order/items', function (): void { api_test_covers('DELETE /order/items', 'department access'); $allowedDepartment = api_fixtures()->createDepartment(['name' => 'Order Item Del Allowed']); $deniedDepartment = api_fixtures()->createDepartment(['name' => 'Order Item Del Denied']); $customer = api_fixtures()->createUser(['display_name' => 'Order Item Del Customer']); $category = api_fixtures()->createCategory(['name' => 'Order Item Del Category']); $product = api_fixtures()->createProduct(['name' => 'Order Item Del Product', 'category' => $category['id']]); $orderAllowed = api_fixtures()->createOrder([ 'customer_id' => $customer['customer_number'], 'department_id' => (int)$allowedDepartment['id'], ]); $orderDenied = api_fixtures()->createOrder([ 'customer_id' => $customer['customer_number'], 'department_id' => (int)$deniedDepartment['id'], ]); $session = api_fixtures()->createUserSession([ 'delete_order_items', 'department_access_' . (int)$allowedDepartment['id'], ]); $superuserSession = api_fixtures()->createUserSession([], ['group_id' => 1]); $itemAllowedResponse = api_client()->post('/order/items', [ 'order_id' => (int)$orderAllowed['id'], 'product_id' => (int)$product['id'], 'quantity' => 1, ], $superuserSession['headers']); $itemAllowed = $itemAllowedResponse->data(); $itemDeniedResponse = api_client()->post('/order/items', [ 'order_id' => (int)$orderDenied['id'], 'product_id' => (int)$product['id'], 'quantity' => 1, ], $superuserSession['headers']); $itemDenied = $itemDeniedResponse->data(); // Should fail for inaccessible department api_client()->delete('/order/items', ['id' => (int)$itemDenied['id']], $session['headers']) ->assertStatus(403) ->assertEnvelope() ->assertSuccess(false) ->assertMissingPermissions(['department_access_' . (int)$deniedDepartment['id']]); // Should succeed for accessible department api_client()->delete('/order/items', ['id' => (int)$itemAllowed['id']], $session['headers']) ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); }); it('enforces department access when editing order items via PUT /order/items', function (): void { api_test_covers('PUT /order/items', 'department access'); $allowedDepartment = api_fixtures()->createDepartment(['name' => 'Order Item Edit Allowed']); $deniedDepartment = api_fixtures()->createDepartment(['name' => 'Order Item Edit Denied']); $customer = api_fixtures()->createUser(['display_name' => 'Order Item Edit Customer']); $category = api_fixtures()->createCategory(['name' => 'Order Item Edit Category']); $product = api_fixtures()->createProduct(['name' => 'Order Item Edit Product', 'category' => $category['id']]); $orderAllowed = api_fixtures()->createOrder([ 'customer_id' => $customer['customer_number'], 'department_id' => (int)$allowedDepartment['id'], ]); $orderDenied = api_fixtures()->createOrder([ 'customer_id' => $customer['customer_number'], 'department_id' => (int)$deniedDepartment['id'], ]); $itemAllowed = api_fixtures()->createOrderItem([ 'order_id' => (int)$orderAllowed['id'], 'product_id' => (int)$product['id'], 'cashier_id' => 1, 'price' => 100, ]); $itemDenied = api_fixtures()->createOrderItem([ 'order_id' => (int)$orderDenied['id'], 'product_id' => (int)$product['id'], 'cashier_id' => 1, 'price' => 200, ]); $session = api_fixtures()->createUserSession([ 'edit_order_items', 'list_order_items', 'department_access_' . (int)$allowedDepartment['id'], ]); // Should fail for inaccessible department api_client()->put('/order/items', [ 'id' => (int)$itemDenied['id'], 'price' => 999, 'notes' => '', 'reference' => '', 'quantity' => 1, ], $session['headers']) ->assertStatus(403) ->assertEnvelope() ->assertSuccess(false) ->assertMissingPermissions(['department_access_' . (int)$deniedDepartment['id']]); // Should succeed for accessible department api_client()->put('/order/items', [ 'id' => (int)$itemAllowed['id'], 'price' => 150, 'notes' => '', 'reference' => '', 'quantity' => 1, ], $session['headers']) ->assertStatus(200) ->assertEnvelope() ->assertSuccess(); });