Complete subuser notification and recovery lifecycle (#325)
## Summary - notify customers by SMS with approve/deny links when a subuser requests access - notify subusers by SMS after approval or denial, including manual grant changes - support subuser password reset and authenticated password changes - add read-only token previews followed by explicit POST confirmation - store short-lived one-time purpose-bound action tokens only as SHA-256 digests - serialize grant decisions transactionally to prevent conflicting concurrent actions - document the API contract in OpenAPI ## Security - generic reset responses reduce account enumeration - URL tokens are removed from browser history after frontend bootstrap - approval previews never mutate state - concurrent decisions lock the exact grant row - SMS failures remain non-fatal and are returned as delivery status Residual risk: existing subuser sessions cannot all be centrally invalidated after password reset because there is no per-subuser session index; they expire normally within the existing session lifetime. ## Verification - backend Pest: 14 tests, 91 assertions - PHP syntax checks passed - focused PHPStan passed - OpenAPI YAML parsed successfully - `git diff --check` passed Database-backed API integration tests were unavailable because the local environment lacks the required database configuration. ## Paired delivery Paired Frontend PR: https://github.com/copenhagentruckwash/pleno-vue/pull/231 Both PRs are required before completion. The frontend PR contains the responsive visual comparisons. Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
This commit is contained in:
co-authored by
Jeppe Bundgaard
parent
8d8f0eccce
commit
d3e4798b11
@@ -0,0 +1,208 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace classes;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Purpose-bound one-time tokens. Only SHA-256 digests are persisted.
|
||||||
|
*/
|
||||||
|
class subuser_action_token_service
|
||||||
|
{
|
||||||
|
public const PURPOSE_GRANT_APPROVE = 'grant_approve';
|
||||||
|
public const PURPOSE_GRANT_DENY = 'grant_deny';
|
||||||
|
public const PURPOSE_PASSWORD_RESET = 'password_reset';
|
||||||
|
public const TOKEN_BYTES = 32;
|
||||||
|
public const GRANT_DECISION_TTL_SECONDS = 24 * 60 * 60;
|
||||||
|
public const PASSWORD_RESET_TTL_SECONDS = 60 * 60;
|
||||||
|
|
||||||
|
public function issue(string $purpose, int $subuserId, ?int $grantId = null, ?int $customerNumber = null, ?int $ttlSeconds = null): string
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
$this->assertPurpose($purpose);
|
||||||
|
if ($subuserId <= 0) {
|
||||||
|
throw new Exception('Invalid subuser action token subject');
|
||||||
|
}
|
||||||
|
$token = bin2hex(random_bytes(self::TOKEN_BYTES));
|
||||||
|
$tokenHash = hash('sha256', $token);
|
||||||
|
$ttlSeconds ??= $purpose === self::PURPOSE_PASSWORD_RESET
|
||||||
|
? self::PASSWORD_RESET_TTL_SECONDS
|
||||||
|
: self::GRANT_DECISION_TTL_SECONDS;
|
||||||
|
$expiresAt = gmdate('Y-m-d H:i:s', time() + max(60, $ttlSeconds));
|
||||||
|
$statement = $db->conn->prepare(
|
||||||
|
'INSERT INTO subuser_action_tokens '
|
||||||
|
. '(token_hash, purpose, subuser_id, grant_id, customer_number, expires_at) VALUES (?, ?, ?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
if ($statement === false) {
|
||||||
|
throw new Exception('Failed to prepare subuser action token');
|
||||||
|
}
|
||||||
|
$statement->bind_param('ssiiis', $tokenHash, $purpose, $subuserId, $grantId, $customerNumber, $expiresAt);
|
||||||
|
$statement->execute();
|
||||||
|
$statement->close();
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function inspect(string $token, ?string $expectedPurpose = null): ?array
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
$token = strtolower($token);
|
||||||
|
if (!preg_match('/^[a-f0-9]{64}$/', $token)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ($expectedPurpose !== null) {
|
||||||
|
$this->assertPurpose($expectedPurpose);
|
||||||
|
}
|
||||||
|
$tokenHash = hash('sha256', $token);
|
||||||
|
$sql = 'SELECT id, purpose, subuser_id, grant_id, customer_number, expires_at FROM subuser_action_tokens '
|
||||||
|
. "WHERE token_hash = '" . $db->escape_string($tokenHash) . "' "
|
||||||
|
. 'AND used_at IS NULL AND expires_at > UTC_TIMESTAMP()';
|
||||||
|
if ($expectedPurpose !== null) {
|
||||||
|
$sql .= " AND purpose = '" . $db->escape_string($expectedPurpose) . "'";
|
||||||
|
}
|
||||||
|
$result = $db->query($sql . ' LIMIT 1');
|
||||||
|
if ($result === false || $result->num_rows === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$row = $result->fetch_assoc();
|
||||||
|
return [
|
||||||
|
'id' => (int)$row['id'],
|
||||||
|
'purpose' => (string)$row['purpose'],
|
||||||
|
'subuser_id' => (int)$row['subuser_id'],
|
||||||
|
'grant_id' => $row['grant_id'] === null ? null : (int)$row['grant_id'],
|
||||||
|
'customer_number' => $row['customer_number'] === null ? null : (int)$row['customer_number'],
|
||||||
|
'expires_at' => (string)$row['expires_at'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function consume(string $token, string $expectedPurpose): ?array
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
$record = $this->inspect($token, $expectedPurpose);
|
||||||
|
if ($record === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$statement = $db->conn->prepare(
|
||||||
|
'UPDATE subuser_action_tokens SET used_at = UTC_TIMESTAMP() '
|
||||||
|
. 'WHERE id = ? AND used_at IS NULL AND expires_at > UTC_TIMESTAMP()'
|
||||||
|
);
|
||||||
|
if ($statement === false) {
|
||||||
|
throw new Exception('Failed to consume subuser action token');
|
||||||
|
}
|
||||||
|
$id = (int)$record['id'];
|
||||||
|
$statement->bind_param('i', $id);
|
||||||
|
$statement->execute();
|
||||||
|
$consumed = $statement->affected_rows === 1;
|
||||||
|
$statement->close();
|
||||||
|
return $consumed ? $record : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function consumeGrantDecision(string $token): ?array
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
$preview = $this->inspect($token);
|
||||||
|
if (
|
||||||
|
$preview === null
|
||||||
|
|| $preview['grant_id'] === null
|
||||||
|
|| !in_array($preview['purpose'], [
|
||||||
|
self::PURPOSE_GRANT_APPROVE,
|
||||||
|
self::PURPOSE_GRANT_DENY,
|
||||||
|
], true)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->conn->begin_transaction();
|
||||||
|
try {
|
||||||
|
// Serializing on the grant row prevents simultaneous approve and
|
||||||
|
// deny links from racing and applying opposite final states.
|
||||||
|
$lock = $db->conn->prepare(
|
||||||
|
'SELECT id FROM subuser_grants '
|
||||||
|
. 'WHERE id = ? AND subuser = ? AND billing_customer_number = ? AND deleted_at IS NULL '
|
||||||
|
. 'FOR UPDATE'
|
||||||
|
);
|
||||||
|
if ($lock === false) {
|
||||||
|
throw new Exception('Failed to lock subuser grant decision');
|
||||||
|
}
|
||||||
|
$grantId = (int)$preview['grant_id'];
|
||||||
|
$subuserId = (int)$preview['subuser_id'];
|
||||||
|
$customerNumber = (int)$preview['customer_number'];
|
||||||
|
$lock->bind_param('iii', $grantId, $subuserId, $customerNumber);
|
||||||
|
$lock->execute();
|
||||||
|
$lock->store_result();
|
||||||
|
$grantExists = $lock->num_rows === 1;
|
||||||
|
$lock->close();
|
||||||
|
if (!$grantExists) {
|
||||||
|
$db->conn->rollback();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$record = $this->consume($token, (string)$preview['purpose']);
|
||||||
|
if ($record === null) {
|
||||||
|
$db->conn->rollback();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$enabled = $record['purpose'] === self::PURPOSE_GRANT_APPROVE ? 1 : 0;
|
||||||
|
$update = $db->conn->prepare('UPDATE subuser_grants SET enabled = ? WHERE id = ?');
|
||||||
|
if ($update === false) {
|
||||||
|
throw new Exception('Failed to apply subuser grant decision');
|
||||||
|
}
|
||||||
|
$update->bind_param('ii', $enabled, $grantId);
|
||||||
|
$update->execute();
|
||||||
|
$applied = $update->affected_rows === 1 || $update->warning_count === 0;
|
||||||
|
$update->close();
|
||||||
|
if (!$applied) {
|
||||||
|
throw new Exception('Failed to apply subuser grant decision');
|
||||||
|
}
|
||||||
|
$this->revokeGrantDecisions($grantId);
|
||||||
|
$db->conn->commit();
|
||||||
|
return $record;
|
||||||
|
} catch (Exception $exception) {
|
||||||
|
$db->conn->rollback();
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function revokeForSubuser(int $subuserId, string $purpose): void
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
$this->assertPurpose($purpose);
|
||||||
|
$statement = $db->conn->prepare(
|
||||||
|
'UPDATE subuser_action_tokens SET used_at = UTC_TIMESTAMP() WHERE subuser_id = ? AND purpose = ? AND used_at IS NULL'
|
||||||
|
);
|
||||||
|
if ($statement === false) {
|
||||||
|
throw new Exception('Failed to revoke subuser action tokens');
|
||||||
|
}
|
||||||
|
$statement->bind_param('is', $subuserId, $purpose);
|
||||||
|
$statement->execute();
|
||||||
|
$statement->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function revokeGrantDecisions(int $grantId): void
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
$statement = $db->conn->prepare(
|
||||||
|
'UPDATE subuser_action_tokens SET used_at = UTC_TIMESTAMP() '
|
||||||
|
. 'WHERE grant_id = ? AND purpose IN (?, ?) AND used_at IS NULL'
|
||||||
|
);
|
||||||
|
if ($statement === false) {
|
||||||
|
throw new Exception('Failed to revoke grant decision tokens');
|
||||||
|
}
|
||||||
|
$approve = self::PURPOSE_GRANT_APPROVE;
|
||||||
|
$deny = self::PURPOSE_GRANT_DENY;
|
||||||
|
$statement->bind_param('iss', $grantId, $approve, $deny);
|
||||||
|
$statement->execute();
|
||||||
|
$statement->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertPurpose(string $purpose): void
|
||||||
|
{
|
||||||
|
if (!in_array($purpose, [
|
||||||
|
self::PURPOSE_GRANT_APPROVE,
|
||||||
|
self::PURPOSE_GRANT_DENY,
|
||||||
|
self::PURPOSE_PASSWORD_RESET,
|
||||||
|
], true)) {
|
||||||
|
throw new Exception('Invalid subuser action token purpose');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,10 +38,41 @@ class subusers_schema_bootstrap
|
|||||||
'email_verified_at',
|
'email_verified_at',
|
||||||
'DATETIME NULL AFTER `email`'
|
'DATETIME NULL AFTER `email`'
|
||||||
);
|
);
|
||||||
|
self::ensureTable(
|
||||||
|
'subuser_action_tokens',
|
||||||
|
<<<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS `subuser_action_tokens` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`token_hash` CHAR(64) NOT NULL,
|
||||||
|
`purpose` VARCHAR(32) NOT NULL,
|
||||||
|
`subuser_id` INT UNSIGNED NOT NULL,
|
||||||
|
`grant_id` INT UNSIGNED NULL,
|
||||||
|
`customer_number` INT NULL,
|
||||||
|
`expires_at` DATETIME NOT NULL,
|
||||||
|
`used_at` DATETIME NULL,
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uniq_subuser_action_token_hash` (`token_hash`),
|
||||||
|
KEY `idx_subuser_action_token_subject` (`subuser_id`, `purpose`, `used_at`),
|
||||||
|
KEY `idx_subuser_action_token_expiry` (`expires_at`, `used_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
SQL
|
||||||
|
);
|
||||||
|
|
||||||
self::$initialized = true;
|
self::$initialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function ensureTable(string $table, string $definition): void
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
|
||||||
|
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||||
|
if ($table === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$db->query($definition);
|
||||||
|
}
|
||||||
|
|
||||||
private static function ensureColumn(string $table, string $column, string $definition): void
|
private static function ensureColumn(string $table, string $column, string $definition): void
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
|
|||||||
@@ -2406,6 +2406,120 @@ paths:
|
|||||||
'404': { $ref: '#/components/responses/NotFound' }
|
'404': { $ref: '#/components/responses/NotFound' }
|
||||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||||
|
|
||||||
|
/subusers/me/password:
|
||||||
|
post:
|
||||||
|
tags: [Subusers]
|
||||||
|
summary: Change the authenticated subuser password
|
||||||
|
operationId: changeCurrentSubuserPassword
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [current_password, password]
|
||||||
|
properties:
|
||||||
|
current_password: { type: string, format: password }
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
format: password
|
||||||
|
minLength: 8
|
||||||
|
maxLength: 255
|
||||||
|
responses:
|
||||||
|
'200': { description: Password updated }
|
||||||
|
'400': { $ref: '#/components/responses/BadRequest' }
|
||||||
|
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||||
|
|
||||||
|
/subusers/password-reset/request:
|
||||||
|
post:
|
||||||
|
tags: [Subusers]
|
||||||
|
summary: Request a subuser password-reset SMS
|
||||||
|
description: Always returns the same success response regardless of account or delivery state.
|
||||||
|
operationId: requestSubuserPasswordReset
|
||||||
|
security: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [phone_country_code, phone]
|
||||||
|
properties:
|
||||||
|
phone_country_code: { type: integer, minimum: 1, maximum: 999 }
|
||||||
|
phone: { type: integer, minimum: 1000 }
|
||||||
|
responses:
|
||||||
|
'200': { description: Generic reset-request acknowledgement }
|
||||||
|
'400': { $ref: '#/components/responses/BadRequest' }
|
||||||
|
|
||||||
|
/subusers/password-reset/validate:
|
||||||
|
get:
|
||||||
|
tags: [Subusers]
|
||||||
|
summary: Validate a subuser password-reset token
|
||||||
|
operationId: validateSubuserPasswordReset
|
||||||
|
security: []
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: token
|
||||||
|
required: true
|
||||||
|
schema: { type: string, pattern: '^[a-f0-9]{64}$' }
|
||||||
|
responses:
|
||||||
|
'200': { description: Token is valid }
|
||||||
|
'404': { $ref: '#/components/responses/NotFound' }
|
||||||
|
|
||||||
|
/subusers/password-reset/set:
|
||||||
|
post:
|
||||||
|
tags: [Subusers]
|
||||||
|
summary: Set a subuser password with a one-time token
|
||||||
|
operationId: setSubuserPasswordFromReset
|
||||||
|
security: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [token, password]
|
||||||
|
properties:
|
||||||
|
token: { type: string, pattern: '^[a-f0-9]{64}$' }
|
||||||
|
password: { type: string, format: password, minLength: 8, maxLength: 255 }
|
||||||
|
responses:
|
||||||
|
'200': { description: Password updated }
|
||||||
|
'400': { $ref: '#/components/responses/BadRequest' }
|
||||||
|
'404': { $ref: '#/components/responses/NotFound' }
|
||||||
|
|
||||||
|
/subusers/access-decision:
|
||||||
|
get:
|
||||||
|
tags: [Subusers]
|
||||||
|
summary: Preview a pre-authorized grant decision
|
||||||
|
description: Read-only preview used before the customer explicitly confirms the decision.
|
||||||
|
operationId: previewSubuserAccessDecision
|
||||||
|
security: []
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: token
|
||||||
|
required: true
|
||||||
|
schema: { type: string, pattern: '^[a-f0-9]{64}$' }
|
||||||
|
responses:
|
||||||
|
'200': { description: Decision preview }
|
||||||
|
'404': { $ref: '#/components/responses/NotFound' }
|
||||||
|
post:
|
||||||
|
tags: [Subusers]
|
||||||
|
summary: Apply a pre-authorized one-time grant decision
|
||||||
|
operationId: applySubuserAccessDecision
|
||||||
|
security: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [token]
|
||||||
|
properties:
|
||||||
|
token: { type: string, pattern: '^[a-f0-9]{64}$' }
|
||||||
|
responses:
|
||||||
|
'200': { description: Grant approved or denied and subuser notified }
|
||||||
|
'404': { $ref: '#/components/responses/NotFound' }
|
||||||
|
|
||||||
/subusers/grants:
|
/subusers/grants:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use classes\economic;
|
|||||||
use classes\email;
|
use classes\email;
|
||||||
use classes\gatewayapi;
|
use classes\gatewayapi;
|
||||||
use classes\response;
|
use classes\response;
|
||||||
|
use classes\subuser_action_token_service;
|
||||||
use classes\subuser_contact_verification_service;
|
use classes\subuser_contact_verification_service;
|
||||||
use classes\subusers_schema_bootstrap;
|
use classes\subusers_schema_bootstrap;
|
||||||
use classes\subuser_permission_templates_service;
|
use classes\subuser_permission_templates_service;
|
||||||
@@ -833,6 +834,76 @@ class subusersRoute
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function deliverSms(?string $destination, string $message): array
|
||||||
|
{
|
||||||
|
if ($destination === null || trim($destination) === '') {
|
||||||
|
return $this->subuserLinkDelivery('sms', 'missing_destination', 'SMS-modtager mangler.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$gateway = new gatewayapi();
|
||||||
|
if (!$gateway->isEnabled()) {
|
||||||
|
return $this->subuserLinkDelivery('sms', 'unavailable', 'SMS-afsendelse er ikke konfigureret.');
|
||||||
|
}
|
||||||
|
$gateway->send([$destination], $message);
|
||||||
|
return $this->subuserLinkDelivery('sms', 'sent', 'SMS er sendt.');
|
||||||
|
} catch (Exception) {
|
||||||
|
return $this->subuserLinkDelivery('sms', 'failed', 'SMS kunne ikke sendes.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function subuserPhoneDestination(subusers_o $subuser): ?string
|
||||||
|
{
|
||||||
|
$countryCode = trim((string)($subuser->phone_country_code->value() ?? ''));
|
||||||
|
$phone = trim((string)($subuser->phone->value() ?? ''));
|
||||||
|
return $countryCode !== '' && $phone !== '' ? $countryCode . $phone : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function notifySubuserGrantDecision(subusers_o $subuser, int $customerNumber, bool $approved): array
|
||||||
|
{
|
||||||
|
$customerName = $this->resolveCustomerName($customerNumber) ?: ('kunde #' . $customerNumber);
|
||||||
|
$message = $approved
|
||||||
|
? 'Truck Wash: Din adgang til ' . $customerName . ' er godkendt.'
|
||||||
|
: 'Truck Wash: Din anmodning om adgang til ' . $customerName . ' er afvist eller deaktiveret.';
|
||||||
|
return $this->deliverSms($this->subuserPhoneDestination($subuser), $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function notifyCustomerOfGrantRequest(
|
||||||
|
subuser_grants_o $grant,
|
||||||
|
subusers_o $subuser,
|
||||||
|
int $customerNumber
|
||||||
|
): array {
|
||||||
|
$customer = (new users_o())->getUserByCustomerNumber($customerNumber);
|
||||||
|
if (!$customer->exists()) {
|
||||||
|
return $this->subuserLinkDelivery('sms', 'missing_destination', 'Kundekontoen blev ikke fundet.');
|
||||||
|
}
|
||||||
|
$customer->getObjectProperties();
|
||||||
|
$countryCode = trim((string)($customer->phone_country_code->value() ?? ''));
|
||||||
|
$phone = trim((string)($customer->phone->value() ?? ''));
|
||||||
|
$destination = $countryCode !== '' && $phone !== '' ? $countryCode . $phone : null;
|
||||||
|
|
||||||
|
$tokens = new subuser_action_token_service();
|
||||||
|
$approveToken = $tokens->issue(
|
||||||
|
subuser_action_token_service::PURPOSE_GRANT_APPROVE,
|
||||||
|
(int)$subuser->id,
|
||||||
|
(int)$grant->id,
|
||||||
|
$customerNumber
|
||||||
|
);
|
||||||
|
$denyToken = $tokens->issue(
|
||||||
|
subuser_action_token_service::PURPOSE_GRANT_DENY,
|
||||||
|
(int)$subuser->id,
|
||||||
|
(int)$grant->id,
|
||||||
|
$customerNumber
|
||||||
|
);
|
||||||
|
$name = trim((string)($subuser->name->value() ?? '')) ?: 'En chauffør';
|
||||||
|
$base = $this->frontendBaseUrl() . '/subuser-access/decision?token=';
|
||||||
|
$message = 'Truck Wash: ' . $name . ' anmoder om adgang. '
|
||||||
|
. 'Godkend: ' . $base . rawurlencode($approveToken) . ' '
|
||||||
|
. 'Afvis: ' . $base . rawurlencode($denyToken);
|
||||||
|
|
||||||
|
return $this->deliverSms($destination, $message);
|
||||||
|
}
|
||||||
|
|
||||||
private function buildSubuserManagementPayload(subusers_o $subuser, int $customerNumber, ?string $customerName = null): array
|
private function buildSubuserManagementPayload(subusers_o $subuser, int $customerNumber, ?string $customerName = null): array
|
||||||
{
|
{
|
||||||
$grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer((int)$subuser->id, $customerNumber, true);
|
$grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer((int)$subuser->id, $customerNumber, true);
|
||||||
@@ -1469,6 +1540,7 @@ class subusersRoute
|
|||||||
global $response;
|
global $response;
|
||||||
|
|
||||||
$grant = $this->getGrantForScopedUserOrFail($grantId, $customerNumber);
|
$grant = $this->getGrantForScopedUserOrFail($grantId, $customerNumber);
|
||||||
|
$wasEnabled = (bool)$grant->enabled->value();
|
||||||
$this->rejectBlockedSubuser((int)$grant->subuser->value());
|
$this->rejectBlockedSubuser((int)$grant->subuser->value());
|
||||||
$updates = [];
|
$updates = [];
|
||||||
$templateAccess = $this->parseAccessTemplatePayload();
|
$templateAccess = $this->parseAccessTemplatePayload();
|
||||||
@@ -1518,10 +1590,17 @@ class subusersRoute
|
|||||||
$updatedGrant->getObjectProperties();
|
$updatedGrant->getObjectProperties();
|
||||||
$subuser = (new subusers_o())->select((int)$updatedGrant->subuser->value());
|
$subuser = (new subusers_o())->select((int)$updatedGrant->subuser->value());
|
||||||
$subuser->getObjectProperties();
|
$subuser->getObjectProperties();
|
||||||
|
$delivery = null;
|
||||||
|
$isEnabled = (bool)$updatedGrant->enabled->value();
|
||||||
|
if ($isEnabled !== $wasEnabled) {
|
||||||
|
(new subuser_action_token_service())->revokeGrantDecisions($grantId);
|
||||||
|
$delivery = $this->notifySubuserGrantDecision($subuser, $customerNumber, $isEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
$response->success([
|
$response->success([
|
||||||
'subuser' => $this->buildSubuserManagementPayload($subuser, $customerNumber),
|
'subuser' => $this->buildSubuserManagementPayload($subuser, $customerNumber),
|
||||||
'grant' => $updatedGrant->asArray(),
|
'grant' => $updatedGrant->asArray(),
|
||||||
|
'decision_notification' => $delivery,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1809,6 +1888,7 @@ class subusersRoute
|
|||||||
if (!$grant->exists()) {
|
if (!$grant->exists()) {
|
||||||
$response->error('Grant not found', 404);
|
$response->error('Grant not found', 404);
|
||||||
}
|
}
|
||||||
|
$wasEnabled = (bool)$grant->enabled->value();
|
||||||
$this->rejectBlockedSubuser((int)$grant->subuser->value());
|
$this->rejectBlockedSubuser((int)$grant->subuser->value());
|
||||||
|
|
||||||
$targetCustomer = (int)$grant->billing_customer_number->value();
|
$targetCustomer = (int)$grant->billing_customer_number->value();
|
||||||
@@ -1851,7 +1931,17 @@ class subusersRoute
|
|||||||
$targetCustomer
|
$targetCustomer
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
$response->success($grant->asArray());
|
$delivery = null;
|
||||||
|
$isEnabled = (bool)$grant->enabled->value();
|
||||||
|
if ($isEnabled !== $wasEnabled) {
|
||||||
|
(new subuser_action_token_service())->revokeGrantDecisions((int)$grant->id);
|
||||||
|
$subuser = $this->loadSubuserOrFail((int)$grant->subuser->value());
|
||||||
|
$delivery = $this->notifySubuserGrantDecision($subuser, $targetCustomer, $isEnabled);
|
||||||
|
}
|
||||||
|
$response->success([
|
||||||
|
...$grant->asArray(),
|
||||||
|
'decision_notification' => $delivery,
|
||||||
|
]);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
'manage_subuser_grants' => 'Edit subuser grants for any customer (admin).',
|
'manage_subuser_grants' => 'Edit subuser grants for any customer (admin).',
|
||||||
@@ -1981,11 +2071,21 @@ class subusersRoute
|
|||||||
// Add the grant request
|
// Add the grant request
|
||||||
$subuser_grants_o = new subuser_grants_o();
|
$subuser_grants_o = new subuser_grants_o();
|
||||||
try {
|
try {
|
||||||
$subuser_grants_o->add($results[0]->customerNumber, $subuser->id, false, null);
|
$grant = $subuser_grants_o->add($results[0]->customerNumber, $subuser->id, false, null);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$response->error('Failed to add subuser grant', 500);
|
$response->error('Failed to add subuser grant', 500);
|
||||||
}
|
}
|
||||||
$response->success(['cvr' => $cvr, 'customer_number' => $results[0]->customerNumber, 'invite' => $invite]);
|
$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
|
// Code for creating a new subuser would go here
|
||||||
});
|
});
|
||||||
$this->get('/subusers/setup', function () {
|
$this->get('/subusers/setup', function () {
|
||||||
@@ -2066,6 +2166,170 @@ class subusersRoute
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$this->post('/subusers/password-reset/request', function () {
|
||||||
|
global $response;
|
||||||
|
|
||||||
|
$this->requireRecaptcha();
|
||||||
|
self::requireParameters(['phone_country_code', 'phone']);
|
||||||
|
$countryCode = (int)self::getParameter('phone_country_code');
|
||||||
|
$phone = (int)self::getParameter('phone');
|
||||||
|
self::requireType($countryCode, self::type_int());
|
||||||
|
self::requireType($phone, self::type_int());
|
||||||
|
self::requireMinLength('phone_country_code', 1);
|
||||||
|
self::requireMaxLength('phone_country_code', 3);
|
||||||
|
self::requireMinLength('phone', 4);
|
||||||
|
self::requireMaxLength('phone', 15);
|
||||||
|
|
||||||
|
$generic = ['message' => 'Hvis chaufførkontoen findes, er et nulstillingslink sendt.'];
|
||||||
|
$this->recordThrottleAttempt(
|
||||||
|
'subuser_password_reset',
|
||||||
|
'phone:' . $countryCode . ':' . $phone,
|
||||||
|
5,
|
||||||
|
15 * 60
|
||||||
|
);
|
||||||
|
$subuser = (new subusers_o())->getSubuserByPhone($countryCode, $phone);
|
||||||
|
if (
|
||||||
|
$subuser === null
|
||||||
|
|| $subuser->requiresSetup()
|
||||||
|
|| account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id)
|
||||||
|
) {
|
||||||
|
$response->success($generic);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$tokens = new subuser_action_token_service();
|
||||||
|
$tokens->revokeForSubuser(
|
||||||
|
(int)$subuser->id,
|
||||||
|
subuser_action_token_service::PURPOSE_PASSWORD_RESET
|
||||||
|
);
|
||||||
|
$token = $tokens->issue(
|
||||||
|
subuser_action_token_service::PURPOSE_PASSWORD_RESET,
|
||||||
|
(int)$subuser->id
|
||||||
|
);
|
||||||
|
$link = $this->frontendBaseUrl()
|
||||||
|
. '/auth/password-reset/' . rawurlencode($token)
|
||||||
|
. '?type=subuser';
|
||||||
|
$this->deliverSms(
|
||||||
|
$this->subuserPhoneDestination($subuser),
|
||||||
|
'Truck Wash: Nulstil din chaufføradgangskode her: ' . $link
|
||||||
|
);
|
||||||
|
} catch (Exception) {
|
||||||
|
// Preserve the same response for every account and delivery state.
|
||||||
|
}
|
||||||
|
|
||||||
|
$response->success($generic);
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->get('/subusers/password-reset/validate', function () {
|
||||||
|
global $response;
|
||||||
|
self::requireParameters(['token']);
|
||||||
|
$record = (new subuser_action_token_service())->inspect(
|
||||||
|
(string)self::getParameter('token'),
|
||||||
|
subuser_action_token_service::PURPOSE_PASSWORD_RESET
|
||||||
|
);
|
||||||
|
if ($record === null) {
|
||||||
|
$response->error('Invalid or expired token', 404);
|
||||||
|
}
|
||||||
|
$response->success(['valid' => true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->post('/subusers/password-reset/set', function () {
|
||||||
|
global $response;
|
||||||
|
$this->requireRecaptcha();
|
||||||
|
self::requireParameters(['token', 'password']);
|
||||||
|
$password = (string)self::getParameter('password');
|
||||||
|
$this->requireSubuserPasswordPolicy($password);
|
||||||
|
|
||||||
|
$tokens = new subuser_action_token_service();
|
||||||
|
$record = $tokens->consume(
|
||||||
|
(string)self::getParameter('token'),
|
||||||
|
subuser_action_token_service::PURPOSE_PASSWORD_RESET
|
||||||
|
);
|
||||||
|
if ($record === null) {
|
||||||
|
$response->error('Invalid or expired token', 404);
|
||||||
|
}
|
||||||
|
$subuser = $this->loadSubuserOrFail((int)$record['subuser_id']);
|
||||||
|
try {
|
||||||
|
$subuser->setPassword($password);
|
||||||
|
$subuser->invalidateCurrentSetupToken();
|
||||||
|
} catch (Exception $exception) {
|
||||||
|
$response->error($exception->getMessage(), 400);
|
||||||
|
}
|
||||||
|
$response->success(['message' => 'Password updated successfully']);
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->get('/subusers/access-decision', function () {
|
||||||
|
global $response;
|
||||||
|
self::requireParameters(['token']);
|
||||||
|
$record = (new subuser_action_token_service())->inspect((string)self::getParameter('token'));
|
||||||
|
if (
|
||||||
|
$record === null
|
||||||
|
|| !in_array($record['purpose'], [
|
||||||
|
subuser_action_token_service::PURPOSE_GRANT_APPROVE,
|
||||||
|
subuser_action_token_service::PURPOSE_GRANT_DENY,
|
||||||
|
], true)
|
||||||
|
) {
|
||||||
|
$response->error('Invalid or expired token', 404);
|
||||||
|
}
|
||||||
|
$grant = (new subuser_grants_o())->select((int)$record['grant_id']);
|
||||||
|
if (!$grant->exists()) {
|
||||||
|
$response->error('Invalid or expired token', 404);
|
||||||
|
}
|
||||||
|
$grant->getObjectProperties();
|
||||||
|
if (
|
||||||
|
(int)$grant->subuser->value() !== (int)$record['subuser_id']
|
||||||
|
|| (int)$grant->billing_customer_number->value() !== (int)$record['customer_number']
|
||||||
|
|| $grant->deleted_at->value() !== null
|
||||||
|
) {
|
||||||
|
$response->error('Invalid or expired token', 404);
|
||||||
|
}
|
||||||
|
$subuser = $this->loadSubuserOrFail((int)$record['subuser_id']);
|
||||||
|
$response->success([
|
||||||
|
'action' => $record['purpose'] === subuser_action_token_service::PURPOSE_GRANT_APPROVE
|
||||||
|
? 'approve'
|
||||||
|
: 'deny',
|
||||||
|
'subuser_name' => trim((string)($subuser->name->value() ?? '')) ?: 'Chauffør',
|
||||||
|
'customer_name' => $this->resolveCustomerName((int)$record['customer_number']),
|
||||||
|
'expires_at' => $record['expires_at'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->post('/subusers/access-decision', function () {
|
||||||
|
global $response;
|
||||||
|
self::requireParameters(['token']);
|
||||||
|
$token = (string)self::getParameter('token');
|
||||||
|
$service = new subuser_action_token_service();
|
||||||
|
$record = $service->consumeGrantDecision($token);
|
||||||
|
if ($record === null) {
|
||||||
|
$response->error('Invalid or expired token', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$grant = (new subuser_grants_o())->select((int)$record['grant_id']);
|
||||||
|
if (!$grant->exists()) {
|
||||||
|
$response->error('Invalid or expired token', 404);
|
||||||
|
}
|
||||||
|
$grant->getObjectProperties();
|
||||||
|
if (
|
||||||
|
(int)$grant->subuser->value() !== (int)$record['subuser_id']
|
||||||
|
|| (int)$grant->billing_customer_number->value() !== (int)$record['customer_number']
|
||||||
|
|| $grant->deleted_at->value() !== null
|
||||||
|
) {
|
||||||
|
$response->error('Invalid or expired token', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$approved = $record['purpose'] === subuser_action_token_service::PURPOSE_GRANT_APPROVE;
|
||||||
|
$subuser = $this->loadSubuserOrFail((int)$record['subuser_id']);
|
||||||
|
$delivery = $this->notifySubuserGrantDecision(
|
||||||
|
$subuser,
|
||||||
|
(int)$record['customer_number'],
|
||||||
|
$approved
|
||||||
|
);
|
||||||
|
$response->success([
|
||||||
|
'decision' => $approved ? 'approved' : 'denied',
|
||||||
|
'delivery' => $delivery,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
$this->post('/subusers/auth/password', function () {
|
$this->post('/subusers/auth/password', function () {
|
||||||
global $response;
|
global $response;
|
||||||
/**
|
/**
|
||||||
@@ -2144,6 +2408,32 @@ class subusersRoute
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$this->post('/subusers/me/password', function () {
|
||||||
|
global $response;
|
||||||
|
$subuser = (new authentication())->get_subuser();
|
||||||
|
if ($subuser === false) {
|
||||||
|
$response->error('Unauthorized', 401);
|
||||||
|
}
|
||||||
|
self::requireParameters(['current_password', 'password']);
|
||||||
|
$currentPassword = (string)self::getParameter('current_password');
|
||||||
|
$password = (string)self::getParameter('password');
|
||||||
|
$this->requireSubuserPasswordPolicy($password);
|
||||||
|
$passwordHash = $subuser->password->value();
|
||||||
|
if (!is_string($passwordHash) || !password_verify($currentPassword, $passwordHash)) {
|
||||||
|
$response->error('Current password is incorrect', 400);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$subuser->setPassword($password);
|
||||||
|
(new subuser_action_token_service())->revokeForSubuser(
|
||||||
|
(int)$subuser->id,
|
||||||
|
subuser_action_token_service::PURPOSE_PASSWORD_RESET
|
||||||
|
);
|
||||||
|
} catch (Exception $exception) {
|
||||||
|
$response->error($exception->getMessage(), 400);
|
||||||
|
}
|
||||||
|
$response->success(['message' => 'Password updated successfully']);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// =============================
|
// =============================
|
||||||
// Subusers - List & Get (with grant visibility)
|
// Subusers - List & Get (with grant visibility)
|
||||||
// =============================
|
// =============================
|
||||||
@@ -2685,12 +2975,22 @@ class subusersRoute
|
|||||||
// Create a pending grant request for the company
|
// Create a pending grant request for the company
|
||||||
$subuser_grants_o = new subuser_grants_o();
|
$subuser_grants_o = new subuser_grants_o();
|
||||||
try {
|
try {
|
||||||
$subuser_grants_o->add($results[0]->customerNumber, $subuser->id, false, null);
|
$grant = $subuser_grants_o->add($results[0]->customerNumber, $subuser->id, false, null);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$response->error('Failed to add subuser grant', 500);
|
$response->error('Failed to add subuser grant', 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
$response->success(['cvr' => $cvr, 'customer_number' => $results[0]->customerNumber, 'invite' => $invite]);
|
$customerNotification = $this->notifyCustomerOfGrantRequest(
|
||||||
|
$grant,
|
||||||
|
$subuser,
|
||||||
|
(int)$results[0]->customerNumber
|
||||||
|
);
|
||||||
|
$response->success([
|
||||||
|
'cvr' => $cvr,
|
||||||
|
'customer_number' => $results[0]->customerNumber,
|
||||||
|
'invite' => $invite,
|
||||||
|
'customer_notification' => $customerNotification,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -902,6 +902,23 @@ CREATE TABLE IF NOT EXISTS `subuser_grants` (
|
|||||||
KEY `idx_subuser_grants_assigned_vehicle_id` (`assigned_vehicle_id`),
|
KEY `idx_subuser_grants_assigned_vehicle_id` (`assigned_vehicle_id`),
|
||||||
KEY `idx_subuser_grants_billing_customer_number` (`billing_customer_number`)
|
KEY `idx_subuser_grants_billing_customer_number` (`billing_customer_number`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
SQL,
|
||||||
|
'subuser_action_tokens' => <<<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS `subuser_action_tokens` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`token_hash` CHAR(64) NOT NULL,
|
||||||
|
`purpose` VARCHAR(32) NOT NULL,
|
||||||
|
`subuser_id` INT UNSIGNED NOT NULL,
|
||||||
|
`grant_id` INT UNSIGNED NULL,
|
||||||
|
`customer_number` INT NULL,
|
||||||
|
`expires_at` DATETIME NOT NULL,
|
||||||
|
`used_at` DATETIME NULL,
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uniq_subuser_action_token_hash` (`token_hash`),
|
||||||
|
KEY `idx_subuser_action_token_subject` (`subuser_id`, `purpose`, `used_at`),
|
||||||
|
KEY `idx_subuser_action_token_expiry` (`expires_at`, `used_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
SQL,
|
SQL,
|
||||||
'tokens' => <<<'SQL'
|
'tokens' => <<<'SQL'
|
||||||
CREATE TABLE IF NOT EXISTS `tokens` (
|
CREATE TABLE IF NOT EXISTS `tokens` (
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
it('defines isolated token purposes and short expiry windows', function (): void {
|
||||||
|
$code = (string)file_get_contents(app_path('classes/subuser_action_token_service.php'));
|
||||||
|
|
||||||
|
expect($code)->toContain("PURPOSE_GRANT_APPROVE = 'grant_approve'")
|
||||||
|
->toContain("PURPOSE_GRANT_DENY = 'grant_deny'")
|
||||||
|
->toContain("PURPOSE_PASSWORD_RESET = 'password_reset'")
|
||||||
|
->toContain('TOKEN_BYTES = 32')
|
||||||
|
->toContain('PASSWORD_RESET_TTL_SECONDS = 60 * 60')
|
||||||
|
->toContain('GRANT_DECISION_TTL_SECONDS = 24 * 60 * 60');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores token digests and consumes each token with a conditional one-time update', function (): void {
|
||||||
|
$code = (string)file_get_contents(app_path('classes/subuser_action_token_service.php'));
|
||||||
|
$normalized = preg_replace('/\s+/', ' ', $code);
|
||||||
|
|
||||||
|
expect($normalized)->toContain("\$tokenHash = hash('sha256', \$token)")
|
||||||
|
->toContain('WHERE id = ? AND used_at IS NULL AND expires_at > UTC_TIMESTAMP()')
|
||||||
|
->toContain('$statement->affected_rows === 1')
|
||||||
|
->toContain('revokeGrantDecisions')
|
||||||
|
->toContain('purpose IN (?, ?)')
|
||||||
|
->toContain('consumeGrantDecision')
|
||||||
|
->toContain('billing_customer_number = ? AND deleted_at IS NULL')
|
||||||
|
->toContain('UPDATE subuser_grants SET enabled = ? WHERE id = ?')
|
||||||
|
->toContain('$db->conn->begin_transaction()')
|
||||||
|
->not->toContain('(token, purpose, subuser_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates the action-token table with hash uniqueness and subject and expiry indexes', function (): void {
|
||||||
|
$schema = (string)file_get_contents(app_path('classes/subusers_schema_bootstrap.php'));
|
||||||
|
|
||||||
|
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS `subuser_action_tokens`')
|
||||||
|
->toContain('UNIQUE KEY `uniq_subuser_action_token_hash` (`token_hash`)')
|
||||||
|
->toContain('KEY `idx_subuser_action_token_subject` (`subuser_id`, `purpose`, `used_at`)')
|
||||||
|
->toContain('KEY `idx_subuser_action_token_expiry` (`expires_at`, `used_at`)');
|
||||||
|
});
|
||||||
@@ -22,6 +22,34 @@ it('exposes chauffeur management endpoints on the subusers route', function ():
|
|||||||
expect($normalized)->toContain("\$this->post('/superuser/subusers/{subuser_id}/password-guide/{channel}/send', function () {");
|
expect($normalized)->toContain("\$this->post('/superuser/subusers/{subuser_id}/password-guide/{channel}/send', function () {");
|
||||||
expect($normalized)->toContain("\$this->post('/superuser/subusers/{subuser_id}/login-link/{channel}/send', function () {");
|
expect($normalized)->toContain("\$this->post('/superuser/subusers/{subuser_id}/login-link/{channel}/send', function () {");
|
||||||
expect($normalized)->toContain("\$this->post('/subusers/{subuser_id}/verification/{channel}/send', function () {");
|
expect($normalized)->toContain("\$this->post('/subusers/{subuser_id}/verification/{channel}/send', function () {");
|
||||||
|
expect($normalized)->toContain("\$this->post('/subusers/password-reset/request', function () {");
|
||||||
|
expect($normalized)->toContain("\$this->get('/subusers/password-reset/validate', function () {");
|
||||||
|
expect($normalized)->toContain("\$this->post('/subusers/password-reset/set', function () {");
|
||||||
|
expect($normalized)->toContain("\$this->post('/subusers/me/password', function () {");
|
||||||
|
expect($normalized)->toContain("\$this->get('/subusers/access-decision', function () {");
|
||||||
|
expect($normalized)->toContain("\$this->post('/subusers/access-decision', function () {");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delivers purpose-bound SMS notifications for grant requests and decisions', function (): void {
|
||||||
|
$code = (string)file_get_contents(app_path('routes/subusersRoute.php'));
|
||||||
|
$normalized = preg_replace('/\s+/', ' ', $code);
|
||||||
|
|
||||||
|
expect($normalized)->toContain('notifyCustomerOfGrantRequest(');
|
||||||
|
expect($normalized)->toContain('PURPOSE_GRANT_APPROVE');
|
||||||
|
expect($normalized)->toContain('PURPOSE_GRANT_DENY');
|
||||||
|
expect($normalized)->toContain('notifySubuserGrantDecision(');
|
||||||
|
expect($normalized)->toContain("'decision_notification' => \$delivery");
|
||||||
|
expect($normalized)->toContain("'customer_notification' => \$customerNotification");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps forgot-password responses generic and protects reset tokens by purpose', function (): void {
|
||||||
|
$code = (string)file_get_contents(app_path('routes/subusersRoute.php'));
|
||||||
|
$normalized = preg_replace('/\s+/', ' ', $code);
|
||||||
|
|
||||||
|
expect($normalized)->toContain('Hvis chaufførkontoen findes, er et nulstillingslink sendt.');
|
||||||
|
expect($normalized)->toContain('PURPOSE_PASSWORD_RESET');
|
||||||
|
expect($normalized)->toContain("account_deletion_service::principalIsBlocked('subuser'");
|
||||||
|
expect($normalized)->toContain('invalidateCurrentSetupToken()');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('includes grant management fields in the subusers payload builder', function (): void {
|
it('includes grant management fields in the subusers payload builder', function (): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user