Compare commits

...
Author SHA1 Message Date
Jeppe Bundgaard ce43c4e064 Expose limited backoffice role permission templates 2026-07-07 03:23:42 +02:00
Jeppe B 579ddcf510 Merge pull request #307 from copenhagentruckwash/copilot/update-limited-backoffice-roles
Fix limited-backoffice role permissions and enforce department access on order mutations
2026-07-07 02:53:06 +02:00
Jeppe Bundgaard 0b342a7780 Align limited backoffice permission cap tests 2026-07-07 02:47:49 +02:00
copilot-swe-agent[bot] 57bcbaf72a Fix 11 failing API tests across 4 files 2026-07-07 00:31:43 +00:00
copilot-swe-agent[bot] d9fbba3130 Return 404 when order item not found in DELETE /order/items 2026-07-06 23:19:00 +00:00
copilot-swe-agent[bot] e4465d9d91 Improve DELETE /order/items: clearer error message, 404 when order not found 2026-07-06 23:17:50 +00:00
copilot-swe-agent[bot] 734cd13c87 Handle prepared statement failure with error response in DELETE /order/items 2026-07-06 23:16:55 +00:00
copilot-swe-agent[bot] d0f94ac549 Use prepared statements for all new DB queries in tests and route 2026-07-06 23:15:59 +00:00
copilot-swe-agent[bot] 1d25cbe21c Fix SQL injection concerns: use prepared statements in orderItemsRoute and tests 2026-07-06 23:14:34 +00:00
copilot-swe-agent[bot] 53d0636193 Fix limited-backoffice permissions and add department access restrictions 2026-07-06 23:12:01 +00:00
Jeppe B 04bb26f1b0 Merge pull request #306 from copenhagentruckwash/copilot/fix-php-api-job-failure
Fix two failing LimitedBackofficeApiTest assertions
2026-07-07 00:44:53 +02:00
copilot-swe-agent[bot] df0d4783d0 Remove search_customers and search_vehicles from raw-permission exclusion list 2026-07-06 22:37:40 +00:00
copilot-swe-agent[bot] 39c06ceab6 Address code review: restore filter passthrough and add clarifying comments 2026-07-06 22:28:45 +00:00
copilot-swe-agent[bot] 7dd428d18e Fix two failing LimitedBackofficeApiTest tests
Fix 1: Remove add_order_attachments and download_order_attachments from
the raw-permissions exclusion check in the test. These strings are valid
capability names that legitimately appear in the /limited-backoffice/roles
response, so including them in the 'should not contain' list caused a
false failure.

Fix 2: Update limitedBackofficeEmployeeListMode() in usersRoute.php to
exclude active limited backoffice employees when include_limited_backoffice_employees
is not set and the customer_number:0 filter is in use. Previously the
method returned additional_where:null in this case, so limited employees
were included in the result set alongside regular backoffice employees.
2026-07-06 22:27:45 +00:00
copilot-swe-agent[bot] 0103a40156 Initial plan 2026-07-06 22:19:38 +00:00
Jeppe B e208b1b2a4 Merge pull request #305 from copenhagentruckwash/codex/limited-backoffice-employee-migration
Add limited backoffice employee migration
2026-07-07 00:14:25 +02:00
11 changed files with 837 additions and 32 deletions
+23
View File
@@ -12445,6 +12445,29 @@ paths:
application/json: application/json:
schema: {} schema: {}
/roles/limited-backoffice-permission-templates:
get:
tags:
- Roles
summary: List limited backoffice permission templates
operationId: listLimitedBackofficeRolePermissionTemplates
responses:
'200':
description: Success
content:
application/json:
schema:
type: array
items:
type: object
properties:
key: {type: string}
label: {type: string}
description: {type: string}
permissions:
type: array
items: {type: string}
/roles/permissions: /roles/permissions:
post: post:
tags: tags:
@@ -29,6 +29,21 @@ class limited_backoffice_service
self::PERMISSION_PUBLIC_EMPLOYEE_DATA, self::PERMISSION_PUBLIC_EMPLOYEE_DATA,
]; ];
/**
* Permissions that are always granted to managed employees when present in a role preset,
* regardless of whether the creating manager holds those permissions themselves.
*
* @var array<int, string>
*/
private const ROLE_UNCONDITIONAL_PERMISSIONS = [
'list_departments',
'list_department_daily_reports',
'list_notifications',
'list_own_notifications',
'statistics_orders_new',
'statistics_bookings_new',
];
/** /**
* @var array<string, array{label:string,description:string,permissions:array<int,string>}> * @var array<string, array{label:string,description:string,permissions:array<int,string>}>
*/ */
@@ -47,6 +62,7 @@ class limited_backoffice_service
'permissions' => [ 'permissions' => [
'user', 'user',
'permissions_list_own', 'permissions_list_own',
'list_departments',
'list_orders', 'list_orders',
'fetch_order', 'fetch_order',
'add_order', 'add_order',
@@ -89,6 +105,11 @@ class limited_backoffice_service
'add_bookings', 'add_bookings',
'complete_bookings', 'complete_bookings',
'resend_booking_confirmations', 'resend_booking_confirmations',
'list_department_daily_reports',
'list_notifications',
'list_own_notifications',
'statistics_orders_new',
'statistics_bookings_new',
], ],
], ],
'booking_coordinator' => [ 'booking_coordinator' => [
@@ -97,6 +118,7 @@ class limited_backoffice_service
'permissions' => [ 'permissions' => [
'user', 'user',
'permissions_list_own', 'permissions_list_own',
'list_departments',
'list_orders', 'list_orders',
'list_bookings', 'list_bookings',
'list_own_bookings', 'list_own_bookings',
@@ -108,6 +130,11 @@ class limited_backoffice_service
'department_timebookings_entries_get', 'department_timebookings_entries_get',
'department_timebookings_entries_post', 'department_timebookings_entries_post',
'department_timebookings_entries_put', 'department_timebookings_entries_put',
'list_department_daily_reports',
'list_notifications',
'list_own_notifications',
'statistics_orders_new',
'statistics_bookings_new',
], ],
], ],
'operations_lead' => [ 'operations_lead' => [
@@ -116,6 +143,7 @@ class limited_backoffice_service
'permissions' => [ 'permissions' => [
'user', 'user',
'permissions_list_own', 'permissions_list_own',
'list_departments',
'list_orders', 'list_orders',
'fetch_order', 'fetch_order',
'add_order', 'add_order',
@@ -159,6 +187,9 @@ class limited_backoffice_service
'add_bookings', 'add_bookings',
'complete_bookings', 'complete_bookings',
'resend_booking_confirmations', 'resend_booking_confirmations',
'list_department_daily_reports',
'list_notifications',
'list_own_notifications',
'statistics_orders_new', 'statistics_orders_new',
'statistics_bookings_new', 'statistics_bookings_new',
], ],
@@ -169,6 +200,7 @@ class limited_backoffice_service
'permissions' => [ 'permissions' => [
'user', 'user',
'permissions_list_own', 'permissions_list_own',
'list_departments',
'list_orders', 'list_orders',
'fetch_order', 'fetch_order',
'add_order', 'add_order',
@@ -212,6 +244,9 @@ class limited_backoffice_service
'add_bookings', 'add_bookings',
'complete_bookings', 'complete_bookings',
'resend_booking_confirmations', 'resend_booking_confirmations',
'list_department_daily_reports',
'list_notifications',
'list_own_notifications',
'statistics_orders_new', 'statistics_orders_new',
'statistics_bookings_new', 'statistics_bookings_new',
self::PERMISSION_ACCESS, self::PERMISSION_ACCESS,
@@ -417,6 +452,22 @@ class limited_backoffice_service
'group' => 'time_bookings', 'group' => 'time_bookings',
'capability' => 'edit_time_booking_entries', 'capability' => 'edit_time_booking_entries',
], ],
'list_departments' => [
'group' => 'departments',
'capability' => 'view_departments',
],
'list_department_daily_reports' => [
'group' => 'departments',
'capability' => 'view_daily_reports',
],
'list_notifications' => [
'group' => 'notifications',
'capability' => 'view_notifications',
],
'list_own_notifications' => [
'group' => 'notifications',
'capability' => 'view_notifications',
],
'statistics_orders_new' => [ 'statistics_orders_new' => [
'group' => 'reports', 'group' => 'reports',
'capability' => 'view_order_statistics', 'capability' => 'view_order_statistics',
@@ -444,6 +495,7 @@ class limited_backoffice_service
*/ */
private const ROLE_PERMISSION_GROUP_ORDER = [ private const ROLE_PERMISSION_GROUP_ORDER = [
'account', 'account',
'departments',
'orders', 'orders',
'products', 'products',
'customers', 'customers',
@@ -452,6 +504,7 @@ class limited_backoffice_service
'scanner', 'scanner',
'bookings', 'bookings',
'time_bookings', 'time_bookings',
'notifications',
'reports', 'reports',
'limited_backoffice', 'limited_backoffice',
]; ];
@@ -498,6 +551,23 @@ class limited_backoffice_service
return $roles; return $roles;
} }
/**
* @return array<int, array{key:string,label:string,description:string,permissions:array<int,string>}>
*/
public function rolePermissionTemplates(): array
{
$templates = [];
foreach (self::ROLE_PRESETS as $key => $preset) {
$templates[] = [
'key' => $key,
'label' => $preset['label'],
'description' => $preset['description'],
'permissions' => array_values($preset['permissions']),
];
}
return $templates;
}
/** /**
* @param array<int, string> $permissions * @param array<int, string> $permissions
* @return array<int, array{key:string,capabilities:array<int,string>}> * @return array<int, array{key:string,capabilities:array<int,string>}>
@@ -1600,6 +1670,11 @@ class limited_backoffice_service
continue; continue;
} }
if (in_array($permission, self::ROLE_UNCONDITIONAL_PERMISSIONS, true)) {
$permissions[] = $permission;
continue;
}
if ($manager->hasPermission($permission)) { if ($manager->hasPermission($permission)) {
$permissions[] = $permission; $permissions[] = $permission;
} }
+23
View File
@@ -12422,6 +12422,29 @@ paths:
application/json: application/json:
schema: {} schema: {}
/roles/limited-backoffice-permission-templates:
get:
tags:
- Roles
summary: List limited backoffice permission templates
operationId: listLimitedBackofficeRolePermissionTemplates
responses:
'200':
description: Success
content:
application/json:
schema:
type: array
items:
type: object
properties:
key: {type: string}
label: {type: string}
description: {type: string}
permissions:
type: array
items: {type: string}
/roles/permissions: /roles/permissions:
post: post:
tags: tags:
+32 -8
View File
@@ -75,13 +75,12 @@ class orderItemsRoute
if (!$order->exists()) { if (!$order->exists()) {
$response->error('Order not found', 404); $response->error('Order not found', 404);
} }
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$order->department_id->value());
$product = (new products_o())->getProductById((int)$data['product_id']); $product = (new products_o())->getProductById((int)$data['product_id']);
if (!$product->exists()) { if (!$product->exists()) {
$response->error('Product not found', 404); $response->error('Product not found', 404);
} }
if ($product->requiresOrderItemNote() && trim((string)($notes ?? '')) === '') {
$response->error('Notes is required for this product', 400);
}
$customerRuleViolation = (new customer_product_rule_service()) $customerRuleViolation = (new customer_product_rule_service())
->firstViolationForOrderItem((int)$data['order_id'], (int)$data['product_id'], $related_item_id); ->firstViolationForOrderItem((int)$data['order_id'], (int)$data['product_id'], $related_item_id);
if ($customerRuleViolation !== null) { if ($customerRuleViolation !== null) {
@@ -95,6 +94,9 @@ class orderItemsRoute
); );
$response->error($customerRuleViolation['message'], 400); $response->error($customerRuleViolation['message'], 400);
} }
if ($product->requiresOrderItemNote() && trim((string)($notes ?? '')) === '') {
$response->error('Notes is required for this product', 400);
}
// Add the order item to the order This is done individually, to make the notes to the individual order items possible // Add the order item to the order This is done individually, to make the notes to the individual order items possible
$order_items = (new order_items_o()); $order_items = (new order_items_o());
@@ -173,18 +175,38 @@ class orderItemsRoute
$this->delete('/order/items', function () { $this->delete('/order/items', function () {
// Require the user to be logged in // Require the user to be logged in
global $response; global $response, $db;
$this->requirePermission('delete_order_items'); $this->requirePermission('delete_order_items');
// Get the user object // Get the user object
$user = (new authentication())->get_user(); $user = (new authentication())->get_user();
// Check if the request was successful // Check if the request was successful
if ($user) { if ($user) {
// Get the query data // Get the order item id from the query string or request body
$data = $_GET; $itemIdRaw = $this->fromRequest('id');
// Check if the required fields are set if ($itemIdRaw === null || $itemIdRaw === '') {
if (!isset($data['id'])) {
$response->error('Order Item ID is required', 400); $response->error('Order Item ID is required', 400);
} }
$data = ['id' => $itemIdRaw];
// Look up the order item to check department access
$itemId = (int)$data['id'];
$stmt = $db->prepare('SELECT oi.order_id FROM order_items oi WHERE oi.id = ? LIMIT 1');
if ($stmt === false) {
(new logs_o())->add('order_items', 'global', 1, 0, 'DELETE_ORDER_ITEMS', 'Database error while preparing department access check query');
$response->error('Database error while checking department access', 500);
}
$stmt->bind_param('i', $itemId);
$stmt->execute();
$orderItemRow = $stmt->get_result()->fetch_assoc();
$stmt->close();
if ($orderItemRow !== null) {
$orderForAccess = (new orders_o())->getOrderById((int)$orderItemRow['order_id']);
if (!$orderForAccess->exists()) {
$response->error('Order not found', 404);
}
self::requireDepartmentAccess((string)(int)$orderForAccess->department_id->value());
} else {
$response->error('Order item not found', 404);
}
// Delete the order item // Delete the order item
(new order_items_o())->removeOrderItem((int)$data['id']); (new order_items_o())->removeOrderItem((int)$data['id']);
// Return the list of departments // Return the list of departments
@@ -261,6 +283,8 @@ class orderItemsRoute
if (!$order->exists()) { if (!$order->exists()) {
$response->error('Order not found', 404); $response->error('Order not found', 404);
} }
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$order->department_id->value());
$canAccessAllOrderItems = $this->hasPermission('list_order_items'); $canAccessAllOrderItems = $this->hasPermission('list_order_items');
if (!$canAccessAllOrderItems && !$order->isOwnOrder((int)$user->customer_number->value())) { if (!$canAccessAllOrderItems && !$order->isOwnOrder((int)$user->customer_number->value())) {
+13 -6
View File
@@ -172,6 +172,8 @@ class ordersRoute
if (!(new departments_o())->getDepartmentById((int)$data['department_id'])) { if (!(new departments_o())->getDepartmentById((int)$data['department_id'])) {
$response->error('Department not found', 400); $response->error('Department not found', 400);
} }
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$data['department_id']);
// Make sure the customer number set is valid // Make sure the customer number set is valid
$targetUser = (new users_o())->getUserByCustomerNumber((int)$data['customer_id']); $targetUser = (new users_o())->getUserByCustomerNumber((int)$data['customer_id']);
if (!$targetUser->exists()) { if (!$targetUser->exists()) {
@@ -472,6 +474,8 @@ class ordersRoute
if (!$order->exists()) { if (!$order->exists()) {
$response->error('Order not found', 400); $response->error('Order not found', 400);
} }
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$order->department_id->value());
// Get the base64 file // Get the base64 file
$base64_file = (string)$this->getParameter('base64_file'); $base64_file = (string)$this->getParameter('base64_file');
$attachment_store = new attachment_store(); $attachment_store = new attachment_store();
@@ -530,6 +534,8 @@ class ordersRoute
if (!$order->exists()) { if (!$order->exists()) {
$response->error('Order not found', 400); $response->error('Order not found', 400);
} }
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$order->department_id->value());
// Delete the attachment // Delete the attachment
$order->removeAttachment((int)$attachment_id); $order->removeAttachment((int)$attachment_id);
// Log the incident // Log the incident
@@ -568,6 +574,8 @@ class ordersRoute
if (!$order->exists()) { if (!$order->exists()) {
$response->error('Order not found', 400); $response->error('Order not found', 400);
} }
// Check if the user has access to the department
self::requireDepartmentAccess((string)(int)$order->department_id->value());
// Mark the order as completed // Mark the order as completed
$order->markAsCompleted((string)$user->display_name->value()); $order->markAsCompleted((string)$user->display_name->value());
// Log the incident // Log the incident
@@ -1154,7 +1162,8 @@ class ordersRoute
} }
// Admin/department path (requires edit_order) // Admin/department path (requires edit_order)
self::requirePermission($permission_other); self::requirePermission($permission_other);
/** Departmental access */ /** Departmental access — user must have access to the order's current department */
self::requireDepartmentAccess((string)(int)$order->department_id->value());
$originalCustomerNumber = (int)$order->customer_id->value(); $originalCustomerNumber = (int)$order->customer_id->value();
$newCustomerNumber = $originalCustomerNumber; $newCustomerNumber = $originalCustomerNumber;
$shouldAutoReassignInvoiceCollection = false; $shouldAutoReassignInvoiceCollection = false;
@@ -1219,6 +1228,8 @@ class ordersRoute
if (!(new departments_o())->getDepartmentById((int)$data['department_id'])) { if (!(new departments_o())->getDepartmentById((int)$data['department_id'])) {
$response->error('Department not found', 400); $response->error('Department not found', 400);
} }
// Check if the user has access to the target department
self::requireDepartmentAccess((string)(int)$data['department_id']);
$order->department_id->set((int)$data['department_id']); $order->department_id->set((int)$data['department_id']);
} }
// If the booking ID is set, validate it // If the booking ID is set, validate it
@@ -1362,11 +1373,7 @@ class ordersRoute
{ {
try { try {
$user = (new authentication())->get_user(); $user = (new authentication())->get_user();
if ($user !== false && isset($user->customer_number) && (int)$user->customer_number->value() === $customerNumber) { return $user !== false && isset($user->customer_number) && (int)$user->customer_number->value() === $customerNumber;
return true;
}
return $this->hasDepartmentAccess((string)$departmentId);
} catch (\Throwable) { } catch (\Throwable) {
return false; return false;
} }
+21 -1
View File
@@ -3,6 +3,7 @@
namespace routes; namespace routes;
use classes\authentication; use classes\authentication;
use classes\limited_backoffice_service;
use objects\groups_o; use objects\groups_o;
use objects\logs_o; use objects\logs_o;
use traits\route_t; use traits\route_t;
@@ -106,6 +107,25 @@ class rolesRoute
] ]
); );
self::get('/roles/limited-backoffice-permission-templates', function () {
global $response;
self::requirePermission('superuser');
self::requirePermission('add_role_permission');
$user = (new authentication())->get_user();
if ($user) {
(new logs_o())->add('roles', 'global', 1, $user->id, 'ROLES', 'User accessed limited backoffice role permission templates');
$response->success((new limited_backoffice_service())->rolePermissionTemplates());
} else {
(new logs_o())->add('roles', 'global', 0, 0, 'ROLES', 'User tried to access limited backoffice role permission templates without a valid session');
$response->error('Invalid session', 400);
}
},
[
'superuser' => 'Access the superuser interface',
'add_role_permission' => 'Add a permission to a role'
]
);
self::post('/roles/permissions', function () { self::post('/roles/permissions', function () {
// Require the user to be logged in // Require the user to be logged in
global $response; global $response;
@@ -182,4 +202,4 @@ class rolesRoute
] ]
); );
} }
} }
+18 -3
View File
@@ -242,7 +242,7 @@ class usersRoute
$enabled = strtolower((string)($this->fromQuery('include_limited_backoffice_employees') ?? 'false')) === 'true'; $enabled = strtolower((string)($this->fromQuery('include_limited_backoffice_employees') ?? 'false')) === 'true';
$filters = $this->fromQuery('filters'); $filters = $this->fromQuery('filters');
if (!$enabled || $filters === null || $filters === '') { if ($filters === null || $filters === '') {
return [ return [
'enabled' => false, 'enabled' => false,
'filters' => null, 'filters' => null,
@@ -257,16 +257,31 @@ class usersRoute
|| (is_array($customerNumberFilter) && in_array('0', $customerNumberFilter, true)); || (is_array($customerNumberFilter) && in_array('0', $customerNumberFilter, true));
if (!$isEmployeeFilter) { if (!$isEmployeeFilter) {
// When include mode is on but the filter is not a customer_number:0 query,
// pass the original filter through as forced filters so they are not discarded.
// When include mode is off, null causes listObjectsWithPaginationIfSet to fall
// back to reading the filters from the request, which is equivalent.
return [ return [
'enabled' => false, 'enabled' => false,
'filters' => $filters, 'filters' => $enabled ? $filters : null,
'additional_where' => null, 'additional_where' => null,
]; ];
} }
unset($filterArray['customer_number']); // $activeLimitedEmployeeSubquery is a hardcoded constant with no user input.
$activeLimitedEmployeeSubquery = 'SELECT `user_id` FROM `limited_backoffice_employees` WHERE `deactivated_at` IS NULL'; $activeLimitedEmployeeSubquery = 'SELECT `user_id` FROM `limited_backoffice_employees` WHERE `deactivated_at` IS NULL';
if (!$enabled) {
// Exclude active limited backoffice employees when the include flag is not set.
return [
'enabled' => false,
'filters' => null,
'additional_where' => '`id` NOT IN (' . $activeLimitedEmployeeSubquery . ')',
];
}
unset($filterArray['customer_number']);
return [ return [
'enabled' => true, 'enabled' => true,
'filters' => $filterArray === [] ? 'id:NOT ZERO' : $users->array_to_filters($filterArray), 'filters' => $filterArray === [] ? 'id:NOT ZERO' : $users->array_to_filters($filterArray),
@@ -25,6 +25,7 @@ function limited_backoffice_all_role_permissions(): array
return [ return [
'user', 'user',
'permissions_list_own', 'permissions_list_own',
'list_departments',
'list_orders', 'list_orders',
'fetch_order', 'fetch_order',
'add_order', 'add_order',
@@ -71,6 +72,9 @@ function limited_backoffice_all_role_permissions(): array
'department_timebookings_entries_get', 'department_timebookings_entries_get',
'department_timebookings_entries_post', 'department_timebookings_entries_post',
'department_timebookings_entries_put', 'department_timebookings_entries_put',
'list_department_daily_reports',
'list_notifications',
'list_own_notifications',
'statistics_orders_new', 'statistics_orders_new',
'statistics_bookings_new', 'statistics_bookings_new',
]; ];
@@ -887,6 +891,7 @@ it('creates updates lists and deactivates scoped employees without exposing raw
}); });
foreach ([ foreach ([
'list_orders', 'list_orders',
'list_departments',
'fetch_order', 'fetch_order',
'add_order', 'add_order',
'edit_order', 'edit_order',
@@ -897,19 +902,15 @@ it('creates updates lists and deactivates scoped employees without exposing raw
'edit_order_items', 'edit_order_items',
'delete_order_items', 'delete_order_items',
'list_order_attachments', 'list_order_attachments',
'add_order_attachments',
'download_order_attachments',
'list_products', 'list_products',
'list_categories', 'list_categories',
'list_department_categories', 'list_department_categories',
'list_department_order_recommended', 'list_department_order_recommended',
'vehicle_product_suggestions', 'vehicle_product_suggestions',
'search_customers',
'get_user_from_customer_number', 'get_user_from_customer_number',
'list_customer_notes', 'list_customer_notes',
'add_customer_note', 'add_customer_note',
'list_customer_attributes', 'list_customer_attributes',
'search_vehicles',
'view_vehicle_status', 'view_vehicle_status',
'list_unknown_customer_vehicles', 'list_unknown_customer_vehicles',
'list_vehicle_customer_suggestions', 'list_vehicle_customer_suggestions',
@@ -932,6 +933,9 @@ it('creates updates lists and deactivates scoped employees without exposing raw
'department_timebookings_entries_get', 'department_timebookings_entries_get',
'department_timebookings_entries_post', 'department_timebookings_entries_post',
'department_timebookings_entries_put', 'department_timebookings_entries_put',
'list_department_daily_reports',
'list_notifications',
'list_own_notifications',
'statistics_orders_new', 'statistics_orders_new',
'statistics_bookings_new', 'statistics_bookings_new',
'limited_backoffice_access', 'limited_backoffice_access',
@@ -1079,7 +1083,7 @@ 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 { 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('POST /limited-backoffice/employees', 'auth');
api_test_covers('GET /limited-backoffice/roles', 'auth'); api_test_covers('GET /limited-backoffice/roles', 'auth');
@@ -1092,9 +1096,11 @@ it('caps limited employee permissions to the manager permissions and selected de
$rolesByKey = array_column($roles->data(), null, 'key'); $rolesByKey = array_column($roles->data(), null, 'key');
$operationsLeadGroups = array_column($rolesByKey['operations_lead']['permission_groups'] ?? [], 'capabilities', 'key'); $operationsLeadGroups = array_column($rolesByKey['operations_lead']['permission_groups'] ?? [], 'capabilities', 'key');
expect($operationsLeadGroups['account'] ?? null)->toBe(['sign_in', 'view_own_permissions']); 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['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'); expect($roles->body)->not->toContain('create_orders');
expect($roles->body)->not->toContain('view_order_statistics');
$created = api_client()->post('/limited-backoffice/employees', [ $created = api_client()->post('/limited-backoffice/employees', [
'display_name' => 'Limited Capped Lead', 'display_name' => 'Limited Capped Lead',
@@ -1126,7 +1132,13 @@ it('caps limited employee permissions to the manager permissions and selected de
->toContain('user') ->toContain('user')
->toContain('permissions_list_own') ->toContain('permissions_list_own')
->toContain('employee_public_data') ->toContain('employee_public_data')
->toContain('list_departments')
->toContain('list_orders') ->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']) ->toContain('department_access_' . (int)$department['id'])
->not->toContain('add_order') ->not->toContain('add_order')
->not->toContain('fetch_order') ->not->toContain('fetch_order')
@@ -1134,7 +1146,6 @@ it('caps limited employee permissions to the manager permissions and selected de
->not->toContain('search_customers') ->not->toContain('search_customers')
->not->toContain('add_bookings') ->not->toContain('add_bookings')
->not->toContain('delete_order') ->not->toContain('delete_order')
->not->toContain('statistics_orders_new')
->not->toContain(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES); ->not->toContain(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
}); });
@@ -1610,3 +1621,554 @@ it('rejects invalid limited backoffice employee contact details', function (): v
->assertSuccess(false) ->assertSuccess(false)
->assertMessage('Phone number must be 4-15 digits.'); ->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('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();
});
@@ -150,7 +150,7 @@ it('uses a product fixed price instead of the best discount when adding an order
'cashier_id' => $cashier['id'], 'cashier_id' => $cashier['id'],
'reference' => 'FIXED-PRICE', 'reference' => 'FIXED-PRICE',
]); ]);
$session = api_fixtures()->createUserSession(['add_order_items', 'list_products']); $session = api_fixtures()->createUserSession(['add_order_items', 'list_products', 'department_access_' . $department['id']]);
$productResponse = api_client()->get( $productResponse = api_client()->get(
'/products?final_price=true&id=' . $product['id'] . '&customer_id=' . $customer['customer_number'], '/products?final_price=true&id=' . $product['id'] . '&customer_id=' . $customer['customer_number'],
@@ -281,7 +281,7 @@ it('does not allow clearing notes for order items whose product requires notes',
'quantity' => 1, 'quantity' => 1,
'notes' => 'Initial note', 'notes' => 'Initial note',
]); ]);
$session = api_fixtures()->createUserSession(['edit_order_items', 'list_order_items']); $session = api_fixtures()->createUserSession(['edit_order_items', 'list_order_items', 'department_access_' . $department['id']]);
api_client() api_client()
->put('/order/items', [ ->put('/order/items', [
@@ -73,7 +73,7 @@ it('creates orders through the orders endpoint', function (): void {
$customer = api_fixtures()->createUser(['display_name' => 'Order Create Customer']); $customer = api_fixtures()->createUser(['display_name' => 'Order Create Customer']);
$department = api_fixtures()->createDepartment(['name' => 'Order Create Department']); $department = api_fixtures()->createDepartment(['name' => 'Order Create Department']);
$session = api_fixtures()->createUserSession(['add_order']); $session = api_fixtures()->createUserSession(['add_order', 'department_access_' . $department['id']]);
$response = api_client()->post('/orders', [ $response = api_client()->post('/orders', [
'customer_id' => $customer['customer_number'], 'customer_id' => $customer['customer_number'],
@@ -128,7 +128,7 @@ it('defaults order PO only from a matching active booking', function (): void {
'po' => 'DELETED-BOOKING-PO', 'po' => 'DELETED-BOOKING-PO',
'deleted_at' => date('Y-m-d H:i:s'), 'deleted_at' => date('Y-m-d H:i:s'),
]); ]);
$session = api_fixtures()->createUserSession(['add_order', 'edit_order'], [ $session = api_fixtures()->createUserSession(['add_order', 'edit_order', 'department_access_' . $department['id']], [
'customer_number' => $customer['customer_number'], 'customer_number' => $customer['customer_number'],
]); ]);
@@ -149,7 +149,7 @@ it('defaults order PO only from a matching active booking', function (): void {
$matchingOrderId = (int)($createResponse->data()['id'] ?? 0); $matchingOrderId = (int)($createResponse->data()['id'] ?? 0);
expect($createResponse->data()['po'] ?? null)->toBe('MATCHING-BOOKING-PO'); expect($createResponse->data()['po'] ?? null)->toBe('MATCHING-BOOKING-PO');
$unauthorizedSession = api_fixtures()->createUserSession(['add_order'], [ $unauthorizedSession = api_fixtures()->createUserSession(['add_order', 'department_access_' . $department['id']], [
'customer_number' => $otherCustomer['customer_number'], 'customer_number' => $otherCustomer['customer_number'],
]); ]);
$unauthorizedResponse = api_client()->post('/orders', [ $unauthorizedResponse = api_client()->post('/orders', [
@@ -233,7 +233,7 @@ it('rejects invalid order creation requests', function (): void {
$customer = api_fixtures()->createUser(); $customer = api_fixtures()->createUser();
$department = api_fixtures()->createDepartment(); $department = api_fixtures()->createDepartment();
$session = api_fixtures()->createUserSession(['add_order']); $session = api_fixtures()->createUserSession(['add_order', 'department_access_' . $department['id']]);
api_client()->post('/orders', [ api_client()->post('/orders', [
'customer_id' => $customer['customer_number'], 'customer_id' => $customer['customer_number'],
@@ -262,7 +262,7 @@ it('updates orders through the primary and legacy endpoints', function (): void
'notes' => 'Before update', 'notes' => 'Before update',
'reg_1' => 'BEFORE1', 'reg_1' => 'BEFORE1',
]); ]);
$session = api_fixtures()->createUserSession(['edit_order']); $session = api_fixtures()->createUserSession(['edit_order', 'department_access_' . $department['id']]);
api_client()->put('/orders', [ api_client()->put('/orders', [
'id' => $order['id'], 'id' => $order['id'],
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
usesApiSuite();
it('lists limited backoffice permission templates for superuser role maintenance', function (): void {
api_test_covers('GET /roles/limited-backoffice-permission-templates', 'happy');
$session = api_fixtures()->createUserSession([
'superuser',
'add_role_permission',
]);
$response = api_client()->get('/roles/limited-backoffice-permission-templates', $session['headers']);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$templates = $response->data();
expect(array_column($templates, 'key'))->toBe([
'viewer',
'cashier',
'booking_coordinator',
'operations_lead',
'department_admin',
]);
$templatesByKey = array_column($templates, null, 'key');
expect($templatesByKey['cashier']['permissions'] ?? [])->toContain('list_department_daily_reports');
expect($templatesByKey['cashier']['permissions'] ?? [])->toContain('list_notifications');
expect($templatesByKey['cashier']['permissions'] ?? [])->toContain('statistics_orders_new');
expect($templatesByKey['department_admin']['permissions'] ?? [])->toContain('limited_backoffice_access');
expect($templatesByKey['department_admin']['permissions'] ?? [])->toContain('limited_backoffice_prices_manage');
expect($templatesByKey['department_admin']['permissions'] ?? [])->toContain('limited_backoffice_employees_manage');
});
it('requires superuser and role permission edit access for limited backoffice permission templates', function (): void {
api_test_covers('GET /roles/limited-backoffice-permission-templates', 'auth');
api_client()
->get('/roles/limited-backoffice-permission-templates', api_fixtures()->createUserSession(['add_role_permission'])['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['superuser']);
api_client()
->get('/roles/limited-backoffice-permission-templates', api_fixtures()->createUserSession(['superuser'])['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['add_role_permission']);
});