Harden Sæby demo registration and department scope (#335)

Complete and secure public customer/driver registration, authoritative limited-backoffice department scope, one-time employee QR login, and pricing concurrency for the Sæby demo.
This commit is contained in:
Jeppe B
2026-08-02 11:50:56 +02:00
committed by GitHub
parent 4587bdfb06
commit 1e0e051775
34 changed files with 1853 additions and 474 deletions
+181 -13
View File
@@ -1746,9 +1746,9 @@ paths:
- Subusers
summary: Create a subuser registration
description: |
Creates a subuser (driver) account using a company's CVR and a phone number. Validates the
CVR via e-conomic, ensures the phone number is not already in use, and if SMS is enabled
sends a setup link by SMS for the user to complete registration.
Starts a driver setup challenge using a company's CVR and a phone number. No company grant
is created until the driver proves possession of the phone by completing the SMS setup link.
The public response is uniform and never includes setup credentials or relationship state.
operationId: createSubuser
security: []
requestBody:
@@ -1761,6 +1761,7 @@ paths:
- cvr
- phone_country_code
- phone
- g_recaptcha_response
properties:
cvr:
type: integer
@@ -1774,24 +1775,23 @@ paths:
type: integer
description: Phone number (415 digits, no leading +)
example: 12345678
g_recaptcha_response:
type: string
description: reCAPTCHA response token
responses:
'200':
description: Subuser created (or pending setup) and company identified
description: Uniform driver registration acknowledgement
content:
application/json:
schema:
type: object
properties:
cvr:
type: integer
example: 12345678
customer_number:
type: integer
description: Matched e-conomic customer number
example: 1000
message:
type: string
'400': { $ref: '#/components/responses/BadRequest' }
'404': { $ref: '#/components/responses/NotFound' }
'500': { $ref: '#/components/responses/InternalServerError' }
'503': { $ref: '#/components/responses/ServiceUnavailable' }
/subusers/{id}:
get:
@@ -13543,8 +13543,72 @@ paths:
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/PricingConflict'
/limited-backoffice/departments/{departmentId}/prices:
get:
tags:
- Limited Backoffice
summary: Get explicit limited-backoffice department prices
description: Returns only explicit department prices and an opaque revision for optimistic concurrency.
operationId: getLimitedBackofficeDepartmentPrices
parameters:
- name: departmentId
in: path
required: true
schema:
type: integer
minimum: 1
responses:
'200':
description: Explicit department prices and current revision
content:
application/json:
schema:
$ref: '#/components/schemas/LimitedBackofficeDepartmentPricesResponse'
'400':
$ref: '#/components/responses/BadRequest'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
put:
tags:
- Limited Backoffice
summary: Replace explicit limited-backoffice department prices
description: Replaces the submitted explicit prices atomically. Send the revision returned by GET as `expected_revision` to prevent stale writes.
operationId: setLimitedBackofficeDepartmentPrices
parameters:
- name: departmentId
in: path
required: true
schema:
type: integer
minimum: 1
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LimitedBackofficeDepartmentPricesUpdateRequest'
responses:
'200':
description: Explicit department prices updated atomically
content:
application/json:
schema:
$ref: '#/components/schemas/LimitedBackofficeDepartmentPricesResponse'
'400':
$ref: '#/components/responses/BadRequest'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/PricingConflict'
/limited-backoffice/departments/{departmentId}/customer-pricing:
get:
@@ -13593,7 +13657,7 @@ paths:
tags:
- Limited Backoffice
summary: Replace limited-backoffice department customer pricing
description: Replaces the complete override set for one customer in an assigned custom-only department.
description: Replaces the complete override set atomically. Send the revision returned by GET as `expected_revision` to prevent stale writes.
operationId: setLimitedBackofficeDepartmentCustomerPricing
parameters:
- name: departmentId
@@ -13622,7 +13686,7 @@ paths:
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
$ref: '#/components/responses/PricingConflict'
/superuser/department/variables:
get:
@@ -14397,6 +14461,14 @@ components:
application/json:
schema:
$ref: '#/components/schemas/Error'
PricingConflict:
description: Pricing is unavailable in the current state or `expected_revision` is stale. Stale writes return code `pricing_revision_conflict` and the current revision.
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/Error'
- $ref: '#/components/schemas/PricingRevisionConflictResponse'
Unauthorized:
description: Unauthorized - Invalid or missing authentication token
content:
@@ -14590,6 +14662,8 @@ components:
customer_number:
type: integer
minimum: 1
expected_revision:
$ref: '#/components/schemas/PricingRevision'
overrides:
type: array
items:
@@ -14606,6 +14680,8 @@ components:
customer_number:
type: integer
minimum: 1
expected_revision:
$ref: '#/components/schemas/PricingRevision'
overrides:
type: array
items:
@@ -14636,6 +14712,7 @@ components:
type: integer
nullable: true
minimum: 0
description: Product-only fixed price. A row must use either a positive discount or a fixed price, not both.
DepartmentCustomerPricingOverride:
allOf:
@@ -14722,6 +14799,8 @@ components:
type: integer
display_name:
type: string
revision:
$ref: '#/components/schemas/PricingRevision'
overrides:
type: array
items:
@@ -14733,6 +14812,95 @@ components:
meta:
type: object
additionalProperties: true
PricingRevision:
type: string
pattern: '^[a-f0-9]{64}$'
description: Opaque SHA-256 content revision. Return it as `expected_revision` on the next update.
PricingRevisionConflictResponse:
type: object
required: [success, data]
properties:
success:
type: boolean
enum: [false]
data:
type: object
required: [message, code, current_revision]
properties:
message:
type: string
enum: [Pricing has changed. Reload and try again.]
code:
type: string
enum: [pricing_revision_conflict]
current_revision:
$ref: '#/components/schemas/PricingRevision'
meta:
type: object
additionalProperties: true
LimitedBackofficeDepartmentPriceInput:
type: object
required: [product_id, price]
properties:
product_id:
type: integer
minimum: 1
price:
type: integer
minimum: 0
LimitedBackofficeDepartmentPricesUpdateRequest:
type: object
required: [prices]
properties:
expected_revision:
$ref: '#/components/schemas/PricingRevision'
prices:
type: array
minItems: 1
items:
$ref: '#/components/schemas/LimitedBackofficeDepartmentPriceInput'
LimitedBackofficeDepartmentPricesResponse:
type: object
properties:
success:
type: boolean
data:
type: object
properties:
department:
type: object
properties:
id: { type: integer }
name: { type: string }
description: { type: string }
custom_pricing_only: { type: boolean }
revision:
$ref: '#/components/schemas/PricingRevision'
categories:
type: array
items:
type: object
properties:
id: { type: integer }
name: { type: string }
description: { type: string }
products:
type: array
items:
type: object
properties:
id: { type: integer }
name: { type: string }
description: { type: string }
price: { type: integer }
meta:
type: object
additionalProperties: true
includes:
type: object
additionalProperties: true
@@ -25,6 +25,7 @@ class department_customer_pricing_service
'customer' => $customer,
'overrides' => $overrides,
'categories' => $this->catalog($departmentId, $customer['id']),
'revision' => $this->revision($overrides),
];
}
@@ -52,34 +53,69 @@ class department_customer_pricing_service
$normalized = $this->normalizeOverrides($departmentId, $overrides);
$overrideObject = new department_customer_price_overrides_o();
$existingOverrides = $overrideObject->getAllPrices($departmentId, $customer['id']);
$expectedRevision = $this->normalizeExpectedRevision($payload['expected_revision'] ?? null);
$existingOverrides = [];
$normalizedKeys = [];
foreach ($normalized as $override) {
$normalizedKeys[$this->overrideKey((bool)$override['is_category'], $override['product_or_category_id'])] = true;
}
global $db;
$db->conn()->begin_transaction();
$mysqli = $db->conn();
$mysqli->begin_transaction();
try {
$db->query(
'DELETE FROM `department_customer_price_overrides` WHERE `department_id` = '
. (int)$departmentId . ' AND `user_id` = ' . (int)$customer['id']
);
foreach ($normalized as $override) {
$overrideObject->setPrice(
$departmentId,
$customer['id'],
(bool)$override['is_category'],
$override['product_or_category_id'],
(int)$override['percentage'],
$override['fixed_price']
);
$this->lockDepartment($departmentId);
$existingOverrides = $overrideObject->getAllPrices($departmentId, $customer['id']);
$currentRevision = $this->revision($existingOverrides);
if ($expectedRevision !== null && !hash_equals($currentRevision, $expectedRevision)) {
throw $this->revisionConflict($currentRevision);
}
$db->conn()->commit();
$deleteStatement = $mysqli->prepare(
'DELETE FROM `department_customer_price_overrides` WHERE `department_id` = ? AND `user_id` = ?'
);
$insertStatement = $mysqli->prepare(
'INSERT INTO `department_customer_price_overrides`
(`department_id`, `user_id`, `is_category`, `product_or_category_id`, `percentage`, `fixed_price`)
VALUES (?, ?, ?, ?, ?, ?)'
);
if ($deleteStatement === false || $insertStatement === false) {
throw new \RuntimeException('Unable to prepare department customer pricing update.');
}
$customerId = (int)$customer['id'];
$deleteStatement->bind_param('ii', $departmentId, $customerId);
if (!$deleteStatement->execute()) {
throw new \RuntimeException('Unable to clear department customer pricing.');
}
foreach ($normalized as $override) {
$isCategory = (int)(bool)$override['is_category'];
$objectId = (string)$override['product_or_category_id'];
$percentage = (int)$override['percentage'];
$fixedPrice = $override['fixed_price'] === null ? null : (int)$override['fixed_price'];
$insertStatement->bind_param(
'iiisii',
$departmentId,
$customerId,
$isCategory,
$objectId,
$percentage,
$fixedPrice
);
if (!$insertStatement->execute()) {
throw new \RuntimeException('Unable to save department customer pricing.');
}
}
$deleteStatement->close();
$insertStatement->close();
$mysqli->commit();
} catch (limited_backoffice_exception $exception) {
$mysqli->rollback();
throw $exception;
} catch (\Throwable) {
$db->conn()->rollback();
$mysqli->rollback();
throw new limited_backoffice_exception('Unable to update department customer pricing.', 500);
}
@@ -254,6 +290,9 @@ class department_customer_pricing_service
}
if ($isCategory) {
if ($fixedPrice !== null) {
throw new limited_backoffice_exception('Fixed prices can only be assigned to products.', 400);
}
$fixedPrice = null;
$objectId = (string)$objectId;
if ($objectId !== 'global') {
@@ -269,6 +308,14 @@ class department_customer_pricing_service
}
$key = $this->overrideKey($isCategory, $objectId);
if (isset($normalized[$key])) {
throw new limited_backoffice_exception('Duplicate customer price overrides are not allowed.', 400);
}
if ($fixedPrice !== null && $percentage > 0) {
throw new limited_backoffice_exception('Choose either a discount or a fixed price.', 400);
}
$normalized[$key] = [
'is_category' => $isCategory,
'product_or_category_id' => $objectId,
@@ -313,6 +360,64 @@ class department_customer_pricing_service
return ((int)$isCategory) . ':' . (string)$objectId;
}
/**
* @param array<int, array<string, mixed>> $overrides
*/
private function revision(array $overrides): string
{
$revisionRows = array_map(static fn(array $override): array => [
'is_category' => (bool)$override['is_category'],
'product_or_category_id' => (string)$override['product_or_category_id'],
'percentage' => (int)$override['percentage'],
'fixed_price' => $override['fixed_price'] === null ? null : (int)$override['fixed_price'],
], $overrides);
usort($revisionRows, static function (array $left, array $right): int {
return [$left['is_category'] ? 0 : 1, $left['product_or_category_id']]
<=> [$right['is_category'] ? 0 : 1, $right['product_or_category_id']];
});
return hash('sha256', json_encode($revisionRows, JSON_THROW_ON_ERROR));
}
private function normalizeExpectedRevision(mixed $value): ?string
{
if ($value === null || $value === '') {
// Keep the backend-first rollout compatible with the currently deployed UI.
return null;
}
if (!is_string($value) || preg_match('/^[a-f0-9]{64}$/', $value) !== 1) {
throw new limited_backoffice_exception('Expected revision is invalid.', 400, [
'message' => 'Expected revision is invalid.',
'code' => 'pricing_revision_invalid',
]);
}
return $value;
}
private function lockDepartment(int $departmentId): void
{
global $db;
$result = $db->query(
'SELECT `id` FROM `departments` WHERE `id` = ' . (int)$departmentId . ' FOR UPDATE'
);
if (!$result || $result->num_rows < 1) {
throw new limited_backoffice_exception('Department not found', 404);
}
}
private function revisionConflict(string $currentRevision): limited_backoffice_exception
{
return new limited_backoffice_exception('Pricing has changed. Reload and try again.', 409, [
'message' => 'Pricing has changed. Reload and try again.',
'code' => 'pricing_revision_conflict',
'current_revision' => $currentRevision,
]);
}
private function assertDepartmentProduct(int $departmentId, int $productId): void
{
global $db;
@@ -634,6 +634,27 @@ class limited_backoffice_service
return $this->accessibleDepartmentIdsForGroup($groupId);
}
/**
* Returns the authoritative department scope only when the authenticated
* user is managed by limited backoffice. A null result means the caller
* must preserve the route's existing non-limited authorization semantics.
*
* @return array<int, int>|null
*/
public function managedEmployeeDepartmentIds(users_o $user): ?array
{
if (!$user->exists() || (int)$user->id <= 0) {
return null;
}
$managedEmployee = $this->loadManagedEmployee((int)$user->id);
if ($managedEmployee === null) {
return null;
}
return $this->decodeDepartmentIds((string)($managedEmployee['department_ids'] ?? '[]'));
}
/**
* Loads department scope from an authoritative group identity rather than
* from user object properties that may be backed by a stale Redis value.
@@ -753,6 +774,7 @@ class limited_backoffice_service
return [
'department' => $department,
'categories' => $catalog['categories'],
'revision' => $this->departmentPricesRevision($departmentId),
];
}
@@ -804,10 +826,17 @@ class limited_backoffice_service
}
}
$expectedRevision = $this->normalizeExpectedPricingRevision($payload['expected_revision'] ?? null);
$mysqli = $this->mysqli();
$mysqli->begin_transaction();
try {
$this->lockDepartmentForPricingUpdate($departmentId);
$currentRevision = $this->departmentPricesRevision($departmentId);
if ($expectedRevision !== null && !hash_equals($currentRevision, $expectedRevision)) {
throw $this->pricingRevisionConflict($currentRevision);
}
$deleteStatement = $mysqli->prepare(
'DELETE FROM `product_department_prices` WHERE `department_id` = ? AND `product_id` = ?'
);
@@ -820,15 +849,22 @@ class limited_backoffice_service
foreach ($normalizedPrices as $productId => $price) {
$deleteStatement->bind_param('ii', $departmentId, $productId);
$deleteStatement->execute();
if (!$deleteStatement->execute()) {
throw new \RuntimeException('Unable to clear department price.');
}
$insertStatement->bind_param('iii', $departmentId, $productId, $price);
$insertStatement->execute();
if (!$insertStatement->execute()) {
throw new \RuntimeException('Unable to save department price.');
}
}
$deleteStatement->close();
$insertStatement->close();
$mysqli->commit();
} catch (limited_backoffice_exception $exception) {
$mysqli->rollback();
throw $exception;
} catch (\Throwable $throwable) {
$mysqli->rollback();
throw new limited_backoffice_exception('Unable to update department prices.', 500);
@@ -1348,35 +1384,7 @@ class limited_backoffice_service
}
/**
* @return array{employee_id:int,login_path:string}
*/
public function createEmployeeLoginLink(users_o $manager, int $employeeId): array
{
$this->assertEmployeeLoginTarget($manager, $employeeId);
$token = (new authentication())->create_employee_token($employeeId);
try {
(new logs_o())->add(
'auth',
'global',
1,
(int)$manager->id,
'AUTH_SUCCESS_LIMITED_BACKOFFICE_EMPLOYEE_LOGIN_LINK',
'Created limited backoffice login link for employee: ' . $employeeId
);
} catch (\Throwable) {
// Audit logging should not block login-link generation.
}
return [
'employee_id' => $employeeId,
'login_path' => '/login/qr?token=' . $token,
];
}
/**
* Applies the same target and department boundary used by the legacy login-link endpoint.
* Applies the target and department boundary used by one-time login grants.
*/
public function assertEmployeeLoginTarget(
users_o $manager,
@@ -1406,6 +1414,80 @@ class limited_backoffice_service
return $db->conn();
}
private function lockDepartmentForPricingUpdate(int $departmentId): void
{
$statement = $this->mysqli()->prepare(
'SELECT `id` FROM `departments` WHERE `id` = ? FOR UPDATE'
);
if ($statement === false) {
throw new \RuntimeException('Unable to prepare pricing update lock.');
}
$statement->bind_param('i', $departmentId);
$statement->execute();
$result = $statement->get_result();
$exists = $result->num_rows > 0;
$statement->close();
if (!$exists) {
throw new limited_backoffice_exception('Department not found', 404);
}
}
private function departmentPricesRevision(int $departmentId): string
{
global $db;
$statement = $this->mysqli()->prepare(
'SELECT `product_id`, `price`
FROM `product_department_prices`
WHERE `department_id` = ?
ORDER BY `product_id` ASC, `id` ASC'
);
if ($statement === false) {
throw new \RuntimeException('Unable to prepare department price revision.');
}
$statement->bind_param('i', $departmentId);
$statement->execute();
$rows = $db->fetch_all($statement->get_result());
$statement->close();
$revisionRows = array_map(static fn(array $row): array => [
'product_id' => (int)$row['product_id'],
'price' => (int)$row['price'],
], $rows);
return hash('sha256', json_encode($revisionRows, JSON_THROW_ON_ERROR));
}
private function normalizeExpectedPricingRevision(mixed $value): ?string
{
if ($value === null || $value === '') {
// Transitional compatibility for already-deployed clients. New clients
// send the revision returned by GET and receive stale-write protection.
return null;
}
if (!is_string($value) || preg_match('/^[a-f0-9]{64}$/', $value) !== 1) {
throw new limited_backoffice_exception('Expected revision is invalid.', 400, [
'message' => 'Expected revision is invalid.',
'code' => 'pricing_revision_invalid',
]);
}
return $value;
}
private function pricingRevisionConflict(string $currentRevision): limited_backoffice_exception
{
return new limited_backoffice_exception('Pricing has changed. Reload and try again.', 409, [
'message' => 'Pricing has changed. Reload and try again.',
'code' => 'pricing_revision_conflict',
'current_revision' => $currentRevision,
]);
}
private function tableHasColumn(string $table, string $column): bool
{
$cacheKey = $table . '.' . $column;
+18
View File
@@ -636,6 +636,24 @@ class redis implements redis_i
return $this;
}
/**
* Atomically increments a fixed-window counter and assigns its TTL on the
* first increment. This avoids the GET/SET race in public abuse controls.
*/
public function incrementWithExpiration(string $key, int $seconds): int
{
if (!self::is_connected()) {
self::connect();
}
$count = (int)$this->redis->incr($key);
if ($count === 1) {
$this->redis->expire($key, max(1, $seconds));
}
return $count;
}
public function generateTemporaryCacheKey(): string
{
// Generate a temporary cache key
+42 -4
View File
@@ -271,12 +271,22 @@ class orders_o extends db
* @param int $entries The number of last entries to return (default 10)
* @return array The orders for the vehicle plate
*/
public function getOrderHistoryByVehiclePlate(string $plate, int $entries = 10): array
public function getOrderHistoryByVehiclePlate(
string $plate,
int $entries = 10,
?array $departmentIds = null
): array
{
global $db;
$plate = $db->escape_string($plate);
$entries = max(1, $entries);
$sql = "SELECT * FROM $this->table WHERE reg_1 = '$plate' OR reg_2 = '$plate' OR reg_3 = '$plate' ORDER BY id DESC LIMIT $entries";
$departmentFilter = $this->departmentScopeSql($departmentIds);
if ($departmentFilter === false) {
return [];
}
$sql = "SELECT * FROM $this->table WHERE (reg_1 = '$plate' OR reg_2 = '$plate' OR reg_3 = '$plate')"
. $departmentFilter
. " ORDER BY id DESC LIMIT $entries";
$result = $db->query($sql);
return $db->fetch_all($result);
}
@@ -622,10 +632,18 @@ class orders_o extends db
* @param string $plate The vehicle plate
* @return array The order history
*/
public function get_vehicle_order_history(string $plate): array
public function get_vehicle_order_history(string $plate, ?array $departmentIds = null): array
{
global $db;
$stmt = $db->prepare("SELECT * FROM $this->table WHERE (reg_1 = ? OR reg_2 = ? OR reg_3 = ?) AND deleted_at IS NULL ORDER BY id DESC LIMIT 5");
$departmentFilter = $this->departmentScopeSql($departmentIds);
if ($departmentFilter === false) {
return [];
}
$stmt = $db->prepare(
"SELECT * FROM $this->table WHERE (reg_1 = ? OR reg_2 = ? OR reg_3 = ?)"
. $departmentFilter
. ' AND deleted_at IS NULL ORDER BY id DESC LIMIT 5'
);
if (!$stmt) {
return [];
}
@@ -636,6 +654,26 @@ class orders_o extends db
return $db->fetch_all($result);
}
/**
* @param array<int, int>|null $departmentIds
*/
private function departmentScopeSql(?array $departmentIds): string|false
{
if ($departmentIds === null) {
return '';
}
$departmentIds = array_values(array_unique(array_filter(
array_map('intval', $departmentIds),
static fn(int $departmentId): bool => $departmentId > 0
)));
if ($departmentIds === []) {
return false;
}
return ' AND department_id IN (' . implode(',', $departmentIds) . ')';
}
/**
* @throws Exception If the order is not selected
*/
+26 -6
View File
@@ -1054,26 +1054,24 @@ class users_o extends db
$overrides = new department_customer_price_overrides_o();
$product = (new products_o())->select((int)$product_id);
$discounts = [];
$productRow = $overrides->getDirectPriceRow($department_id, (int)$this->id, false, (int)$product_id);
if ($productRow !== null) {
$discounts[] = (int)$productRow['percentage'];
return (int)$productRow['percentage'];
}
if ((bool)$product->apply_category_discount->value()) {
$categoryRow = $overrides->getDirectPriceRow($department_id, (int)$this->id, true, (string)$product->category->value());
if ($categoryRow !== null) {
$discounts[] = (int)$categoryRow['percentage'];
return (int)$categoryRow['percentage'];
}
$globalRow = $overrides->getDirectPriceRow($department_id, (int)$this->id, true, 'global');
if ($globalRow !== null) {
$discounts[] = (int)$globalRow['percentage'];
return (int)$globalRow['percentage'];
}
}
return max([0, ...$discounts]);
return 0;
}
@@ -1731,6 +1729,28 @@ class users_o extends db
$this->objectChanged();
}
public function hydrateRegistrationContact(
string $email,
int $phoneNumber,
int $phoneCountryCode = 45,
?string $contactName = null
): void {
self::requireSelected();
$email = trim($email);
if ($email !== '') {
$this->email->set($email);
}
$this->setPhoneNumber($phoneNumber, $phoneCountryCode);
$contactName = trim((string)$contactName);
if ($contactName !== '') {
$this->keys->setValue('registration_contact_name', $contactName);
}
$this->objectChanged();
}
public function setSMSNotificationsEnabled(bool $enabled): void
{
self::requireSelected();
+192 -14
View File
@@ -1998,9 +1998,9 @@ paths:
- Subusers
summary: Create a subuser registration
description: |
Creates a subuser (driver) account using a company's CVR and a phone number. Validates the
CVR via e-conomic, ensures the phone number is not already in use, and if SMS is enabled
sends a setup link by SMS for the user to complete registration.
Starts a driver setup challenge using a company's CVR and a phone number. No company grant
is created until the driver proves possession of the phone by completing the SMS setup link.
The public response is uniform and never includes setup credentials or relationship state.
operationId: createSubuser
security: []
requestBody:
@@ -2013,6 +2013,7 @@ paths:
- cvr
- phone_country_code
- phone
- g_recaptcha_response
properties:
cvr:
type: integer
@@ -2026,24 +2027,27 @@ paths:
type: integer
description: Phone number (415 digits, no leading +)
example: 12345678
g_recaptcha_response:
type: string
description: reCAPTCHA response token
responses:
'200':
description: Subuser created (or pending setup) and company identified
description: Uniform driver registration acknowledgement
content:
application/json:
schema:
type: object
properties:
cvr:
type: integer
example: 12345678
customer_number:
type: integer
description: Matched e-conomic customer number
example: 1000
message:
type: string
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'409': { $ref: '#/components/responses/Conflict' }
'429':
description: Registration rate limit exceeded
'404': { $ref: '#/components/responses/NotFound' }
'500': { $ref: '#/components/responses/InternalServerError' }
'503': { $ref: '#/components/responses/ServiceUnavailable' }
/subusers/me/verification:
get:
@@ -3422,7 +3426,6 @@ paths:
- invoiceEmail
- contactEmail
- contactPhone
- contactName
- g_recaptcha_response
properties:
cvr:
@@ -3457,6 +3460,13 @@ paths:
minimum: 10000000
maximum: 9999999999
example: 21754690
contactPhoneCountryCode:
type: integer
description: Contact phone country code without a leading plus sign. Defaults to Denmark.
minimum: 1
maximum: 999
default: 45
example: 45
contactName:
type: string
description: Contact person name
@@ -14438,8 +14448,72 @@ paths:
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/PricingConflict'
/limited-backoffice/departments/{departmentId}/prices:
get:
tags:
- Limited Backoffice
summary: Get explicit limited-backoffice department prices
description: Returns only explicit department prices and an opaque revision for optimistic concurrency.
operationId: getLimitedBackofficeDepartmentPrices
parameters:
- name: departmentId
in: path
required: true
schema:
type: integer
minimum: 1
responses:
'200':
description: Explicit department prices and current revision
content:
application/json:
schema:
$ref: '#/components/schemas/LimitedBackofficeDepartmentPricesResponse'
'400':
$ref: '#/components/responses/BadRequest'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
put:
tags:
- Limited Backoffice
summary: Replace explicit limited-backoffice department prices
description: Replaces the submitted explicit prices atomically. Send the revision returned by GET as `expected_revision` to prevent stale writes.
operationId: setLimitedBackofficeDepartmentPrices
parameters:
- name: departmentId
in: path
required: true
schema:
type: integer
minimum: 1
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LimitedBackofficeDepartmentPricesUpdateRequest'
responses:
'200':
description: Explicit department prices updated atomically
content:
application/json:
schema:
$ref: '#/components/schemas/LimitedBackofficeDepartmentPricesResponse'
'400':
$ref: '#/components/responses/BadRequest'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/PricingConflict'
/limited-backoffice/departments/{departmentId}/customer-pricing:
get:
@@ -14488,7 +14562,7 @@ paths:
tags:
- Limited Backoffice
summary: Replace limited-backoffice department customer pricing
description: Replaces the complete override set for one customer in an assigned custom-only department.
description: Replaces the complete override set atomically. Send the revision returned by GET as `expected_revision` to prevent stale writes.
operationId: setLimitedBackofficeDepartmentCustomerPricing
parameters:
- name: departmentId
@@ -14517,7 +14591,7 @@ paths:
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
$ref: '#/components/responses/PricingConflict'
/limited-backoffice/employees/{employeeId}/login-grants:
post:
@@ -15400,6 +15474,14 @@ components:
application/json:
schema:
$ref: '#/components/schemas/Error'
PricingConflict:
description: Pricing is unavailable in the current state or `expected_revision` is stale. Stale writes return code `pricing_revision_conflict` and the current revision.
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/Error'
- $ref: '#/components/schemas/PricingRevisionConflictResponse'
Unauthorized:
description: Unauthorized - Invalid or missing authentication token
content:
@@ -15926,6 +16008,8 @@ components:
customer_number:
type: integer
minimum: 1
expected_revision:
$ref: '#/components/schemas/PricingRevision'
overrides:
type: array
items:
@@ -15942,6 +16026,8 @@ components:
customer_number:
type: integer
minimum: 1
expected_revision:
$ref: '#/components/schemas/PricingRevision'
overrides:
type: array
items:
@@ -15972,6 +16058,7 @@ components:
type: integer
nullable: true
minimum: 0
description: Product-only fixed price. A row must use either a positive discount or a fixed price, not both.
DepartmentCustomerPricingOverride:
allOf:
@@ -16058,6 +16145,8 @@ components:
type: integer
display_name:
type: string
revision:
$ref: '#/components/schemas/PricingRevision'
overrides:
type: array
items:
@@ -16069,6 +16158,95 @@ components:
meta:
type: object
additionalProperties: true
PricingRevision:
type: string
pattern: '^[a-f0-9]{64}$'
description: Opaque SHA-256 content revision. Return it as `expected_revision` on the next update.
PricingRevisionConflictResponse:
type: object
required: [success, data]
properties:
success:
type: boolean
enum: [false]
data:
type: object
required: [message, code, current_revision]
properties:
message:
type: string
enum: [Pricing has changed. Reload and try again.]
code:
type: string
enum: [pricing_revision_conflict]
current_revision:
$ref: '#/components/schemas/PricingRevision'
meta:
type: object
additionalProperties: true
LimitedBackofficeDepartmentPriceInput:
type: object
required: [product_id, price]
properties:
product_id:
type: integer
minimum: 1
price:
type: integer
minimum: 0
LimitedBackofficeDepartmentPricesUpdateRequest:
type: object
required: [prices]
properties:
expected_revision:
$ref: '#/components/schemas/PricingRevision'
prices:
type: array
minItems: 1
items:
$ref: '#/components/schemas/LimitedBackofficeDepartmentPriceInput'
LimitedBackofficeDepartmentPricesResponse:
type: object
properties:
success:
type: boolean
data:
type: object
properties:
department:
type: object
properties:
id: { type: integer }
name: { type: string }
description: { type: string }
custom_pricing_only: { type: boolean }
revision:
$ref: '#/components/schemas/PricingRevision'
categories:
type: array
items:
type: object
properties:
id: { type: integer }
name: { type: string }
description: { type: string }
products:
type: array
items:
type: object
properties:
id: { type: integer }
name: { type: string }
description: { type: string }
price: { type: integer }
meta:
type: object
additionalProperties: true
includes:
type: object
additionalProperties: true
+133 -13
View File
@@ -32,6 +32,9 @@ class authRoute
{
use route_t;
private const PUBLIC_REGISTRATION_RATE_LIMIT = 5;
private const PUBLIC_REGISTRATION_RATE_WINDOW_SECONDS = 15 * 60;
private function passkeyChallengePrincipalCacheKey(string $challengeToken): string
{
return 'passkey_challenge_principal:' . $challengeToken;
@@ -64,6 +67,38 @@ class authRoute
constant('redis')->delete($this->passkeyChallengePrincipalCacheKey($challengeToken));
}
private function requirePublicRegistrationRateLimit(
string $scope,
string $identifier,
int $limit = self::PUBLIC_REGISTRATION_RATE_LIMIT,
int $windowSeconds = self::PUBLIC_REGISTRATION_RATE_WINDOW_SECONDS,
bool $includeClientIp = true
): void {
global $response;
if (!defined('redis')) {
if (PHP_SAPI === 'cli') {
return;
}
$response->error('Registration protection is temporarily unavailable.', 503);
}
$remoteAddress = trim((string)($_SERVER['REMOTE_ADDR'] ?? 'unknown'));
$key = 'auth_public_registration_throttle:' . preg_replace('/[^a-z0-9:_-]/i', '_', $scope) . ':' . hash(
'sha256',
($includeClientIp ? $remoteAddress . ':' : '') . $identifier
);
try {
$attempts = constant('redis')->incrementWithExpiration($key, $windowSeconds);
} catch (\Throwable $exception) {
error_log('[auth] public registration throttle unavailable: ' . $exception->getMessage());
$response->error('Registration protection is temporarily unavailable.', 503);
}
if ($attempts > $limit) {
$response->error('Too many registration attempts. Please wait and try again.', 429);
}
}
private function passkeyAllowCredentials(int $userId, bool $isSubuser): array
{
if ($userId <= 0) {
@@ -403,10 +438,10 @@ class authRoute
$recaptcha = (new recaptcha())->getPublicConfig();
$response->success([
'rate_limit' => [
'enabled' => false,
'limit' => 0,
'remaining' => 0,
'reset' => 0,
'enabled' => defined('redis'),
'limit' => self::PUBLIC_REGISTRATION_RATE_LIMIT,
'remaining' => self::PUBLIC_REGISTRATION_RATE_LIMIT,
'reset' => self::PUBLIC_REGISTRATION_RATE_WINDOW_SECONDS,
'warning' => null
],
'recaptcha' => $recaptcha
@@ -461,6 +496,9 @@ class authRoute
$contactEmail = self::getParameter('contactEmail');
$contactPhone = (int)self::getParameter('contactPhone');
$contactName = self::getParameter('contactName');
$contactPhoneCountryCode = self::isParametersSet(['contactPhoneCountryCode'])
? (int)self::getParameter('contactPhoneCountryCode')
: 45;
$ean = null;
/**
* Validate
@@ -475,11 +513,17 @@ class authRoute
self::requireType($invoiceEmail, $this->type_string());
self::requireMinLength('invoiceEmail', 5);
self::requireMaxLength('invoiceEmail', 255);
if (!filter_var((string)$invoiceEmail, FILTER_VALIDATE_EMAIL)) {
$response->error('Invalid invoice email format', 400);
}
}
if (self::isParametersSet(['contactEmail']) && !is_null($contactEmail)) {
self::requireType($contactEmail, $this->type_string());
self::requireMinLength('contactEmail', 5);
self::requireMaxLength('contactEmail', 255);
if (!filter_var((string)$contactEmail, FILTER_VALIDATE_EMAIL)) {
$response->error('Invalid contact email format', 400);
}
}
/**
* If the contact phone is set, validate it
@@ -489,6 +533,17 @@ class authRoute
self::requireMinValue($contactPhone, 10000000);
self::requireMaxValue($contactPhone, 9999999999);
}
if ($contactPhoneCountryCode < 1 || $contactPhoneCountryCode > 999) {
$response->error('Invalid contact phone country code', 400);
}
$this->requirePublicRegistrationRateLimit('customer_ip', 'all');
$this->requirePublicRegistrationRateLimit(
'customer_identity',
'cvr:' . (string)$cvr . ':phone:' . $companyPhone,
3,
60 * 60,
false
);
if (self::isParametersSet(['ean'])) {
try {
$ean = economic::normalizeCustomerEan(self::getParameter('ean'));
@@ -527,7 +582,15 @@ class authRoute
$response->error('Company phone number already registered', 400);
}
$this->bootstrapLocalCustomerOrFail($companyPhone);
$customer = $this->bootstrapLocalCustomerOrFail($companyPhone, $matchingEconomicCustomer);
$this->hydrateRegistrationContact(
$customer,
$companyPhone,
(string)$contactEmail,
(int)$contactPhone,
$contactPhoneCountryCode,
is_scalar($contactName) ? (string)$contactName : null
);
$this->sendRegistrationWelcomeEmails($companyPhone, (string)$invoiceEmail);
$response->success($matchingEconomicCustomer, 200);
}
@@ -592,7 +655,11 @@ class authRoute
$economic,
(string)$cvr,
$companyPhone,
(string)$invoiceEmail
(string)$invoiceEmail,
(string)$contactEmail,
(int)$contactPhone,
$contactPhoneCountryCode,
is_scalar($contactName) ? (string)$contactName : null
);
if ($recoveredCustomer !== null) {
@@ -638,7 +705,15 @@ class authRoute
);
}
$this->bootstrapLocalCustomerOrFail($companyPhone, $result);
$customer = $this->bootstrapLocalCustomerOrFail($companyPhone, $result);
$this->hydrateRegistrationContact(
$customer,
$companyPhone,
(string)$contactEmail,
(int)$contactPhone,
$contactPhoneCountryCode,
is_scalar($contactName) ? (string)$contactName : null
);
$this->sendRegistrationWelcomeEmails($companyPhone, (string)$invoiceEmail);
$response->success($result, 201);
});
@@ -964,7 +1039,11 @@ class authRoute
economic $economic,
string $cvr,
int $customerNumber,
string $invoiceEmail
string $invoiceEmail,
string $contactEmail,
int $contactPhone,
int $contactPhoneCountryCode,
?string $contactName
): ?object {
// The upstream POST can commit before the client receives a validation/transport error.
// Re-read by CVR and only recover when e-conomic confirms the requested customer number.
@@ -985,7 +1064,15 @@ class authRoute
return null;
}
$this->bootstrapLocalCustomerOrFail($customerNumber, $matchingEconomicCustomer);
$customer = $this->bootstrapLocalCustomerOrFail($customerNumber, $matchingEconomicCustomer);
$this->hydrateRegistrationContact(
$customer,
$customerNumber,
$contactEmail,
$contactPhone,
$contactPhoneCountryCode,
$contactName
);
$this->sendRegistrationWelcomeEmails($customerNumber, $invoiceEmail);
return $matchingEconomicCustomer;
@@ -1059,6 +1146,30 @@ class authRoute
$response->error('Customer was created in e-conomic but could not be imported locally.', 500);
}
private function hydrateRegistrationContact(
users_o $customer,
int $customerNumber,
string $contactEmail,
int $contactPhone,
int $contactPhoneCountryCode,
?string $contactName
): void {
try {
$customer->hydrateRegistrationContact(
$contactEmail,
$contactPhone,
$contactPhoneCountryCode,
$contactName
);
} catch (\Throwable $exception) {
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_CONTACT_HYDRATION_FAILED', [
'customerNumber' => $customerNumber,
'message' => $exception->getMessage(),
]);
throw $exception;
}
}
/**
* @throws Exception
*/
@@ -1068,12 +1179,21 @@ class authRoute
$jimmyEmail = 'jm@truckwash.dk';
//$infoEmail = "info@truckwash.dk";
//$email->sendWelcomeEmailToCustomer($customerNumber, (string)$infoEmail); - Disabled, requested by Christian.
$email->sendWelcomeEmailToCustomer($customerNumber, $jimmyEmail);
$email->sendWelcomeEmailToCustomer($customerNumber, $invoiceEmail);
foreach ([$jimmyEmail, $invoiceEmail] as $recipient) {
try {
$email->sendWelcomeEmailToCustomer($customerNumber, $recipient);
} catch (\Throwable $exception) {
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_WELCOME_EMAIL_FAILED', [
'customerNumber' => $customerNumber,
'recipientHash' => hash('sha256', strtolower(trim($recipient))),
'message' => $exception->getMessage(),
]);
}
}
try {
$email->sendNewCustomerRegistrationNotifications($customerNumber);
} catch (Exception $exception) {
} catch (\Throwable $exception) {
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_SUPERUSER_NOTIFICATION_FAILED', [
'customerNumber' => $customerNumber,
'message' => $exception->getMessage(),
@@ -1082,7 +1202,7 @@ class authRoute
try {
(new slack())->send_customer_registration_notification($customerNumber);
} catch (Exception $exception) {
} catch (\Throwable $exception) {
$this->logRegisterCvrIssue('AUTH_REGISTER_CVR_SLACK_NOTIFICATION_FAILED', [
'customerNumber' => $customerNumber,
'message' => $exception->getMessage(),
+5 -1
View File
@@ -51,6 +51,10 @@ class bookingsRoute
if (!$user->hasAccessToBooking((int)self::getParameter('id'))) {
$response->forbidden(['list_bookings']);
}
$this->requireLimitedBackofficeDepartmentAccess(
$user,
(int)$bookings_o->department->value()
);
// Return the booking
$response->success(
$bookings_o->asArray()
@@ -68,7 +72,7 @@ class bookingsRoute
},
$bookings_o->forceRestrictFilters(
[
'department' => $user->getGroup()->getDepartments(),
'department' => $this->effectiveDepartmentIds($user),
]
)
))
@@ -65,7 +65,7 @@ class departmentDailyReportsRoute
$department_daily_reports_o->forceRestrictFilters(
[
// This makes sure that the user can only see orders from the departments they explicitly have access to
'department_id' => $user->getGroup()->getDepartments(),
'department_id' => $this->effectiveDepartmentIds($user),
]
)
)
@@ -520,7 +520,7 @@ class departmentDailyReportsRoute
->listObjectsWithPaginationIfSet(
fn (array $complaint): array => $repository->parseComplaint($complaint),
$repository->forceRestrictFilters([
'department_id' => $user->getGroup()->getDepartments(),
'department_id' => $this->effectiveDepartmentIds($user),
])
)
);
@@ -56,7 +56,7 @@ class departmentTimeBookingsRoute
$department_time_bookings_opening_hours->forceRestrictFilters(
[
// This makes sure that the user can only see orders from the departments they explicitly have access to
'department' => ($specific_department ?? $user->getGroup()->getDepartments()),
'department' => ($specific_department ?? $this->effectiveDepartmentIds($user)),
]
)
);
@@ -151,7 +151,7 @@ class departmentTimeBookingsRoute
$department_time_bookings_types->forceRestrictFilters(
[
// This makes sure that the user can only see orders from the departments they explicitly have access to
'department' => ($specific_department ?? $user->getGroup()->getDepartments()),
'department' => ($specific_department ?? $this->effectiveDepartmentIds($user)),
]
)
);
@@ -306,7 +306,7 @@ class departmentTimeBookingsRoute
$department_time_bookings_entries->forceRestrictFilters(
[
// This makes sure that the user can only see orders from the departments they explicitly have access to
'department' => ($specific_department ?? $user->getGroup()->getDepartments()),
'department' => ($specific_department ?? $this->effectiveDepartmentIds($user)),
]
)
);
@@ -433,4 +433,4 @@ class departmentTimeBookingsRoute
]
);
}
}
}
+17 -3
View File
@@ -20,7 +20,11 @@ class departmentsRoute
{
use route_t;
private function buildDepartmentListFilters(departments_o $departments, bool $canListArchived): array
private function buildDepartmentListFilters(
departments_o $departments,
bool $canListArchived,
?array $departmentScope = null
): array
{
global $response;
@@ -43,6 +47,9 @@ class departmentsRoute
unset($filters['visible'], $filters['archived']);
$filters['visible'] = 1;
$filters['archived'] = $archived;
if ($departmentScope !== null) {
$filters['id'] = array_values(array_map('intval', $departmentScope));
}
return $filters;
}
@@ -83,17 +90,23 @@ class departmentsRoute
$user = (new authentication())->get_user();
// Check if the request was successful
if ($user) {
$departmentScope = $this->limitedBackofficeDepartmentScope($user);
// Log the incident
(new logs_o())->add('departments', 'global', 1, $user->id, 'LIST_DEPARTMENTS', 'Successfully listed departments');
// Check if the id is set in the request
if (self::isParametersSet(['id'])) {
$departmentId = (int)self::getParameter('id');
$this->requireLimitedBackofficeDepartmentAccess($user, $departmentId);
// Return the department
$response->success(
(new departments_o())->select((int)self::getParameter('id'))->asArray([
(new departments_o())->select($departmentId)->asArray([
'slack_webhook' => $user->hasPermission('view_slack_webhook')
])
);
}
if ($departmentScope === []) {
$response->success([]);
}
$departments_o = new departments_o();
// Return the list of departments
$response->success(
@@ -141,7 +154,8 @@ class departmentsRoute
},
$this->buildDepartmentListFilters(
$departments_o,
$user->hasPermission('superuser_fetch_department')
$user->hasPermission('superuser_fetch_department'),
$departmentScope
)
)
);
@@ -118,15 +118,12 @@ class limitedBackofficeRoute
]);
$this->post('/limited-backoffice/employees/{employeeId}/login-link', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
return $service->createEmployeeLoginLink($user, $this->routePositiveInt('employeeId'));
});
}, [
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
]);
global $response;
$response->error(
'Reusable employee login links are retired. Create a short-lived one-time login grant instead.',
410
);
});
$this->post('/limited-backoffice/employees/{employeeId}/login-grants', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
@@ -110,6 +110,10 @@ class moduleStripeRoute
$order = (new orders_o())->select((int)self::fromRequest('order_id'));
$order->requireSelected();
$this->requireLimitedBackofficeDepartmentAccess(
$user,
(int)$order->department_id->value()
);
if (!$order->stripe_module_orders->exists()) {
$response->success([
@@ -142,7 +142,7 @@ class orderBookingRoute
$effectiveCustomer = self::resolveEffectiveCustomerNumber();
$forcedFilters = $object->forceRestrictFilters([
...($has_permission_other && $user !== false ? [
'department' => $user->getGroup()->getDepartments()
'department' => $this->effectiveDepartmentIds($user)
] : []),
...(!$has_permission_other && $effectiveCustomer !== null ? [
'customer_number' => [(int)$effectiveCustomer]
@@ -214,7 +214,7 @@ class orderBookingRoute
if ($requestedDepartmentId !== null) {
$departmentIds = [$requestedDepartmentId];
} elseif ($hasPermissionOther && $user !== false) {
$departmentIds = array_map('intval', $user->getGroup()->getDepartments());
$departmentIds = $this->effectiveDepartmentIds($user);
if ($departmentIds === []) {
$response->success([
'past' => 0,
+13 -2
View File
@@ -111,11 +111,14 @@ class ordersRoute
// Build department filter when listing as department/admin
$department_ids = [];
if ($has_permission_other) {
$department_ids = $user->getGroup()->getDepartments();
$department_ids = $this->effectiveDepartmentIds($user);
}
if (self::isParametersSet(['show_wash_subscription'])) {
if (self::getParameter('show_wash_subscription') === 'true') {
$department_ids[] = '10';
$managedScope = $this->limitedBackofficeDepartmentScope($user);
if ($managedScope === null || in_array(10, $department_ids, true)) {
$department_ids[] = 10;
}
}
}
@@ -418,6 +421,10 @@ class ordersRoute
if (!$order->exists()) {
$response->error('Order not found', 400);
}
$this->requireLimitedBackofficeDepartmentAccess(
$user,
(int)$order->department_id->value()
);
// Permissions (subuser-aware)
$permission_own = self::definePermission('list_own_order_attachments', subusers_permission_node_key::ORDERS_LIST);
$permission_other = self::definePermission('list_order_attachments');
@@ -2019,6 +2026,10 @@ class ordersRoute
if (!$order->exists()) {
$response->error('Order not found', 404);
}
$this->requireLimitedBackofficeDepartmentAccess(
$user,
(int)$order->department_id->value()
);
if (!$hasPermissionOther) {
$effectiveCustomer = self::resolveEffectiveCustomerNumber();
if ($effectiveCustomer === null || (int)$order->customer_id->value() !== (int)$effectiveCustomer) {
@@ -129,7 +129,7 @@ class plateScansRoute
},
$number_plate_scans->forceRestrictFilters(
[
'department_id' => $user->getGroup()->getDepartments()
'department_id' => $this->effectiveDepartmentIds($user)
]
)
);
@@ -174,4 +174,4 @@ class plateScansRoute
'add_number_plate_scan' => 'Add a number plate scan'
]);
}
}
}
@@ -63,7 +63,7 @@ class potentialOrderMatchesRoute
$potentialOrderMatches->forceRestrictFilters(
[
// This makes sure that the user can only see department matches that belong to their departments
'department' => $user->getGroup()->getDepartments()
'department' => $this->effectiveDepartmentIds($user)
]
)
)
@@ -127,4 +127,4 @@ class potentialOrderMatchesRoute
]
);
}
}
}
@@ -26,6 +26,7 @@ class statisticsRoute
$user = (new authentication())->get_user();
// Check if the request was successful
if ($user) {
$departmentScope = $this->limitedBackofficeDepartmentScope($user);
// Log the incident
(new logs_o())->add('statistics', 'global', 1, $user->id, 'NEW_BOOKINGS', 'User requested new bookings');
$statics = new statistics();
@@ -33,7 +34,7 @@ class statisticsRoute
$response->success(
$statics
->bookings()
->get_new_bookings()
->get_new_bookings($departmentScope)
->get_response()
);
} else {
@@ -60,6 +61,7 @@ class statisticsRoute
$user = (new authentication())->get_user();
// Check if the request was successful
if ($user) {
$departmentScope = $this->limitedBackofficeDepartmentScope($user);
// Log the incident
(new logs_o())->add('statistics', 'global', 1, $user->id, 'NEW_ORDERS', 'User requested new orders');
$statics = new statistics();
@@ -67,7 +69,7 @@ class statisticsRoute
$response->success(
$statics
->orders()
->get_new_orders()
->get_new_orders($departmentScope)
->get_response()
);
} else {
@@ -395,4 +397,4 @@ class statisticsRoute
]
);
}
}
}
+210 -156
View File
@@ -741,25 +741,36 @@ class subusersRoute
return $remoteAddress !== '' ? $remoteAddress : 'unknown';
}
private function recordThrottleAttempt(string $scope, string $identifier, int $limit, int $windowSeconds): ?string
{
private function recordThrottleAttempt(
string $scope,
string $identifier,
int $limit,
int $windowSeconds,
bool $includeClientIp = true
): ?string {
global $response;
if (!defined('redis')) {
return null;
if (PHP_SAPI === 'cli') {
return null;
}
$response->error('Request protection is temporarily unavailable.', 503);
}
$safeScope = preg_replace('/[^a-z0-9:_-]/i', '_', $scope);
$key = 'subusers_route_throttle:' . $safeScope . ':' . hash(
'sha256',
$this->clientThrottleIp() . ':' . $identifier
($includeClientIp ? $this->clientThrottleIp() . ':' : '') . $identifier
);
$redis = constant('redis');
$attempts = (int)($redis->get($key) ?? '0');
if ($attempts >= $limit) {
try {
$attempts = constant('redis')->incrementWithExpiration($key, $windowSeconds);
} catch (\Throwable $exception) {
error_log('[subusers] throttle unavailable: ' . $exception->getMessage());
$response->error('Request protection is temporarily unavailable.', 503);
}
if ($attempts > $limit) {
$response->error('Too many attempts. Please wait and try again.', 429);
}
$redis->setEx($key, (string)($attempts + 1), $windowSeconds);
return $key;
}
@@ -835,6 +846,45 @@ class subusersRoute
];
}
private function publicRegistrationPendingKey(string $setupToken): string
{
return 'subuser_public_registration_pending:' . hash('sha256', $setupToken);
}
private function storePublicRegistrationPending(string $setupToken, int $customerNumber): void
{
global $response;
if (!defined('redis')) {
$response->error('Driver registration is temporarily unavailable.', 503);
}
constant('redis')->setEx(
$this->publicRegistrationPendingKey($setupToken),
json_encode(['customer_number' => $customerNumber], JSON_THROW_ON_ERROR),
48 * 60 * 60
);
}
private function getPublicRegistrationPending(string $setupToken): ?array
{
if (!defined('redis')) {
return null;
}
$raw = constant('redis')->get($this->publicRegistrationPendingKey($setupToken));
if (!is_string($raw) || $raw === '') {
return null;
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : null;
}
private function clearPublicRegistrationPending(string $setupToken): void
{
if (defined('redis')) {
constant('redis')->delete($this->publicRegistrationPendingKey($setupToken));
}
}
private function deliverSms(?string $destination, string $message): array
{
if ($destination === null || trim($destination) === '') {
@@ -1730,6 +1780,124 @@ class subusersRoute
]);
}
private function registerPublicSubuser(): void
{
global $db, $response;
$this->requireRecaptcha();
self::requireParameters(['cvr', 'phone_country_code', 'phone']);
$cvr = (int)self::getParameter('cvr');
$phoneCountryCode = (int)self::getParameter('phone_country_code');
$phone = (int)self::getParameter('phone');
self::requireType($cvr, self::type_int());
self::requireMinLength('cvr', 8);
self::requireMaxLength('cvr', 8);
self::requireType($phoneCountryCode, self::type_int());
self::requireMinLength('phone_country_code', 1);
self::requireMaxLength('phone_country_code', 3);
self::requireType($phone, self::type_int());
self::requireMinLength('phone', 4);
self::requireMaxLength('phone', 15);
$this->recordThrottleAttempt(
'public_registration_ip',
'all',
5,
15 * 60
);
$this->recordThrottleAttempt(
'public_registration_identity',
'cvr:' . $cvr . ':phone:' . $phoneCountryCode . ':' . $phone,
3,
60 * 60,
false
);
$results = (new economic())->customers->customers->search([
'corporateIdentificationNumber' => (string)$cvr,
], [
'skipPages' => 0,
'pageSize' => 1000,
])->collection;
if (!is_array($results) || count($results) === 0) {
$response->error('Customer not found', 404);
}
$customerNumber = (int)($results[0]->customerNumber ?? 0);
if ($customerNumber <= 0) {
$response->error('Customer not found', 404);
}
$companyUser = (new users_o())->getUserByCustomerNumber($customerNumber);
$companyUser->requireSelected();
$lockName = 'subuser-public-registration:' . hash(
'sha256',
$phoneCountryCode . ':' . $phone
);
$lockStatement = $db->conn->prepare('SELECT GET_LOCK(?, 5) AS `acquired`');
if ($lockStatement === false) {
$response->error('Unable to start driver registration', 503);
}
$lockStatement->bind_param('s', $lockName);
$lockStatement->execute();
$lockRow = $lockStatement->get_result()->fetch_assoc() ?: [];
$lockStatement->close();
if ((int)($lockRow['acquired'] ?? 0) !== 1) {
$response->error('Driver registration is already being processed. Please try again.', 409);
}
$subuser = null;
try {
$db->conn->begin_transaction();
$subuser = (new subusers_o())->getSubuserByPhone($phoneCountryCode, $phone);
if ($subuser !== null) {
$this->rejectBlockedSubuser((int)$subuser->id);
} else {
$subuser = (new subusers_o())->add(
null,
null,
null,
null,
$phoneCountryCode,
$phone
);
}
$db->conn->commit();
} catch (\Throwable $exception) {
$db->conn->rollback();
error_log('[subusers] public registration failed: ' . $exception->getMessage());
$response->error('Failed to create driver registration', 500);
} finally {
$releaseStatement = $db->conn->prepare('SELECT RELEASE_LOCK(?)');
if ($releaseStatement !== false) {
$releaseStatement->bind_param('s', $lockName);
$releaseStatement->execute();
$releaseStatement->close();
}
}
if (!$subuser instanceof subusers_o) {
$response->error('Failed to create driver registration', 500);
}
if ($subuser->requiresSetup()) {
$issuedInvite = $this->issueSetupInvite($subuser);
if (($issuedInvite['delivery']['status'] ?? null) !== 'sent') {
$response->error('Driver registration was saved, but the setup SMS could not be sent. Please try again.', 503);
}
$setupToken = (string)($issuedInvite['setup_token'] ?? '');
if ($setupToken === '') {
$response->error('Driver registration is temporarily unavailable.', 503);
}
$this->storePublicRegistrationPending($setupToken, $customerNumber);
}
$response->success([
'message' => 'If the driver can be registered, setup instructions have been sent.',
]);
}
public function run(): void
{
subusers_schema_bootstrap::ensureTables();
@@ -2016,78 +2184,7 @@ class subusersRoute
]);
$this->post('/subusers', function () {
global /** @var response $response */
$response;
self::requireParameters([
'cvr',
'phone_country_code',
'phone'
]);
$cvr = (int)self::getParameter('cvr');
$phone_country_code =(int) self::getParameter('phone_country_code');
$phone = (int)self::getParameter('phone');
/** Validation */
// Check if the CVR number is valid (8 digits)
self::requireType($cvr, self::type_int());
self::requireMinLength('cvr', 8);
self::requireMaxLength('cvr', 8);
// Check if the phone country code is valid (1-3 digits)
self::requireType($phone_country_code, self::type_int());
self::requireMinLength('phone_country_code', 1);
self::requireMaxLength('phone_country_code', 3);
// Check if the phone number is valid (4-15 digits)
self::requireType($phone, self::type_int());
self::requireMinLength('phone', 4);
self::requireMaxLength('phone', 15);
// Check if the CVR is registered to a company in the database
$economic = new economic();
$results = $economic->customers->customers->search([
'corporateIdentificationNumber' => (string)$cvr,
],[
'skipPages' => 0,
'pageSize' => 1000, // Since the limit is 1000, we need to set the page size to 1000.
])->collection;
if (count($results) === 0) {
$response->error('Customer not found', 404);
}
/** At this point, we know that the CVR is valid and that the company exists in the database. */
$company_user = (new users_o())->getUserByCustomerNumber((int)$results[0]->customerNumber);
$company_user->requireSelected();
/** We can now check if a subuser already exists with the same phone number. */
$subuser = (new subusers_o())->getSubuserByPhone($phone_country_code, $phone);
if ($subuser !== null) {
$response->error('Account already exists with this phone number', 400);
}
/** We can now create the subuser and send a request to the company user to link the subuser to the company. */
$subuser = (new subusers_o())->add(
null,
null,
null,
null,
(int)$phone_country_code,
(int)$phone
);
$invite = $this->issueSetupInvite($subuser);
// Add the grant request
$subuser_grants_o = new subuser_grants_o();
try {
$grant = $subuser_grants_o->add($results[0]->customerNumber, $subuser->id, false, null);
} catch (Exception $e) {
$response->error('Failed to add subuser grant', 500);
}
$customerNotification = $this->notifyCustomerOfGrantRequest(
$grant,
$subuser,
(int)$results[0]->customerNumber
);
$response->success([
'cvr' => $cvr,
'customer_number' => $results[0]->customerNumber,
'invite' => $invite,
'customer_notification' => $customerNotification,
]);
// Code for creating a new subuser would go here
$this->registerPublicSubuser();
});
$this->get('/subusers/setup', function () {
// Require the user to be logged in
@@ -2158,8 +2255,40 @@ class subusersRoute
...(!empty($email) ? ['email' => $email] : []),
];
$subuser->update($this->verificationService()->clearVerificationForChangedContacts($subuser, $setupUpdates));
$pendingRegistration = $this->getPublicRegistrationPending($token);
if ($pendingRegistration !== null) {
$customerNumber = (int)($pendingRegistration['customer_number'] ?? 0);
if ($customerNumber <= 0) {
$response->error('Driver registration request is invalid.', 400);
}
$grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer(
(int)$subuser->id,
$customerNumber,
true
);
if ($grant === null) {
$grant = (new subuser_grants_o())->add(
$customerNumber,
(int)$subuser->id,
false,
null
);
}
$notification = $this->notifyCustomerOfGrantRequest(
$grant,
$subuser,
$customerNumber
);
if (($notification['status'] ?? null) !== 'sent') {
$response->error(
'Driver setup was saved, but the customer notification could not be sent. Please try again.',
503
);
}
}
// Invalidate the setup token
$subuser->invalidateSetupToken($token);
$this->clearPublicRegistrationPending($token);
$this->clearThrottleAttempt($setupThrottleKey);
$response->success(['message' => 'Complete registration successful']);
} catch (Exception $e) {
@@ -2916,82 +3045,7 @@ class subusersRoute
]);
// Public registration endpoint (alias of POST /subusers) matching OpenAPI: POST /subusers/me
$this->post('/subusers/me', function () {
global /** @var response $response */
$response;
// Required input
self::requireParameters([
'cvr',
'phone_country_code',
'phone'
]);
$cvr = (int)self::getParameter('cvr');
$phone_country_code = (int)self::getParameter('phone_country_code');
$phone = (int)self::getParameter('phone');
// Validation
self::requireType($cvr, self::type_int());
self::requireMinLength('cvr', 8);
self::requireMaxLength('cvr', 8);
self::requireType($phone_country_code, self::type_int());
self::requireMinLength('phone_country_code', 1);
self::requireMaxLength('phone_country_code', 3);
self::requireType($phone, self::type_int());
self::requireMinLength('phone', 4);
self::requireMaxLength('phone', 15);
// Look up company by CVR in e-conomic
$economic = new economic();
$results = $economic->customers->customers->search([
'corporateIdentificationNumber' => (string)$cvr,
], [
'skipPages' => 0,
'pageSize' => 1000,
])->collection;
if (count($results) === 0) {
$response->error('Customer not found', 404);
}
// Ensure company user exists (sanity) and phone is not already registered
$company_user = (new users_o())->getUserByCustomerNumber((int)$results[0]->customerNumber);
$company_user->requireSelected();
$existing = (new subusers_o())->getSubuserByPhone($phone_country_code, $phone);
if ($existing !== null) {
$response->error('Account already exists with this phone number', 400);
}
// Create subuser skeleton
$subuser = (new subusers_o())->add(
null,
null,
null,
null,
(int)$phone_country_code,
(int)$phone
);
$invite = $this->issueSetupInvite($subuser);
// Create a pending grant request for the company
$subuser_grants_o = new subuser_grants_o();
try {
$grant = $subuser_grants_o->add($results[0]->customerNumber, $subuser->id, false, null);
} catch (Exception $e) {
$response->error('Failed to add subuser grant', 500);
}
$customerNotification = $this->notifyCustomerOfGrantRequest(
$grant,
$subuser,
(int)$results[0]->customerNumber
);
$response->success([
'cvr' => $cvr,
'customer_number' => $results[0]->customerNumber,
'invite' => $invite,
'customer_notification' => $customerNotification,
]);
$this->registerPublicSubuser();
});
}
}
@@ -511,11 +511,7 @@ class systemSearchRoute
}
try {
$departments = $user->getGroup()->getDepartments();
if (!is_array($departments)) {
return [];
}
return array_values(array_unique(array_map('intval', $departments)));
return $this->effectiveDepartmentIds($user);
} catch (Throwable) {
return [];
}
@@ -21,6 +21,7 @@ class vehiclePlateLastOrdersRoute
$user = (new authentication())->get_user();
// Check if the request was successful
if ($user) {
$departmentScope = $this->limitedBackofficeDepartmentScope($user);
// Make sure the vehicle plate is set
if (!(string)$this->fromRequest('plate')) {
$response->error('Plate parameter is required', 400);
@@ -29,7 +30,11 @@ class vehiclePlateLastOrdersRoute
(new logs_o())->add('orders', 'global', 1, $user->id, 'FETCH_VEHICLE_ORDER_HISTORY', 'Successfully fetched vehicle order history');
// Return the list of departments
$response->success(
(new orders_o())->getOrderHistoryByVehiclePlate($this->fromRequest('plate'), 5)
(new orders_o())->getOrderHistoryByVehiclePlate(
$this->fromRequest('plate'),
5,
$departmentScope
)
);
} else {
// Log the incident
@@ -43,4 +48,4 @@ class vehiclePlateLastOrdersRoute
]
);
}
}
}
@@ -23,13 +23,17 @@ class vehiclePlateLookupRoute
$user = (new authentication())->get_user();
// Check if the request was successful
if ($user) {
$departmentScope = $this->limitedBackofficeDepartmentScope($user);
// Make sure the vehicle plate is set
if (!(string)$this->fromRequest('plate')) {
$response->error('Plate parameter is required', 400);
}
// Return the vehicle order history
$response->success(
(new orders_o())->get_vehicle_order_history($this->fromRequest('plate'))
(new orders_o())->get_vehicle_order_history(
$this->fromRequest('plate'),
$departmentScope
)
);
} else {
// Log the incident
@@ -51,6 +55,7 @@ class vehiclePlateLookupRoute
$user = (new authentication())->get_user();
// Check if the request was successful
if ($user) {
$departmentScope = $this->limitedBackofficeDepartmentScope($user);
// Make sure the vehicle plate is set
self::requireParameters(['plate', 'customer_number', 'column']);
self::requireType((string)self::getParameter('plate'), 'string');
@@ -65,6 +70,13 @@ class vehiclePlateLookupRoute
if (!in_array((string)self::getParameter('column'), $allowedColumns)) {
$response->error('Invalid column, allowed columns are: ' . implode(', ', $allowedColumns), 400);
}
if ($departmentScope === []) {
$response->success([]);
}
$filters = ['customer_id' => self::getParameter('customer_number')];
if ($departmentScope !== null) {
$filters['department_id'] = $departmentScope;
}
// Return the vehicle order history
$response->success(
(new orders_o())
@@ -73,7 +85,7 @@ class vehiclePlateLookupRoute
1,
10,
(string)self::getParameter('plate'),
['customer_id' => self::getParameter('customer_number')],
$filters,
['created_at' => 'DESC'],
function ($order) {
return [
@@ -94,4 +106,4 @@ class vehiclePlateLookupRoute
]
);
}
}
}
+17 -4
View File
@@ -8,7 +8,10 @@ class bookings_s
{
use statistic_t;
public function get_new_bookings(): self
/**
* @param array<int, int>|null $departmentIds Null keeps the existing unrestricted contract.
*/
public function get_new_bookings(?array $departmentIds = null): self
{
// Get the departments
$departments_o = new \objects\departments_o();
@@ -16,9 +19,19 @@ class bookings_s
$bookings_o = new \objects\bookings_o();
// Get the bookings
$bookings = $bookings_o->getFields(['id', 'department', 'date']);
$bookings = $departmentIds === null
? $bookings_o->getFields(['id', 'department', 'date'])
: ($departmentIds === [] ? [] : $bookings_o->getFieldsWhere(
['department' => $departmentIds],
['id', 'department', 'date']
));
// Get the departments
$departments = $departments_o->getFields(['id', 'name']);
$departments = $departmentIds === null
? $departments_o->getFields(['id', 'name'])
: ($departmentIds === [] ? [] : $departments_o->getFieldsWhere(
['id' => $departmentIds],
['id', 'name']
));
// For each year, add the months covered
$this->add_monthly_labels();
// Add the counts for each department, for each month in each year
@@ -40,4 +53,4 @@ class bookings_s
}
return $this;
}
}
}
+17 -4
View File
@@ -8,7 +8,10 @@ class orders_s
{
use statistic_t;
public function get_new_orders(): self
/**
* @param array<int, int>|null $departmentIds Null keeps the existing unrestricted contract.
*/
public function get_new_orders(?array $departmentIds = null): self
{
// Get the departments
$departments_o = new \objects\departments_o();
@@ -16,9 +19,19 @@ class orders_s
$orders_o = new \objects\orders_o();
// Get the orders
$orders = $orders_o->getFields(['id', 'department_id', 'created_at']);
$orders = $departmentIds === null
? $orders_o->getFields(['id', 'department_id', 'created_at'])
: ($departmentIds === [] ? [] : $orders_o->getFieldsWhere(
['department_id' => $departmentIds],
['id', 'department_id', 'created_at']
));
// Get the departments
$departments = $departments_o->getFields(['id', 'name']);
$departments = $departmentIds === null
? $departments_o->getFields(['id', 'name'])
: ($departmentIds === [] ? [] : $departments_o->getFieldsWhere(
['id' => $departmentIds],
['id', 'name']
));
// For each year, add the months covered
$this->add_monthly_labels();
// Add the counts for each department, for each month in each year
@@ -40,4 +53,4 @@ class orders_s
}
return $this;
}
}
}
@@ -93,6 +93,16 @@ it('sets and applies department-specific customer discounts without legacy fallb
'department_id' => $fixture['department']['id'],
'user_id' => $fixture['customer']['id'],
'overrides' => [
[
'is_category' => true,
'product_or_category_id' => (string)$fixture['category']['id'],
'discount' => 60,
],
[
'is_category' => true,
'product_or_category_id' => 'global',
'discount' => 80,
],
[
'is_category' => false,
'product_or_category_id' => $fixture['product']['id'],
@@ -107,7 +117,12 @@ it('sets and applies department-specific customer discounts without legacy fallb
->assertEnvelope()
->assertSuccess();
expect($updated->data()['overrides'][0]['percentage'] ?? null)->toBe(25);
$productOverrides = array_values(array_filter(
$updated->data()['overrides'],
static fn(array $override): bool => $override['is_category'] === false
));
expect($productOverrides)->toHaveCount(1);
expect($productOverrides[0]['percentage'] ?? null)->toBe(25);
expect($updated->data()['categories'][0]['products'][0]['effective_price'] ?? null)->toBe(750);
$byCustomerNumber = api_client()->get(
@@ -136,6 +151,124 @@ it('sets and applies department-specific customer discounts without legacy fallb
expect((int)($productResponse->data()['price'] ?? 0))->toBe(750);
});
it('lets only the first writer replace customer pricing for a shared revision', function (): void {
api_test_covers('GET /limited-backoffice/departments/{departmentId}/customer-pricing', 'revision');
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/customer-pricing', 'revision conflict');
$fixture = department_customer_pricing_setup();
$session = api_fixtures()->createUserSession([
limited_backoffice_service::PERMISSION_ACCESS,
limited_backoffice_service::PERMISSION_VIEW_CUSTOMER_PRICING,
limited_backoffice_service::PERMISSION_MANAGE_CUSTOMER_PRICING,
'department_access_' . (int)$fixture['department']['id'],
]);
$path = '/limited-backoffice/departments/' . (int)$fixture['department']['id'] . '/customer-pricing';
$query = '?user_id=' . (int)$fixture['customer']['id'];
$initial = api_client()->get($path . $query, $session['headers']);
$initial->assertStatus(200)->assertEnvelope()->assertSuccess();
$sharedRevision = $initial->data()['revision'] ?? null;
expect($sharedRevision)->toBeString()->toMatch('/^[a-f0-9]{64}$/');
$winner = api_client()->put($path, [
'user_id' => (int)$fixture['customer']['id'],
'expected_revision' => $sharedRevision,
'overrides' => [[
'is_category' => false,
'product_or_category_id' => (int)$fixture['product']['id'],
'discount' => 20,
]],
], $session['headers']);
$winner->assertStatus(200)->assertEnvelope()->assertSuccess();
$winningRevision = $winner->data()['revision'] ?? null;
expect($winningRevision)->toBeString()->not->toBe($sharedRevision);
$stale = api_client()->put($path, [
'user_id' => (int)$fixture['customer']['id'],
'expected_revision' => $sharedRevision,
'overrides' => [[
'is_category' => false,
'product_or_category_id' => (int)$fixture['product']['id'],
'discount' => 70,
]],
], $session['headers']);
$stale
->assertStatus(409)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Pricing has changed. Reload and try again.');
expect($stale->data()['code'] ?? null)->toBe('pricing_revision_conflict');
expect($stale->data()['current_revision'] ?? null)->toBe($winningRevision);
$reloaded = api_client()->get($path . $query, $session['headers']);
$reloaded->assertStatus(200)->assertEnvelope()->assertSuccess();
expect($reloaded->data()['revision'] ?? null)->toBe($winningRevision);
expect($reloaded->data()['overrides'])->toHaveCount(1);
expect($reloaded->data()['overrides'][0]['percentage'] ?? null)->toBe(20);
expect($reloaded->data()['categories'][0]['products'][0]['effective_price'] ?? null)->toBe(800);
});
it('rejects ambiguous and duplicate customer price overrides without changing saved pricing', function (): void {
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/customer-pricing', 'validation');
$fixture = department_customer_pricing_setup();
$session = api_fixtures()->createUserSession([
limited_backoffice_service::PERMISSION_ACCESS,
limited_backoffice_service::PERMISSION_VIEW_CUSTOMER_PRICING,
limited_backoffice_service::PERMISSION_MANAGE_CUSTOMER_PRICING,
'department_access_' . (int)$fixture['department']['id'],
]);
$path = '/limited-backoffice/departments/' . (int)$fixture['department']['id'] . '/customer-pricing';
$initial = api_client()->get(
$path . '?user_id=' . (int)$fixture['customer']['id'],
$session['headers']
);
$revision = $initial->data()['revision'];
foreach ([
[[
'is_category' => false,
'product_or_category_id' => (int)$fixture['product']['id'],
'discount' => 10,
'fixed_price' => 500,
]],
[[
'is_category' => true,
'product_or_category_id' => (string)$fixture['category']['id'],
'discount' => 10,
'fixed_price' => 500,
]],
[
[
'is_category' => false,
'product_or_category_id' => (int)$fixture['product']['id'],
'discount' => 10,
],
[
'is_category' => false,
'product_or_category_id' => (int)$fixture['product']['id'],
'discount' => 20,
],
],
] as $overrides) {
api_client()->put($path, [
'user_id' => (int)$fixture['customer']['id'],
'expected_revision' => $revision,
'overrides' => $overrides,
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false);
}
$unchanged = api_client()->get(
$path . '?user_id=' . (int)$fixture['customer']['id'],
$session['headers']
);
expect($unchanged->data()['revision'] ?? null)->toBe($revision);
expect($unchanged->data()['overrides'])->toBe([]);
});
it('limits department customer pricing to assigned limited-backoffice departments', function (): void {
api_test_covers('GET /limited-backoffice/departments/{departmentId}/customer-pricing', 'auth');
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/customer-pricing', 'auth');
@@ -22,6 +22,46 @@ function limited_backoffice_manager_session(array $departmentIds, array $extraPe
return api_fixtures()->createUserSession(array_values(array_unique(array_merge($permissions, $extraPermissions))));
}
function limited_backoffice_managed_employee_session(
array $assignedDepartmentIds,
array $permissions,
array $extraRawDepartmentIds = []
): array {
\classes\limited_backoffice_schema_bootstrap::ensureTables();
$rawDepartmentIds = array_values(array_unique(array_map(
'intval',
array_merge($assignedDepartmentIds, $extraRawDepartmentIds)
)));
foreach ($rawDepartmentIds as $departmentId) {
$permissions[] = 'department_access_' . $departmentId;
}
$session = api_fixtures()->createUserSession(
array_values(array_unique($permissions)),
['customer_number' => 0]
);
$userId = (int)$session['user']['id'];
$groupId = (int)$session['user']['group_id'];
$departmentIdsJson = json_encode(
array_values(array_unique(array_map('intval', $assignedDepartmentIds))),
JSON_THROW_ON_ERROR
);
$roleKey = 'cashier';
$createdByUserId = 1;
$statement = api_test_runtime()->db()->prepare(
'INSERT INTO `limited_backoffice_employees`
(`user_id`, `managed_group_id`, `role_key`, `department_ids`, `created_by_user_id`)
VALUES (?, ?, ?, ?, ?)'
);
$statement->bind_param('iissi', $userId, $groupId, $roleKey, $departmentIdsJson, $createdByUserId);
$statement->execute();
$statement->close();
api_fixtures()->cleanupDeleteWhere('limited_backoffice_employees', ['user_id' => $userId]);
return $session;
}
function limited_backoffice_all_role_permissions(): array
{
return [
@@ -263,6 +303,62 @@ it('lists and updates explicit prices only for assigned departments', function (
expect($updated->data()['categories'][0]['products'][0]['price'] ?? null)->toBe(2222);
});
it('lets only the first writer replace department prices for a shared revision', function (): void {
api_test_covers('GET /limited-backoffice/departments/{departmentId}/prices', 'revision');
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'revision conflict');
$department = api_fixtures()->createDepartment(['name' => 'Limited Concurrent Prices']);
$category = api_fixtures()->createCategory(['name' => 'Limited Concurrent Price Category']);
$product = api_fixtures()->createProduct(['category' => $category['id']]);
api_fixtures()->linkDepartmentCategory((int)$department['id'], (int)$category['id']);
limited_backoffice_price_insert((int)$department['id'], (int)$product['id'], 100);
$session = limited_backoffice_manager_session([(int)$department['id']]);
$path = '/limited-backoffice/departments/' . (int)$department['id'] . '/prices';
$initial = api_client()->get($path, $session['headers']);
$initial->assertStatus(200)->assertEnvelope()->assertSuccess();
$sharedRevision = $initial->data()['revision'] ?? null;
expect($sharedRevision)->toBeString()->toMatch('/^[a-f0-9]{64}$/');
$winner = api_client()->put($path, [
'expected_revision' => $sharedRevision,
'prices' => [
['product_id' => (int)$product['id'], 'price' => 200],
],
], $session['headers']);
$winner->assertStatus(200)->assertEnvelope()->assertSuccess();
$winningRevision = $winner->data()['revision'] ?? null;
expect($winningRevision)->toBeString()->not->toBe($sharedRevision);
$stale = api_client()->put($path, [
'expected_revision' => $sharedRevision,
'prices' => [
['product_id' => (int)$product['id'], 'price' => 300],
],
], $session['headers']);
$stale
->assertStatus(409)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Pricing has changed. Reload and try again.');
expect($stale->data()['code'] ?? null)->toBe('pricing_revision_conflict');
expect($stale->data()['current_revision'] ?? null)->toBe($winningRevision);
expect(limited_backoffice_price_value((int)$department['id'], (int)$product['id']))->toBe(200);
expect(limited_backoffice_price_rows((int)$department['id'], (int)$product['id']))->toHaveCount(1);
api_client()->put($path, [
'expected_revision' => 'not-a-revision',
'prices' => [
['product_id' => (int)$product['id'], 'price' => 400],
],
], $session['headers'])
->assertStatus(400)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Expected revision is invalid.');
expect(limited_backoffice_price_value((int)$department['id'], (int)$product['id']))->toBe(200);
});
it('returns saved prices and collapses legacy duplicate department price rows', function (): void {
api_test_covers('PUT /limited-backoffice/departments/{departmentId}/prices', 'legacy duplicates');
@@ -1181,62 +1277,14 @@ it('caps manager-gated limited employee permissions while keeping baseline role
->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');
it('retires reusable QR login links in favor of one-time grants', function (): void {
api_test_covers('POST /limited-backoffice/employees/{employeeId}/login-link', 'retired');
$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)
api_client()->post('/limited-backoffice/employees/1/login-link')
->assertStatus(410)
->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');
->assertSuccess(false)
->assertMessage('Reusable employee login links are retired. Create a short-lived one-time login grant instead.');
});
it('creates, exchanges once, idempotently guards, and revokes scoped employee login grants', function (): void {
@@ -1408,120 +1456,6 @@ it('creates, exchanges once, idempotently guards, and revokes scoped employee lo
->assertSuccess(false);
});
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');
@@ -2389,3 +2323,166 @@ it('enforces department access when editing order items via PUT /order/items', f
->assertEnvelope()
->assertSuccess();
});
it('uses managed assignments as the authoritative scope even when raw department permissions drift', function (): void {
api_test_covers('GET /departments', 'managed scope');
$allowedDepartment = api_fixtures()->createDepartment(['name' => 'Managed Scope Allowed']);
$deniedDepartment = api_fixtures()->createDepartment(['name' => 'Managed Scope Denied']);
$session = limited_backoffice_managed_employee_session(
[(int)$allowedDepartment['id']],
['list_departments'],
[(int)$deniedDepartment['id']]
);
$listed = api_client()->get('/departments', $session['headers']);
$listed->assertStatus(200)->assertEnvelope()->assertSuccess();
$departmentIds = array_map('intval', array_column($listed->data(), 'id'));
expect($departmentIds)
->toContain((int)$allowedDepartment['id'])
->not->toContain((int)$deniedDepartment['id']);
api_client()->get('/departments?id=' . (int)$deniedDepartment['id'], $session['headers'])
->assertStatus(404)
->assertEnvelope()
->assertSuccess(false);
});
it('denies every attachment and legacy Stripe action outside the managed department assignment', function (): void {
api_test_covers('GET /orders/attachments', 'managed scope');
api_test_covers('POST /orders/attachments/upload', 'managed scope');
api_test_covers('DELETE /orders/attachments', 'managed scope');
api_test_covers('GET /orders/attachments/download', 'managed scope');
api_test_covers('DELETE /modules/stripe/invoice', 'managed scope');
$allowedDepartment = api_fixtures()->createDepartment(['name' => 'Managed Action Allowed']);
$deniedDepartment = api_fixtures()->createDepartment(['name' => 'Managed Action Denied']);
$customer = api_fixtures()->createUser(['display_name' => 'Managed Action Customer']);
$allowedOrder = api_fixtures()->createOrder([
'customer_id' => (int)$customer['customer_number'],
'department_id' => (int)$allowedDepartment['id'],
]);
$deniedOrder = api_fixtures()->createOrder([
'customer_id' => (int)$customer['customer_number'],
'department_id' => (int)$deniedDepartment['id'],
]);
$deniedAttachment = api_fixtures()->createOrderAttachment([
'order_id' => (int)$deniedOrder['id'],
]);
$session = limited_backoffice_managed_employee_session(
[(int)$allowedDepartment['id']],
[
'list_order_attachments',
'add_order_attachments',
'delete_order_attachments',
'download_order_attachments',
'modules_stripe_invoice_send',
],
[(int)$deniedDepartment['id']]
);
api_client()->get('/orders/attachments?id=' . (int)$allowedOrder['id'], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
api_client()->delete('/modules/stripe/invoice', ['order_id' => (int)$allowedOrder['id']], $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
api_client()->get('/orders/attachments?id=' . (int)$deniedOrder['id'], $session['headers'])
->assertStatus(404)
->assertEnvelope()
->assertSuccess(false);
api_client()->post('/orders/attachments/upload', [
'order_id' => (int)$deniedOrder['id'],
'base64_file' => base64_encode('denied'),
'file_name' => 'denied.txt',
], $session['headers'])
->assertStatus(404)
->assertEnvelope()
->assertSuccess(false);
api_client()->delete('/orders/attachments', [
'order_id' => (int)$deniedOrder['id'],
'attachment_id' => (int)$deniedAttachment['id'],
], $session['headers'])
->assertStatus(404)
->assertEnvelope()
->assertSuccess(false);
api_client()->get(
'/orders/attachments/download?order_id=' . (int)$deniedOrder['id']
. '&attachment_id=' . (int)$deniedAttachment['id'],
$session['headers']
)
->assertStatus(404)
->assertEnvelope()
->assertSuccess(false);
api_client()->delete('/modules/stripe/invoice', ['order_id' => (int)$deniedOrder['id']], $session['headers'])
->assertStatus(404)
->assertEnvelope()
->assertSuccess(false);
});
it('filters dashboard statistics and vehicle history to managed department assignments', function (): void {
api_test_covers('GET /statistics/orders/new', 'managed scope');
api_test_covers('GET /statistics/bookings/new', 'managed scope');
api_test_covers('GET /department/license-plate/lookup', 'managed scope');
api_test_covers('GET /department/vehicle/order/history', 'managed scope');
$allowedDepartmentName = 'Managed Dashboard Allowed';
$deniedDepartmentName = 'Managed Dashboard Denied';
$allowedDepartment = api_fixtures()->createDepartment(['name' => $allowedDepartmentName]);
$deniedDepartment = api_fixtures()->createDepartment(['name' => $deniedDepartmentName]);
$customer = api_fixtures()->createUser(['display_name' => 'Managed Dashboard Customer']);
$plate = 'SCOPE' . ((int)$customer['customer_number'] % 1000);
api_fixtures()->createOrder([
'customer_id' => (int)$customer['customer_number'],
'department_id' => (int)$allowedDepartment['id'],
'reg_1' => $plate,
]);
api_fixtures()->createOrder([
'customer_id' => (int)$customer['customer_number'],
'department_id' => (int)$deniedDepartment['id'],
'reg_1' => $plate,
]);
api_fixtures()->createLegacyBooking([
'customer_number' => (int)$customer['customer_number'],
'department' => (int)$allowedDepartment['id'],
'regNrTraekker' => $plate,
]);
api_fixtures()->createLegacyBooking([
'customer_number' => (int)$customer['customer_number'],
'department' => (int)$deniedDepartment['id'],
'regNrTraekker' => $plate,
]);
$session = limited_backoffice_managed_employee_session(
[(int)$allowedDepartment['id']],
[
'statistics_orders_new',
'statistics_bookings_new',
'department_license_plate_lookup',
'department_vehicle_order_last_five',
],
[(int)$deniedDepartment['id']]
);
foreach (['/statistics/orders/new', '/statistics/bookings/new'] as $path) {
$response = api_client()->get($path, $session['headers']);
$response->assertStatus(200)->assertEnvelope()->assertSuccess();
$labels = array_column($response->data()['datasets'] ?? [], 'label');
expect($labels)
->toContain($allowedDepartmentName)
->not->toContain($deniedDepartmentName);
}
foreach (['/department/license-plate/lookup', '/department/vehicle/order/history'] as $path) {
$response = api_client()->get($path . '?plate=' . rawurlencode($plate), $session['headers']);
$response->assertStatus(200)->assertEnvelope()->assertSuccess();
expect($response->data())->not->toBeEmpty();
$departmentIds = array_values(array_unique(array_map(
static fn(array $order): int => (int)($order['department_id'] ?? 0),
$response->data()
)));
expect($departmentIds)->toBe([(int)$allowedDepartment['id']]);
}
});
@@ -0,0 +1,18 @@
<?php
it('applies separate IP and identity throttles to public customer registration', function (): void {
$code = preg_replace(
'/\s+/',
' ',
(string)file_get_contents(app_path('routes/authRoute.php'))
);
expect($code)->toContain("\$this->post('/auth/register/cvr', function () {");
expect($code)->toContain('$this->requireRecaptcha();');
expect($code)->toContain("requirePublicRegistrationRateLimit('customer_ip', 'all')");
expect($code)->toContain("'customer_identity', 'cvr:' . (string)\$cvr . ':phone:' . \$companyPhone, 3, 60 * 60, false");
expect($code)->toContain("'enabled' => defined('redis')");
expect($code)->toContain('incrementWithExpiration($key, $windowSeconds)');
expect($code)->toContain("Registration protection is temporarily unavailable.");
expect($code)->not->toContain("\$attempts = (int)(\$redis->get(\$key)");
});
@@ -13,7 +13,7 @@ it('wires complaint create, lookup, list, edit, and delete routes with validatio
expect($content)->toContain("hasPermission('edit_department_daily_report_complaints')");
expect($content)->toContain("requireDepartmentAccess((int)self::getParameter('department_id'))");
expect($content)->toContain('requireDepartmentAccess((int)$complaint->department_id->value())');
expect($content)->toContain("'department_id' => \$user->getGroup()->getDepartments()");
expect($content)->toContain("'department_id' => \$this->effectiveDepartmentIds(\$user)");
expect($content)->toContain("getOrImportCustomerByCustomerNumber");
expect($content)->toContain("Search must be at least 2 characters");
expect($content)->toContain("Failed to fetch complaint customers from e-conomic");
@@ -0,0 +1,32 @@
<?php
it('uses authoritative managed scope across every limited-role list surface', function (): void {
$routes = [
'ordersRoute.php',
'bookingsRoute.php',
'orderBookingRoute.php',
'departmentTimeBookingsRoute.php',
'departmentDailyReportsRoute.php',
'plateScansRoute.php',
'potentialOrderMatchesRoute.php',
'systemSearchRoute.php',
];
foreach ($routes as $route) {
$code = (string)file_get_contents(app_path('routes/' . $route));
expect($code, $route)->toContain('effectiveDepartmentIds(');
expect($code, $route)->not->toContain('getGroup()->getDepartments()');
}
$trait = (string)file_get_contents(app_path('traits/route_t.php'));
expect($trait)->toContain('public function effectiveDepartmentIds(object $user): array');
expect($trait)->toContain('$managedScope = $this->limitedBackofficeDepartmentScope($user);');
expect($trait)->toContain('return $departmentIds === [] ? [0] : $departmentIds;');
$orders = (string)file_get_contents(app_path('routes/ordersRoute.php'));
expect($orders)->toContain('$managedScope === null || in_array(10, $department_ids, true)');
$bookings = (string)file_get_contents(app_path('routes/bookingsRoute.php'));
expect($bookings)->toContain('$this->requireLimitedBackofficeDepartmentAccess(');
expect($bookings)->toContain('(int)$bookings_o->department->value()');
});
@@ -0,0 +1,69 @@
<?php
function public_subuser_registration_method(): string
{
$code = (string)file_get_contents(app_path('routes/subusersRoute.php'));
$start = strpos($code, 'private function registerPublicSubuser(): void');
$end = strpos($code, 'public function run(): void', $start === false ? 0 : $start);
if ($start === false || $end === false || $end <= $start) {
throw new RuntimeException('Unable to locate the public subuser registration handler.');
}
return substr($code, $start, $end - $start);
}
it('routes both public driver registration aliases through one canonical handler', function (): void {
$code = (string)file_get_contents(app_path('routes/subusersRoute.php'));
$normalized = preg_replace('/\s+/', ' ', $code);
expect($normalized)->toContain("\$this->post('/subusers', function () { \$this->registerPublicSubuser(); });");
expect($normalized)->toContain("\$this->post('/subusers/me', function () { \$this->registerPublicSubuser(); });");
expect(substr_count($normalized, '$this->registerPublicSubuser();'))->toBe(2);
});
it('requires abuse controls and serializes idempotent driver registration writes', function (): void {
$method = preg_replace('/\s+/', ' ', public_subuser_registration_method());
expect($method)->toContain('$this->requireRecaptcha();');
expect($method)->toContain("'public_registration_ip'");
expect($method)->toContain("'public_registration_identity'");
expect($method)->toContain("3, 60 * 60, false");
expect($method)->toContain('SELECT GET_LOCK(?, 5)');
expect($method)->toContain('$db->conn->begin_transaction();');
expect($method)->toContain('$db->conn->commit();');
expect($method)->toContain('SELECT RELEASE_LOCK(?)');
expect(strpos($method, '$db->conn->commit();'))->toBeLessThan(strpos($method, '$this->issueSetupInvite($subuser);'));
expect($method)->toContain('$this->storePublicRegistrationPending($setupToken, $customerNumber);');
expect($method)->not->toContain('(new subuser_grants_o())->add(');
});
it('never returns setup credentials from either public driver registration alias', function (): void {
$method = public_subuser_registration_method();
expect($method)->not->toContain("'setup_link'");
expect($method)->toContain("'message' => 'If the driver can be registered, setup instructions have been sent.'");
expect($method)->not->toContain("'customer_number'");
expect($method)->not->toContain("'already_registered'");
$openApi = (string)file_get_contents(app_path('openapi.yaml'));
$start = strpos($openApi, ' /subusers/me:');
$end = strpos($openApi, ' /subusers/me/verification:', $start === false ? 0 : $start);
$operation = $start === false || $end === false ? '' : substr($openApi, $start, $end - $start);
expect($operation)->toContain('- g_recaptcha_response');
expect($operation)->not->toContain('setup_token:');
expect($operation)->not->toContain('setup_link:');
});
it('creates and notifies a company grant only after the SMS setup token is completed', function (): void {
$code = preg_replace('/\s+/', ' ', (string)file_get_contents(app_path('routes/subusersRoute.php')));
$setupStart = strpos($code, "\$this->post('/subusers/setup', function () {");
$setupEnd = strpos($code, "\$this->post('/subusers/password-reset/request'", $setupStart === false ? 0 : $setupStart);
$setup = $setupStart === false || $setupEnd === false ? '' : substr($code, $setupStart, $setupEnd - $setupStart);
expect($setup)->toContain('$this->getPublicRegistrationPending($token)');
expect($setup)->toContain('(new subuser_grants_o())->add(');
expect($setup)->toContain('$this->notifyCustomerOfGrantRequest(');
expect($setup)->toContain('$this->clearPublicRegistrationPending($token)');
expect(strpos($setup, '$subuser->update('))->toBeLessThan(strpos($setup, '(new subuser_grants_o())->add('));
});
@@ -39,7 +39,8 @@ it('delivers purpose-bound SMS notifications for grant requests and decisions',
expect($normalized)->toContain('PURPOSE_GRANT_DENY');
expect($normalized)->toContain('notifySubuserGrantDecision(');
expect($normalized)->toContain("'decision_notification' => \$delivery");
expect($normalized)->toContain("'customer_notification' => \$customerNotification");
expect($normalized)->toContain('$this->getPublicRegistrationPending($token)');
expect($normalized)->toContain('$this->notifyCustomerOfGrantRequest(');
});
it('keeps forgot-password responses generic and protects reset tokens by purpose', function (): void {
@@ -8,6 +8,7 @@ namespace {
$DEBUG = true;
$_SERVER['REQUEST_URI'] = '/auth/register/cvr';
$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
}
namespace classes {
@@ -189,15 +190,20 @@ namespace classes {
{
public static array $sent = [];
public static array $superuser_notifications = [];
public static array $failing_recipients = [];
public static function reset(): void
{
self::$sent = [];
self::$superuser_notifications = [];
self::$failing_recipients = [];
}
public function sendWelcomeEmailToCustomer($phone, $email): bool
{
if (in_array((string)$email, self::$failing_recipients, true)) {
throw new \RuntimeException('Mock welcome delivery failure');
}
self::$sent[] = [
'customer_number' => (int)$phone,
'email' => (string)$email,
@@ -254,6 +260,7 @@ namespace objects {
public static array $mock_importable_customer_numbers = [];
public static bool $mock_external_lookup_enabled = true;
public static array $interaction_log = [];
public static array $hydrated_contacts = [];
public int $id = 0;
private bool $exists = false;
@@ -264,6 +271,7 @@ namespace objects {
self::$mock_importable_customer_numbers = [];
self::$mock_external_lookup_enabled = true;
self::$interaction_log = [];
self::$hydrated_contacts = [];
}
public function getFieldsWhere(array $fieldsAndValues, array $fields): array
@@ -318,6 +326,22 @@ namespace objects {
{
return $this->exists;
}
public function hydrateRegistrationContact(
string $email,
int $phoneNumber,
int $phoneCountryCode = 45,
?string $contactName = null
): void {
self::$hydrated_contacts[] = [
'customer_number' => $this->id,
'email' => $email,
'phone' => $phoneNumber,
'phone_country_code' => $phoneCountryCode,
'contact_name' => $contactName,
];
self::$interaction_log[] = 'hydrate:' . $this->id;
}
}
class logs_o
@@ -403,6 +427,34 @@ namespace {
}
}
/**
* Each table-driven case is an independent registration request. Clear
* only the two production throttle keys that request can touch so the
* legacy harness does not leak rate-limit state between cases.
*
* @param array<string, mixed> $params
*/
function reset_registration_throttles(array $params): void
{
if (!defined('redis')) {
return;
}
$remoteAddress = trim((string)($_SERVER['REMOTE_ADDR'] ?? 'unknown'));
$keys = [
'auth_public_registration_throttle:customer_ip:' . hash('sha256', $remoteAddress . ':all'),
];
if (isset($params['cvr'], $params['companyPhone'])) {
$identifier = 'cvr:' . (string)$params['cvr'] . ':phone:' . (int)$params['companyPhone'];
$keys[] = 'auth_public_registration_throttle:customer_identity:' . hash('sha256', $identifier);
}
foreach ($keys as $key) {
constant('redis')->delete($key);
}
}
$router = new MockRouter();
$response = new \classes\response();
@@ -423,6 +475,8 @@ namespace {
'invoiceEmail' => 'test@test.com',
'contactEmail' => 'test@test.com',
'contactPhone' => 12345678,
'contactPhoneCountryCode' => 45,
'contactName' => 'Test Contact',
'g_recaptcha_response' => 'valid',
];
@@ -544,6 +598,13 @@ namespace {
'expected_status' => 200,
'assert' => static function (): void {
assert_true(count(\classes\economic::$create_calls) === 0, 'Recovery must not create a second e-conomic customer.');
assert_same_data(\objects\users_o::$hydrated_contacts, [[
'customer_number' => 12345678,
'email' => 'test@test.com',
'phone' => 12345678,
'phone_country_code' => 45,
'contact_name' => 'Test Contact',
]], 'Recovery must hydrate the local registration contact.');
assert_true(count(\classes\email::$sent) === 2, 'Recovery must send two welcome emails.');
assert_true(\objects\users_o::$interaction_log[0] === 'bootstrap:12345678', 'Local bootstrap must happen before sending emails.');
assert_true(\classes\email::$sent[0]['email'] === 'jm@truckwash.dk', 'Recovery should notify the internal welcome recipient first.');
@@ -614,6 +675,13 @@ namespace {
assert_true(\classes\economic::$create_calls[0]['phone'] === 12345678, 'Fresh registration must use the company phone as the e-conomic customer phone.');
assert_true(\classes\economic::$create_calls[0]['mobile_phone'] === 87654320, 'Fresh registration must pass the contact phone as the e-conomic mobile phone.');
assert_true(\classes\economic::$create_calls[0]['ean'] === '5790001234567', 'Fresh registration must pass normalized EAN to e-conomic.');
assert_same_data(\objects\users_o::$hydrated_contacts, [[
'customer_number' => 12345678,
'email' => 'test@test.com',
'phone' => 87654320,
'phone_country_code' => 45,
'contact_name' => 'Test Contact',
]], 'Fresh registration must hydrate the local registration contact.');
$companyInformation = \classes\economic::$create_calls[0]['company_information'];
assert_true(is_object($companyInformation), 'Fresh registration must pass CVR company information to e-conomic.');
assert_true($companyInformation->address === 'Demo Street 1', 'Fresh registration must pass the CVR address to e-conomic.');
@@ -629,6 +697,29 @@ namespace {
assert_true(\classes\slack::$customer_registration_notifications[0]['customer_number'] === 12345678, 'Fresh registration Slack notification must use the created customer number.');
},
],
[
'name' => 'Welcome email failure does not roll back a completed registration',
'params' => $baseParams,
'setup' => static function (): void {
\classes\economic::$mock_create_response = (object)[
'customerNumber' => 12345678,
'name' => 'Mock Company',
];
\classes\email::$failing_recipients = ['test@test.com'];
},
'expected_success' => (object)[
'customerNumber' => 12345678,
'name' => 'Mock Company',
],
'expected_status' => 201,
'assert' => static function (): void {
assert_true(count(\classes\email::$sent) === 1, 'Successful welcome deliveries should be preserved.');
assert_true(count(\classes\email::$superuser_notifications) === 1, 'Superuser notification should continue after welcome email failure.');
assert_true(count(\classes\slack::$customer_registration_notifications) === 1, 'Slack notification should continue after welcome email failure.');
$events = array_column(\objects\logs_o::$entries, 'event');
assert_true(in_array('AUTH_REGISTER_CVR_WELCOME_EMAIL_FAILED', $events, true), 'Welcome email failure should be logged.');
},
],
[
'name' => 'Successful registration falls back to the create response when immediate import lookup misses',
'params' => array_merge($baseParams, ['contactPhone' => 87654320]),
@@ -749,6 +840,7 @@ namespace {
\classes\virkdata::$mock_exception = null;
\objects\users_o::reset();
\objects\logs_o::reset();
reset_registration_throttles($test['params']);
if (isset($test['setup'])) {
$test['setup']();
+83
View File
@@ -267,6 +267,18 @@ trait route_t
*/
public function requireDepartmentAccess(string $department, string|null $permission = null): void
{
global $response;
$departmentId = (int)$department;
$authenticatedUser = (new authentication())->get_user();
if ($authenticatedUser instanceof \objects\users_o) {
$managedScope = (new \classes\limited_backoffice_service())
->managedEmployeeDepartmentIds($authenticatedUser);
if ($managedScope !== null && !in_array($departmentId, $managedScope, true)) {
$response->error('Department resource not found', 404);
}
}
self::requirePermission('department_access_' . $department . ($permission ? '_' . $permission : ''));
}
@@ -279,9 +291,80 @@ trait route_t
*/
public function hasDepartmentAccess(string $department, string|null $permission = null): bool
{
$departmentId = (int)$department;
$authenticatedUser = (new authentication())->get_user();
if ($authenticatedUser instanceof \objects\users_o) {
$managedScope = (new \classes\limited_backoffice_service())
->managedEmployeeDepartmentIds($authenticatedUser);
if ($managedScope !== null && !in_array($departmentId, $managedScope, true)) {
return false;
}
}
return self::hasPermission('department_access_' . $department . ($permission ? '_' . $permission : ''));
}
/**
* Applies department scoping only to users managed by limited backoffice.
* Other user types retain the route's existing permission contract.
*
* @return array<int, int>|null
*/
public function limitedBackofficeDepartmentScope(object $user): ?array
{
if (!$user instanceof \objects\users_o) {
return null;
}
return (new \classes\limited_backoffice_service())->managedEmployeeDepartmentIds($user);
}
/**
* Returns the authoritative managed assignment for limited-backoffice
* employees and preserves the legacy group-derived scope for every other
* account type.
*
* @return array<int, int>
*/
public function effectiveDepartmentIds(object $user): array
{
$managedScope = $this->limitedBackofficeDepartmentScope($user);
if ($managedScope !== null) {
$departmentIds = array_values(array_unique(array_filter(
array_map('intval', $managedScope),
static fn (int $departmentId): bool => $departmentId > 0
)));
// Empty filter arrays are treated as "no filter" by legacy query
// helpers. A corrupt or missing managed assignment must deny all,
// never widen a limited employee to global department access.
return $departmentIds === [] ? [0] : $departmentIds;
}
if (!method_exists($user, 'getGroup')) {
return [];
}
return array_values(array_unique(array_map(
'intval',
(array)$user->getGroup()->getDepartments()
)));
}
public function requireLimitedBackofficeDepartmentAccess(object $user, int $departmentId): void
{
global $response;
$departmentScope = $this->limitedBackofficeDepartmentScope($user);
if ($departmentScope === null) {
return;
}
if (!in_array($departmentId, $departmentScope, true)) {
$response->error('Department resource not found', 404);
}
}
/**
* Get parameters as an array from the request
* @return array