Add in-app account deletion (#319)
## Summary - Add self-service deletion for the authenticated customer or subuser identity only. - Preserve shared customer grants, reset keys, bookings, order bookings, vehicles, invoices, and legally required history. - Require password/TOTP or a fresh deletion-specific, five-minute, single-use WebAuthn assertion. - Reject support impersonation and expired legacy plain-session tokens. - Use durable database throttling, transactional request processing, a durable outbox, and terminal `manual_review` state. - Keep API and worker default-off behind separate `account_deletion.api_enabled` and `account_deletion.worker_enabled` module-config flags. ## Safe rollout 1. Keep both flags disabled. 2. Run `php scripts/account-deletion-schema.php check`. 3. If needed, run `php scripts/account-deletion-schema.php apply --yes`, then rerun `check` until `ready:true`. 4. Deploy the frontend companion PR while the API remains disabled. 5. Enable `api_enabled` for a controlled canary; verify password and passwordless request flows plus immediate authentication revocation. 6. Inspect queued request/outbox state, then enable `worker_enabled`. 7. Verify anonymization, preserved tenant/history data, outbox delivery, retries, and manual-review behavior before broad rollout. ## Verification - Account deletion unit tests: 2 passed, 43 assertions. - PHP lint, both OpenAPI YAML parses, runtime-DDL scan, destructive-scope scan, and `git diff --check` passed. - Full API/unit/integration evidence is required from exact-head CI; local Docker is unavailable and shared-vendor tests were explicitly discarded. ## Security notes - Schema mutation is CLI-only; web and cron paths perform read-only readiness checks. - Runtime behavior fails closed when schema/config/throttle/delivery prerequisites are unavailable.
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class account_deletion_http_exception extends RuntimeException
|
||||
{
|
||||
public function __construct(string $message, public readonly int $status)
|
||||
{
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Explicit account-deletion schema management.
|
||||
*
|
||||
* apply() must only be invoked by the dedicated CLI. Web requests and cron jobs
|
||||
* are deliberately limited to the read-only check().
|
||||
*/
|
||||
class account_deletion_schema_bootstrap
|
||||
{
|
||||
/** @return array{ready:bool,missing:array<int,string>} */
|
||||
public static function check(): array
|
||||
{
|
||||
global $db;
|
||||
$missing = [];
|
||||
foreach (['account_deletion_requests', 'account_deletion_credential_attempts', 'account_deletion_outbox'] as $table) {
|
||||
$tableSql = $db->escape_string($table);
|
||||
$result = $db->query("SHOW TABLES LIKE '$tableSql'");
|
||||
if ($result === false || $result->num_rows === 0) {
|
||||
$missing[] = 'table:' . $table;
|
||||
}
|
||||
}
|
||||
foreach (['users' => 'deleted_at', 'subusers' => 'deleted_at'] as $table => $column) {
|
||||
$result = $db->query("SHOW COLUMNS FROM `$table` LIKE '$column'");
|
||||
if ($result === false || $result->num_rows === 0) {
|
||||
$missing[] = 'column:' . $table . '.' . $column;
|
||||
}
|
||||
}
|
||||
if (!in_array('table:account_deletion_requests', $missing, true)) {
|
||||
$result = $db->query("SHOW COLUMNS FROM account_deletion_requests LIKE 'manual_review_required_at'");
|
||||
if ($result === false || $result->num_rows === 0) {
|
||||
$missing[] = 'column:account_deletion_requests.manual_review_required_at';
|
||||
}
|
||||
}
|
||||
return ['ready' => $missing === [], 'missing' => $missing];
|
||||
}
|
||||
|
||||
public static function apply(): void
|
||||
{
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
throw new \RuntimeException('Account deletion schema changes are CLI-only.');
|
||||
}
|
||||
global $db;
|
||||
self::execute("CREATE TABLE IF NOT EXISTS account_deletion_requests (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
request_id CHAR(36) NOT NULL,
|
||||
principal_type VARCHAR(16) NOT NULL,
|
||||
principal_id BIGINT UNSIGNED NOT NULL,
|
||||
customer_number_snapshot INT NULL,
|
||||
active_principal_key VARCHAR(191) NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'requested',
|
||||
policy_version VARCHAR(32) NOT NULL,
|
||||
retained_data_json LONGTEXT NOT NULL,
|
||||
request_ip VARCHAR(45) NULL,
|
||||
request_user_agent VARCHAR(512) NULL,
|
||||
retry_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
failure_code VARCHAR(191) NULL,
|
||||
requested_at DATETIME NOT NULL,
|
||||
processing_at DATETIME NULL,
|
||||
completed_at DATETIME NULL,
|
||||
next_attempt_at DATETIME NULL,
|
||||
manual_review_required_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_account_deletion_request_id (request_id),
|
||||
UNIQUE KEY uniq_account_deletion_active_principal (active_principal_key),
|
||||
INDEX idx_account_deletion_worker (status, next_attempt_at, requested_at),
|
||||
INDEX idx_account_deletion_principal (principal_type, principal_id, requested_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
|
||||
self::ensureColumn('account_deletion_requests', 'manual_review_required_at', 'DATETIME NULL AFTER `next_attempt_at`');
|
||||
self::execute("CREATE TABLE IF NOT EXISTS account_deletion_credential_attempts (
|
||||
throttle_key CHAR(64) NOT NULL,
|
||||
attempt_count INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
window_started_at DATETIME NOT NULL,
|
||||
blocked_until DATETIME NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (throttle_key), INDEX idx_account_deletion_throttle_expiry (updated_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
|
||||
self::execute("CREATE TABLE IF NOT EXISTS account_deletion_outbox (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
request_id CHAR(36) NOT NULL,
|
||||
event_type VARCHAR(64) NOT NULL,
|
||||
payload_json LONGTEXT NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||||
attempts INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
available_at DATETIME NOT NULL,
|
||||
processing_at DATETIME NULL,
|
||||
delivered_at DATETIME NULL,
|
||||
last_error VARCHAR(191) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id), UNIQUE KEY uniq_account_deletion_outbox_event (request_id, event_type),
|
||||
INDEX idx_account_deletion_outbox_delivery (status, available_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
|
||||
self::ensureColumn('account_deletion_outbox', 'processing_at', 'DATETIME NULL AFTER `available_at`');
|
||||
self::ensureColumn('users', 'deleted_at', 'DATETIME NULL AFTER `updated_at`');
|
||||
self::ensureColumn('subusers', 'deleted_at', 'DATETIME NULL AFTER `suspended_at`');
|
||||
self::ensureIndex('users', 'idx_users_deleted_at', '`deleted_at`');
|
||||
self::ensureIndex('subusers', 'idx_subusers_deleted_at', '`deleted_at`');
|
||||
}
|
||||
|
||||
private static function execute(string $sql): void
|
||||
{
|
||||
global $db;
|
||||
if ($db->query($sql) === false) {
|
||||
throw new \RuntimeException('Account deletion schema operation failed.');
|
||||
}
|
||||
}
|
||||
|
||||
private static function ensureColumn(string $table, string $column, string $definition): void
|
||||
{
|
||||
global $db;
|
||||
$result = $db->query("SHOW COLUMNS FROM `$table` LIKE '$column'");
|
||||
if ($result === false) throw new \RuntimeException('Unable to inspect account deletion schema.');
|
||||
if ($result->num_rows === 0) self::execute("ALTER TABLE `$table` ADD COLUMN `$column` $definition");
|
||||
}
|
||||
|
||||
private static function ensureIndex(string $table, string $index, string $columns): void
|
||||
{
|
||||
global $db;
|
||||
$result = $db->query("SHOW INDEX FROM `$table` WHERE Key_name = '$index'");
|
||||
if ($result === false) throw new \RuntimeException('Unable to inspect account deletion indexes.');
|
||||
if ($result->num_rows === 0) self::execute("ALTER TABLE `$table` ADD INDEX `$index` ($columns)");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace classes;
|
||||
|
||||
require_once WD . '/classes/account_deletion_service.php';
|
||||
|
||||
use classes\totp;
|
||||
use Exception;
|
||||
use interfaces\authentication_i;
|
||||
@@ -69,6 +71,10 @@ class authentication implements authentication_i
|
||||
|
||||
public function create_2fa_token(int $id, string $type): string
|
||||
{
|
||||
$principalType = $type === '2FA_VERIFICATION_SUBUSER' ? 'subuser' : 'customer';
|
||||
if (account_deletion_service::principalIsBlocked($principalType, $id)) {
|
||||
throw new Exception('Account unavailable');
|
||||
}
|
||||
// Create a temporary 2FA token
|
||||
$token = bin2hex(random_bytes(32));
|
||||
(new tokens_o())->create($id, $token, $type);
|
||||
@@ -100,6 +106,9 @@ class authentication implements authentication_i
|
||||
throw new \Exception('User not found for customer number: ' . $customer_number);
|
||||
}
|
||||
$user_id = $user->id;
|
||||
if (account_deletion_service::principalIsBlocked('customer', (int)$user_id)) {
|
||||
throw new Exception('Account unavailable');
|
||||
}
|
||||
// Save the token in the database
|
||||
(new tokens_o())->create($user_id, $token, 'AUTH_TOKEN');
|
||||
return $token;
|
||||
@@ -107,6 +116,9 @@ class authentication implements authentication_i
|
||||
|
||||
public function create_token_by_user_id(int $user_id): string
|
||||
{
|
||||
if (account_deletion_service::principalIsBlocked('customer', $user_id)) {
|
||||
throw new Exception('Account unavailable');
|
||||
}
|
||||
// Create a token
|
||||
$token = bin2hex(random_bytes(32));
|
||||
// Save the token in the database
|
||||
@@ -116,6 +128,9 @@ class authentication implements authentication_i
|
||||
|
||||
public function create_employee_token(int $employee_id): string
|
||||
{
|
||||
if (account_deletion_service::principalIsBlocked('customer', $employee_id)) {
|
||||
throw new Exception('Account unavailable');
|
||||
}
|
||||
// Create a token
|
||||
$token = bin2hex(random_bytes(32));
|
||||
// Save the token in the database
|
||||
@@ -123,13 +138,26 @@ class authentication implements authentication_i
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function create_impersonation_token(int $target_user_id, int $actor_user_id): string
|
||||
{
|
||||
if ($actor_user_id <= 0 || account_deletion_service::principalIsBlocked('customer', $target_user_id)) {
|
||||
throw new Exception('Account unavailable');
|
||||
}
|
||||
$token = bin2hex(random_bytes(32));
|
||||
(new tokens_o())->create($target_user_id, $token, 'AUTH_TOKEN_IMPERSONATION:' . $actor_user_id);
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function validate_token(string $token): bool
|
||||
{
|
||||
// First: try validating as a classic user auth token
|
||||
try {
|
||||
$dbToken = (new tokens_o())->getToken($token);
|
||||
if ($dbToken && $dbToken->id && $dbToken->type->value() === 'AUTH_TOKEN') {
|
||||
return true;
|
||||
if ($dbToken && $dbToken->id && $this->isClassicAuthTokenType((string)$dbToken->type->value())) {
|
||||
return !account_deletion_service::principalIsBlocked(
|
||||
'customer',
|
||||
(int)$dbToken->user_id->value()
|
||||
);
|
||||
}
|
||||
} catch (Exception) {
|
||||
// Ignore and continue to subuser session validation
|
||||
@@ -137,7 +165,7 @@ class authentication implements authentication_i
|
||||
// Fallback: try validating as a subuser session token
|
||||
$subuser = (new subusers_o())->getSubuserBySessionToken($token);
|
||||
if ($subuser !== null) {
|
||||
return true;
|
||||
return !account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -168,7 +196,10 @@ class authentication implements authentication_i
|
||||
if (!$token->id) {
|
||||
return false;
|
||||
}
|
||||
if ($token->type->value() !== 'AUTH_TOKEN') {
|
||||
if (!$this->isClassicAuthTokenType((string)$token->type->value())) {
|
||||
return false;
|
||||
}
|
||||
if (account_deletion_service::principalIsBlocked('customer', (int)$token->user_id->value())) {
|
||||
return false;
|
||||
}
|
||||
// Get the user from the database
|
||||
@@ -177,6 +208,11 @@ class authentication implements authentication_i
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function isClassicAuthTokenType(string $type): bool
|
||||
{
|
||||
return $type === 'AUTH_TOKEN' || str_starts_with($type, 'AUTH_TOKEN_IMPERSONATION:');
|
||||
}
|
||||
|
||||
public function get_plate_scanner(): plate_scanners_o|false
|
||||
{
|
||||
// Get the token from the headers
|
||||
@@ -227,6 +263,9 @@ class authentication implements authentication_i
|
||||
if ($subuser === null) {
|
||||
return false;
|
||||
}
|
||||
if (account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id)) {
|
||||
return false;
|
||||
}
|
||||
$customerNumberContext = null;
|
||||
if (isset($headers['X-Customer-Number'])) {
|
||||
$customerNumberContext = (int)$headers['X-Customer-Number'];
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// prevent direct access
|
||||
|
||||
use classes\backup_store;
|
||||
use classes\account_deletion_service;
|
||||
use classes\economic;
|
||||
use classes\economic_transfer_queue;
|
||||
use classes\invoice_period_flag_service;
|
||||
@@ -63,6 +64,12 @@ $response_cron = [];
|
||||
|
||||
// Define the cron tasks
|
||||
$cron_tasks = [
|
||||
'ProcessAccountDeletionRequestsCron' => [
|
||||
'interval' => 300,
|
||||
'last_run' => 0,
|
||||
'next_run' => 0,
|
||||
'function' => 'ProcessAccountDeletionRequestsCron',
|
||||
],
|
||||
// 'CheckUnfulfilledBookings' => [
|
||||
// 'interval' => 86400, // 24 hours
|
||||
// 'last_run' => 0,
|
||||
@@ -192,6 +199,17 @@ $cron_tasks = [
|
||||
],
|
||||
];
|
||||
|
||||
function ProcessAccountDeletionRequestsCron(): array
|
||||
{
|
||||
if (!account_deletion_service::workerEnabled()) {
|
||||
return ['processed' => 0, 'completed' => 0, 'failed' => 0, 'skipped' => true];
|
||||
}
|
||||
$result = (new account_deletion_service())->processPending(25);
|
||||
echo '[' . date('Y-m-d H:i:s') . '][CRON] Account deletion requests: '
|
||||
. (int)$result['completed'] . ' completed, ' . (int)$result['failed'] . " failed.\n";
|
||||
return $result;
|
||||
}
|
||||
|
||||
function ReplicaFailoverMonitorCron(): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
[
|
||||
'id' => 'account.process_deletion_requests',
|
||||
'legacy_name' => 'ProcessAccountDeletionRequestsCron',
|
||||
'name' => 'Process account deletion requests',
|
||||
'description' => 'Anonymizes requested customer and chauffeur accounts while retaining legally required records.',
|
||||
'module' => 'account',
|
||||
'handler' => 'ProcessAccountDeletionRequestsCron',
|
||||
'schedule' => ['type' => 'interval', 'seconds' => 300],
|
||||
'timeout_seconds' => 300,
|
||||
'estimated_duration_ms' => 2000,
|
||||
'priority' => 25,
|
||||
],
|
||||
];
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace objects;
|
||||
|
||||
use classes\account_deletion_service;
|
||||
use classes\db;
|
||||
use classes\object_property;
|
||||
use Exception;
|
||||
@@ -56,6 +57,13 @@ class customer_password_reset_keys_o extends db
|
||||
return bin2hex(random_bytes($length / 2));
|
||||
}
|
||||
|
||||
protected function selectedCustomerCanResetPassword(): bool
|
||||
{
|
||||
$customer = (new users_o())->getUserByCustomerNumber((int)$this->customer_id->value());
|
||||
return $customer->exists()
|
||||
&& !account_deletion_service::principalIsBlocked('customer', (int)$customer->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a valid reset key by token
|
||||
* @param string $token The token to search for
|
||||
@@ -79,6 +87,10 @@ class customer_password_reset_keys_o extends db
|
||||
$row = $result->fetch_assoc();
|
||||
$this->id = (int)$row['id'];
|
||||
$this->getObjectProperties();
|
||||
if (!$this->selectedCustomerCanResetPassword()) {
|
||||
$this->delete();
|
||||
return null;
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -121,6 +133,10 @@ class customer_password_reset_keys_o extends db
|
||||
$customer = new users_o();
|
||||
$customer->getUserByCustomerNumber((int)$this->customer_id->value());
|
||||
$customer->requireSelected();
|
||||
if (account_deletion_service::principalIsBlocked('customer', (int)$customer->id)) {
|
||||
$this->delete();
|
||||
throw new Exception('Invalid or expired token');
|
||||
}
|
||||
$customer->setPassword($new_password);
|
||||
$this->delete();
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ class passkeys_o extends db
|
||||
{
|
||||
global $db;
|
||||
$credentialId = $db->escape_string($credentialId);
|
||||
$where = "credential_id = '" . $credentialId . "'";
|
||||
$where = "credential_id = '" . $credentialId . "' AND deleted_at IS NULL";
|
||||
if ($userId !== null) {
|
||||
$where .= ' AND user_id = ' . (int)$userId;
|
||||
}
|
||||
@@ -106,4 +106,4 @@ class passkeys_o extends db
|
||||
{
|
||||
//TODO: Add cache invalidation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace objects;
|
||||
|
||||
use classes\account_deletion_service;
|
||||
use classes\db;
|
||||
use classes\object_property;
|
||||
use Exception;
|
||||
@@ -135,6 +136,9 @@ class subuser_grants_o extends db
|
||||
public function add(int $billing_customer_number, int $subuser, bool $enabled, ?string $note, ?array $permissions = self::defaultPermissions): subuser_grants_o
|
||||
{
|
||||
global $db;
|
||||
if (account_deletion_service::principalIsBlocked('subuser', $subuser)) {
|
||||
throw new Exception('Subuser account is unavailable');
|
||||
}
|
||||
$permissions = self::normalizePermissionsValue($permissions);
|
||||
$tmp = $this->add_object([
|
||||
'billing_customer_number' => (int)$billing_customer_number,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace objects;
|
||||
|
||||
use classes\authentication;
|
||||
use classes\account_deletion_service;
|
||||
use classes\db;
|
||||
use classes\object_property;
|
||||
use Exception;
|
||||
@@ -187,6 +188,9 @@ class subusers_o extends db
|
||||
public function setPassword(string $password): self
|
||||
{
|
||||
self::requireSelected();
|
||||
if (account_deletion_service::principalIsBlocked('subuser', (int)$this->id)) {
|
||||
throw new Exception('Account unavailable');
|
||||
}
|
||||
self::assertValidPassword($password);
|
||||
$this->password->set((string)password_hash($password, PASSWORD_DEFAULT));
|
||||
return $this;
|
||||
@@ -215,6 +219,9 @@ class subusers_o extends db
|
||||
public function generateSetupToken(): string
|
||||
{
|
||||
self::requireSelected();
|
||||
if (account_deletion_service::principalIsBlocked('subuser', (int)$this->id)) {
|
||||
throw new Exception('Account unavailable');
|
||||
}
|
||||
try {
|
||||
$token = bin2hex(random_bytes(16));
|
||||
} catch (Exception $e) {
|
||||
@@ -265,6 +272,10 @@ class subusers_o extends db
|
||||
}
|
||||
$subuser = (new subusers_o())->select((int)$subuser_id);
|
||||
$subuser->getObjectProperties();
|
||||
if (account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id)) {
|
||||
$this->invalidateSetupToken($token);
|
||||
return null;
|
||||
}
|
||||
return $subuser;
|
||||
}
|
||||
|
||||
@@ -351,6 +362,9 @@ class subusers_o extends db
|
||||
public function generateSession(): string
|
||||
{
|
||||
self::requireSelected();
|
||||
if (account_deletion_service::principalIsBlocked('subuser', (int)$this->id)) {
|
||||
throw new Exception('Account unavailable');
|
||||
}
|
||||
$session_token = bin2hex(random_bytes(32));
|
||||
$this->cache('session_token:' . $session_token, $this->id, 'subuser_sessions');
|
||||
$this->setCachedExpiration('session_token:' . $session_token, 7 * 24 * 60 * 60, 'subuser_sessions'); // Set the session to expire after 7 days
|
||||
@@ -375,6 +389,10 @@ class subusers_o extends db
|
||||
if ($subuser_id !== null) {
|
||||
$subuser = (new subusers_o())->select((int)$subuser_id);
|
||||
$subuser->getObjectProperties();
|
||||
if (account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id)) {
|
||||
$this->invalidateSessionToken($token);
|
||||
return null;
|
||||
}
|
||||
return $subuser;
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace objects;
|
||||
|
||||
use classes\account_deletion_service;
|
||||
use classes\db;
|
||||
use classes\customer_rule_product_restriction_schema_bootstrap;
|
||||
use classes\customer_name_cache_payload_builder;
|
||||
@@ -2049,6 +2050,9 @@ class users_o extends db
|
||||
public function generatePasswordResetLink(): string
|
||||
{
|
||||
self::requireSelected();
|
||||
if (account_deletion_service::principalIsBlocked('customer', (int)$this->id)) {
|
||||
throw new Exception('Account unavailable');
|
||||
}
|
||||
// Generate token
|
||||
$token = customer_password_reset_keys_o::generateToken();
|
||||
|
||||
|
||||
@@ -2935,6 +2935,156 @@ paths:
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
|
||||
/account/deletion:
|
||||
get:
|
||||
tags:
|
||||
- Security
|
||||
summary: Describe account deletion requirements
|
||||
description: Returns the authenticated customer or chauffeur deletion state, required confirmation phrase, and categories retained for legal obligations.
|
||||
operationId: getAccountDeletion
|
||||
responses:
|
||||
'200':
|
||||
description: Account deletion requirements retrieved successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- principal_type
|
||||
- status
|
||||
- confirmation_phrase
|
||||
- password_required
|
||||
- two_factor_required
|
||||
- access_effect
|
||||
- retained_data_categories
|
||||
- privacy_policy_version
|
||||
properties:
|
||||
principal_type:
|
||||
type: string
|
||||
enum: [customer, subuser]
|
||||
status:
|
||||
type: string
|
||||
enum: [available, requested, processing, failed, manual_review, completed]
|
||||
confirmation_phrase:
|
||||
type: string
|
||||
enum: [SLET MIN KONTO]
|
||||
password_required:
|
||||
type: boolean
|
||||
description: False for authenticated passkey-only accounts that have no password.
|
||||
two_factor_required:
|
||||
type: boolean
|
||||
access_effect:
|
||||
type: string
|
||||
retained_data_categories:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum: [invoices_payments_accounting, orders_wash_history, security_audit_logs, legal_obligations, customer_reference, driver_reference]
|
||||
privacy_policy_version:
|
||||
type: string
|
||||
request_id:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
requested_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
post:
|
||||
tags:
|
||||
- Security
|
||||
summary: Request deletion of the authenticated account
|
||||
description: Reauthenticates the principal, records an auditable deletion request, and revokes access immediately. A background worker subsequently anonymizes personal account fields while preserving legally required history.
|
||||
operationId: requestAccountDeletion
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- confirmation
|
||||
- acknowledge_legal_retention
|
||||
properties:
|
||||
password:
|
||||
type: string
|
||||
format: password
|
||||
description: Required when password_required is true; omit for passkey-only accounts.
|
||||
passkey_challenge_token:
|
||||
type: string
|
||||
description: Required for passwordless accounts; issued only by the deletion-specific challenge endpoint.
|
||||
passkey_credential:
|
||||
type: object
|
||||
description: Fresh WebAuthn assertion bound to passkey_challenge_token and the authenticated principal.
|
||||
two_factor_code:
|
||||
type: string
|
||||
description: Required when two-factor authentication is enabled.
|
||||
confirmation:
|
||||
type: string
|
||||
enum: [SLET MIN KONTO]
|
||||
acknowledge_legal_retention:
|
||||
type: boolean
|
||||
enum: [true]
|
||||
responses:
|
||||
'202':
|
||||
description: Deletion request accepted and account access revoked
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- request_id
|
||||
- status
|
||||
- requested_at
|
||||
- access_revoked
|
||||
- retained_data_categories
|
||||
properties:
|
||||
request_id:
|
||||
type: string
|
||||
format: uuid
|
||||
status:
|
||||
type: string
|
||||
enum: [requested, processing, failed, manual_review, completed]
|
||||
requested_at:
|
||||
type: string
|
||||
format: date-time
|
||||
access_revoked:
|
||||
type: boolean
|
||||
enum: [true]
|
||||
retained_data_categories:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum: [invoices_payments_accounting, orders_wash_history, security_audit_logs, legal_obligations, customer_reference, driver_reference]
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'429':
|
||||
$ref: '#/components/responses/TooManyRequests'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
|
||||
/account/deletion/passkey/challenge:
|
||||
post:
|
||||
tags: [Security]
|
||||
summary: Create a deletion-specific WebAuthn challenge
|
||||
description: Creates a short-lived, single-use challenge bound to the authenticated passwordless principal. A normal sign-in assertion cannot authorize deletion.
|
||||
operationId: createAccountDeletionPasskeyChallenge
|
||||
responses:
|
||||
'200':
|
||||
description: Deletion-specific challenge created
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
|
||||
/auth/2fa/setup:
|
||||
post:
|
||||
tags:
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace routes;
|
||||
|
||||
use classes\account_deletion_http_exception;
|
||||
use classes\account_deletion_service;
|
||||
use Throwable;
|
||||
use traits\route_t;
|
||||
|
||||
class accountDeletionRoute
|
||||
{
|
||||
use route_t;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->get('/account/deletion', function () {
|
||||
global $response;
|
||||
try {
|
||||
if (!account_deletion_service::apiEnabled()) {
|
||||
$response->error('Account deletion is unavailable', 404);
|
||||
return;
|
||||
}
|
||||
$service = new account_deletion_service();
|
||||
$principal = $service->currentPrincipal();
|
||||
$response->success($service->state($principal));
|
||||
} catch (account_deletion_http_exception $exception) {
|
||||
$response->error($exception->getMessage(), $exception->status);
|
||||
} catch (Throwable $throwable) {
|
||||
$response->error('Unable to load account deletion status', 500);
|
||||
}
|
||||
});
|
||||
|
||||
$this->post('/account/deletion', function () {
|
||||
global $response;
|
||||
try {
|
||||
if (!account_deletion_service::apiEnabled()) {
|
||||
$response->error('Account deletion is unavailable', 404);
|
||||
return;
|
||||
}
|
||||
$service = new account_deletion_service();
|
||||
$principal = $service->currentPrincipal();
|
||||
$payload = $service->request(
|
||||
$principal,
|
||||
$this->getParametersAsArray(),
|
||||
isset($_SERVER['REMOTE_ADDR']) ? (string)$_SERVER['REMOTE_ADDR'] : null,
|
||||
isset($_SERVER['HTTP_USER_AGENT']) ? (string)$_SERVER['HTTP_USER_AGENT'] : null,
|
||||
);
|
||||
$response->success($payload, 202);
|
||||
} catch (account_deletion_http_exception $exception) {
|
||||
$response->error($exception->getMessage(), $exception->status);
|
||||
} catch (Throwable $throwable) {
|
||||
error_log('[account-deletion] Request failed: ' . $throwable->getMessage());
|
||||
$response->error('Unable to request account deletion', 500);
|
||||
}
|
||||
});
|
||||
|
||||
$this->post('/account/deletion/passkey/challenge', function () {
|
||||
global $response;
|
||||
try {
|
||||
if (!account_deletion_service::apiEnabled()) {
|
||||
$response->error('Account deletion is unavailable', 404);
|
||||
return;
|
||||
}
|
||||
$service = new account_deletion_service();
|
||||
$response->success($service->passkeyChallenge($service->currentPrincipal()));
|
||||
} catch (account_deletion_http_exception $exception) {
|
||||
$response->error($exception->getMessage(), $exception->status);
|
||||
} catch (Throwable $throwable) {
|
||||
error_log('[account-deletion] Passkey challenge failed: ' . $throwable->getMessage());
|
||||
$response->error('Unable to create deletion passkey challenge', 500);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace routes;
|
||||
|
||||
use classes\authentication;
|
||||
use classes\account_deletion_service;
|
||||
use classes\economic;
|
||||
use classes\email;
|
||||
use classes\release_manager;
|
||||
@@ -22,6 +23,7 @@ use objects\passkeys_o;
|
||||
use traits\route_t;
|
||||
|
||||
require_once WD . '/classes/security_policy_service.php';
|
||||
require_once WD . '/classes/account_deletion_service.php';
|
||||
|
||||
class authRoute
|
||||
{
|
||||
@@ -67,7 +69,7 @@ class authRoute
|
||||
|
||||
$passkeys = new passkeys_o();
|
||||
$passkeys->setAdditionalWhereClause(
|
||||
'`user_id` = ' . (int)$userId . ' AND `is_subuser` = ' . ($isSubuser ? '1' : '0')
|
||||
'`user_id` = ' . (int)$userId . ' AND `is_subuser` = ' . ($isSubuser ? '1' : '0') . ' AND `deleted_at` IS NULL'
|
||||
);
|
||||
$list = $passkeys->listObjectsWithPaginationIfSet(function ($o) {
|
||||
$transports = null;
|
||||
@@ -125,6 +127,10 @@ class authRoute
|
||||
$this->observeLoginFailure('customer', $data['customer_number'], ['reason' => 'missing_user_or_password']);
|
||||
$response->error('Invalid credentials', 401);
|
||||
}
|
||||
if (account_deletion_service::principalIsBlocked('customer', (int)$user->id)) {
|
||||
$this->observeLoginFailure('customer', $data['customer_number'], ['reason' => 'account_unavailable']);
|
||||
$response->error('Invalid credentials', 401);
|
||||
}
|
||||
|
||||
$isCredentialsValid = false;
|
||||
try {
|
||||
@@ -312,14 +318,21 @@ class authRoute
|
||||
|
||||
if ($token_type === '2FA_VERIFICATION_USER') {
|
||||
$user = (new users_o())->getUserById($user_id);
|
||||
if ($user->exists() && $auth->verify_2fa_code($user, $code)) {
|
||||
if (
|
||||
$user->exists()
|
||||
&& !account_deletion_service::principalIsBlocked('customer', $user_id)
|
||||
&& $auth->verify_2fa_code($user, $code)
|
||||
) {
|
||||
$token_o->delete($token_str);
|
||||
$new_token = $auth->create_employee_token($user_id); // Works for both users and employees
|
||||
$response->success(['token' => $new_token]);
|
||||
}
|
||||
} elseif ($token_type === '2FA_VERIFICATION_SUBUSER') {
|
||||
$subuser = (new subusers_o())->select($user_id);
|
||||
if ($auth->verify_2fa_code($subuser, $code)) {
|
||||
if (
|
||||
!account_deletion_service::principalIsBlocked('subuser', $user_id)
|
||||
&& $auth->verify_2fa_code($subuser, $code)
|
||||
) {
|
||||
$token_o->delete($token_str);
|
||||
$new_token = $subuser->generateSession();
|
||||
$response->success(['session' => $new_token]);
|
||||
@@ -626,6 +639,9 @@ class authRoute
|
||||
// For security reasons, don't reveal if the user exists
|
||||
$response->success(['message' => 'If the customer exists, a password reset email has been sent.']);
|
||||
}
|
||||
if (account_deletion_service::principalIsBlocked('customer', (int)$user->id)) {
|
||||
$response->success(['message' => 'If the customer exists, a password reset email has been sent.']);
|
||||
}
|
||||
|
||||
$email_address = $user->email->value();
|
||||
if (empty($email_address)) {
|
||||
@@ -746,8 +762,10 @@ class authRoute
|
||||
}
|
||||
|
||||
if ($subuser !== null) {
|
||||
$user_id = (int)$subuser->id;
|
||||
$allowCredentials = $this->passkeyAllowCredentials($user_id, true);
|
||||
if (!account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id)) {
|
||||
$user_id = (int)$subuser->id;
|
||||
$allowCredentials = $this->passkeyAllowCredentials($user_id, true);
|
||||
}
|
||||
}
|
||||
} elseif ($customer_number !== null) {
|
||||
$principal_type = 'user';
|
||||
@@ -759,8 +777,10 @@ class authRoute
|
||||
$user = (new users_o())->getUserByCustomerNumber($customer_number);
|
||||
|
||||
if ($user->exists()) {
|
||||
$user_id = (int)$user->id;
|
||||
$allowCredentials = $this->passkeyAllowCredentials($user_id, false);
|
||||
if (!account_deletion_service::principalIsBlocked('customer', (int)$user->id)) {
|
||||
$user_id = (int)$user->id;
|
||||
$allowCredentials = $this->passkeyAllowCredentials($user_id, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -870,6 +890,11 @@ class authRoute
|
||||
// Success → issue session token accordingly and delete the challenge token
|
||||
$issued_to_user_id = (int)$passkey->user_id->value();
|
||||
$is_subuser = (bool)$passkey->is_subuser->value();
|
||||
if (account_deletion_service::principalIsBlocked($is_subuser ? 'subuser' : 'customer', $issued_to_user_id)) {
|
||||
$token_o->delete($challenge_token);
|
||||
$this->clearPasskeyChallengePrincipal($challenge_token);
|
||||
$response->error('Invalid credential', 401);
|
||||
}
|
||||
if (
|
||||
($challengePrincipalType === 'subuser' && !$is_subuser)
|
||||
|| ($challengePrincipalType === 'user' && $is_subuser)
|
||||
|
||||
@@ -31,7 +31,10 @@ class intimidateRoute
|
||||
// Log the incident
|
||||
(new logs_o())->add('auth', 'global', 1, $user->id, 'AUTH_SUCCESS_INTIMIDATE', 'Created intimidate token for customer: ' . $data['user_id']);
|
||||
// If the credentials are valid, create a token (We're using the create_employee_token, since it's using user_id, and not customer_numbers.)
|
||||
$token = (new authentication())->create_employee_token($data['user_id']);
|
||||
$token = (new authentication())->create_impersonation_token(
|
||||
(int)$data['user_id'],
|
||||
(int)$user->id
|
||||
);
|
||||
// Return the token
|
||||
$response->success(['token' => $token]);
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace routes;
|
||||
|
||||
use classes\authentication;
|
||||
use classes\account_deletion_service;
|
||||
use classes\economic;
|
||||
use classes\email;
|
||||
use classes\gatewayapi;
|
||||
@@ -386,6 +387,10 @@ class subusersRoute
|
||||
{
|
||||
global $response;
|
||||
|
||||
if (account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id)) {
|
||||
$response->error('Chauffeur account is unavailable', 409);
|
||||
}
|
||||
|
||||
$customerNumber = $this->resolveDirectLoginCustomerNumber($subuser);
|
||||
try {
|
||||
$sessionToken = $subuser->generateSession();
|
||||
@@ -770,8 +775,17 @@ class subusersRoute
|
||||
$response->error('Invalid credentials', 401);
|
||||
}
|
||||
|
||||
private function rejectBlockedSubuser(int $subuserId): void
|
||||
{
|
||||
global $response;
|
||||
if (account_deletion_service::principalIsBlocked('subuser', $subuserId)) {
|
||||
$response->error('Subuser account is unavailable', 409);
|
||||
}
|
||||
}
|
||||
|
||||
private function issueSetupInvite(subusers_o $subuser): array
|
||||
{
|
||||
$this->rejectBlockedSubuser((int)$subuser->id);
|
||||
if (!$subuser->requiresSetup()) {
|
||||
return [
|
||||
'setup_token' => null,
|
||||
@@ -1455,6 +1469,7 @@ class subusersRoute
|
||||
global $response;
|
||||
|
||||
$grant = $this->getGrantForScopedUserOrFail($grantId, $customerNumber);
|
||||
$this->rejectBlockedSubuser((int)$grant->subuser->value());
|
||||
$updates = [];
|
||||
$templateAccess = $this->parseAccessTemplatePayload();
|
||||
if ($templateAccess !== null) {
|
||||
@@ -1735,6 +1750,7 @@ class subusersRoute
|
||||
$subuser_id = (int)self::getParameter('subuser_id');
|
||||
self::requireType($customer_number, self::type_int());
|
||||
self::requireType($subuser_id, self::type_int());
|
||||
$this->rejectBlockedSubuser($subuser_id);
|
||||
|
||||
if (!self::hasPermission($permission_other, (int)$customer_number)) {
|
||||
$this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_ADD, (int)$customer_number);
|
||||
@@ -1793,6 +1809,7 @@ class subusersRoute
|
||||
if (!$grant->exists()) {
|
||||
$response->error('Grant not found', 404);
|
||||
}
|
||||
$this->rejectBlockedSubuser((int)$grant->subuser->value());
|
||||
|
||||
$targetCustomer = (int)$grant->billing_customer_number->value();
|
||||
if (!self::hasPermission($permission_other, $targetCustomer)) {
|
||||
@@ -2103,6 +2120,9 @@ class subusersRoute
|
||||
if ($subuser === null) {
|
||||
$this->subuserAuthFailure();
|
||||
}
|
||||
if (account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id)) {
|
||||
$this->subuserAuthFailure();
|
||||
}
|
||||
self::requireParameters(['password']);
|
||||
$password = (string)self::getParameter('password');
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
usesApiSuite();
|
||||
|
||||
function enableAccountDeletionForApiTest(): void
|
||||
{
|
||||
api_fixtures()->setModuleConfig('account_deletion', 'api_enabled', 'true', 'bool');
|
||||
api_fixtures()->setModuleConfig('account_deletion', 'worker_enabled', 'true', 'bool');
|
||||
}
|
||||
|
||||
/** @return array{processed:int,completed:int,failed:int} */
|
||||
function runAccountDeletionWorkerForApiTest(): array
|
||||
{
|
||||
$target = strtolower(trim((string)(getenv('CONFIG_DB_TARGET') ?: 'debug')));
|
||||
$debug = $target === 'debug';
|
||||
$value = static function (string $key) use ($debug): string {
|
||||
$debugValue = trim((string)getenv('CONFIG_DB_DEBUG_' . $key));
|
||||
if ($debug && $debugValue !== '') {
|
||||
return $debugValue;
|
||||
}
|
||||
return trim((string)getenv('CONFIG_DB_' . $key));
|
||||
};
|
||||
$workerDb = new \classes\db([
|
||||
'host' => $value('HOST'),
|
||||
'user' => $value('USER'),
|
||||
'password' => $value('PASSWORD'),
|
||||
'database' => $value('DATABASE'),
|
||||
'port' => (int)($value('PORT') ?: '3306'),
|
||||
]);
|
||||
$workerDb->connect();
|
||||
$previousDb = $GLOBALS['db'] ?? null;
|
||||
$previousTimezone = date_default_timezone_get();
|
||||
date_default_timezone_set(trim((string)(getenv('CONFIG_TIMEZONE') ?: 'Europe/Copenhagen')));
|
||||
$GLOBALS['db'] = $workerDb;
|
||||
|
||||
try {
|
||||
return (new \classes\account_deletion_service())->processPending(25);
|
||||
} finally {
|
||||
$workerDb->close();
|
||||
date_default_timezone_set($previousTimezone);
|
||||
if ($previousDb !== null) {
|
||||
$GLOBALS['db'] = $previousDb;
|
||||
} else {
|
||||
unset($GLOBALS['db']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('describes the authenticated customer deletion contract', function (): void {
|
||||
enableAccountDeletionForApiTest();
|
||||
api_test_covers('GET /account/deletion', 'happy');
|
||||
$session = api_fixtures()->createUserSession([], [
|
||||
'display_name' => 'Deletion Contract Customer',
|
||||
'password_plaintext' => 'Secret123!',
|
||||
]);
|
||||
|
||||
$response = api_client()->get('/account/deletion', $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($response->data())
|
||||
->toHaveKey('principal_type', 'customer')
|
||||
->toHaveKey('status', 'available')
|
||||
->toHaveKey('confirmation_phrase', 'SLET MIN KONTO')
|
||||
->toHaveKey('password_required', true)
|
||||
->toHaveKey('two_factor_required', false)
|
||||
->toHaveKey('privacy_policy_version', '2026-07-20')
|
||||
->and($response->data()['retained_data_categories'] ?? null)
|
||||
->toBeArray()
|
||||
->toContain('invoices_payments_accounting')
|
||||
->toContain('customer_reference');
|
||||
});
|
||||
|
||||
it('requires authentication before describing account deletion', function (): void {
|
||||
enableAccountDeletionForApiTest();
|
||||
api_test_covers('GET /account/deletion', 'auth');
|
||||
|
||||
api_client()->get('/account/deletion', [
|
||||
'Authorization' => 'Bearer invalid-account-deletion-token',
|
||||
])
|
||||
->assertStatus(401)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Unauthorized');
|
||||
});
|
||||
|
||||
it('rejects employee and superuser-style classic accounts without a customer number', function (): void {
|
||||
enableAccountDeletionForApiTest();
|
||||
$session = api_fixtures()->createUserSession([], [
|
||||
'customer_number' => 0,
|
||||
'display_name' => 'Administrative Account',
|
||||
'password_plaintext' => 'Secret123!',
|
||||
]);
|
||||
|
||||
api_client()->get('/account/deletion', $session['headers'])
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Account deletion is only available to customer accounts');
|
||||
|
||||
api_client()->post('/account/deletion', [
|
||||
'password' => 'Secret123!',
|
||||
'confirmation' => 'SLET MIN KONTO',
|
||||
'acknowledge_legal_retention' => true,
|
||||
], $session['headers'])
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Account deletion is only available to customer accounts');
|
||||
|
||||
expect(api_test_runtime()->queryOne(
|
||||
'SELECT deleted_at FROM users WHERE id = ' . (int)$session['user']['id'] . ' LIMIT 1'
|
||||
)['deleted_at'] ?? null)->toBeNull();
|
||||
});
|
||||
|
||||
it('rejects deletion while a support actor is impersonating a customer', function (): void {
|
||||
enableAccountDeletionForApiTest();
|
||||
$session = api_fixtures()->createUserSession([], [
|
||||
'display_name' => 'Impersonated Customer',
|
||||
'password_plaintext' => 'Secret123!',
|
||||
]);
|
||||
api_test_runtime()->db()->query(
|
||||
"UPDATE tokens SET type = 'AUTH_TOKEN_IMPERSONATION:4242' WHERE token = '" . $session['token'] . "'"
|
||||
);
|
||||
|
||||
api_client()->get('/account/deletion', $session['headers'])
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Account deletion is unavailable during support impersonation');
|
||||
|
||||
api_client()->post('/account/deletion', [
|
||||
'password' => 'Secret123!',
|
||||
'confirmation' => 'SLET MIN KONTO',
|
||||
'acknowledge_legal_retention' => true,
|
||||
], $session['headers'])
|
||||
->assertStatus(403)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Account deletion is unavailable during support impersonation');
|
||||
|
||||
expect(api_test_runtime()->queryOne(
|
||||
'SELECT deleted_at FROM users WHERE id = ' . (int)$session['user']['id'] . ' LIMIT 1'
|
||||
)['deleted_at'] ?? null)->toBeNull();
|
||||
});
|
||||
|
||||
it('requires a fresh deletion-specific assertion for a passkey-only customer', function (): void {
|
||||
enableAccountDeletionForApiTest();
|
||||
api_fixtures()->setModuleConfig('email', 'mailersend_enabled', 'false', 'bool');
|
||||
$session = api_fixtures()->createUserSession([], [
|
||||
'display_name' => 'Passkey Only Customer',
|
||||
'password_plaintext' => 'Secret123!',
|
||||
]);
|
||||
api_test_runtime()->db()->query(
|
||||
'UPDATE users SET password = NULL WHERE id = ' . (int)$session['user']['id']
|
||||
);
|
||||
api_fixtures()->createPasskey([
|
||||
'user_id' => (int)$session['user']['id'],
|
||||
'is_subuser' => false,
|
||||
]);
|
||||
|
||||
$description = api_client()->get('/account/deletion', $session['headers'])
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
expect($description->data())
|
||||
->toBeArray()
|
||||
->toHaveKey('password_required', false);
|
||||
|
||||
api_client()->post('/account/deletion', [
|
||||
'confirmation' => 'SLET MIN KONTO',
|
||||
'acknowledge_legal_retention' => true,
|
||||
], $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('A fresh deletion passkey assertion is required');
|
||||
|
||||
api_client()->post('/account/deletion/passkey/challenge', [], $session['headers'])
|
||||
->assertStatus(200)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
});
|
||||
|
||||
it('requires password, exact confirmation, and legal-retention acknowledgement', function (): void {
|
||||
enableAccountDeletionForApiTest();
|
||||
api_test_covers('POST /account/deletion', 'failure');
|
||||
$session = api_fixtures()->createUserSession([], [
|
||||
'password_plaintext' => 'Secret123!',
|
||||
]);
|
||||
|
||||
api_client()->post('/account/deletion', [
|
||||
'password' => 'Secret123!',
|
||||
'confirmation' => 'slet min konto',
|
||||
'acknowledge_legal_retention' => true,
|
||||
], $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Confirmation phrase does not match');
|
||||
|
||||
api_client()->post('/account/deletion', [
|
||||
'password' => 'Secret123!',
|
||||
'confirmation' => 'SLET MIN KONTO',
|
||||
'acknowledge_legal_retention' => false,
|
||||
], $session['headers'])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Legal retention acknowledgement is required');
|
||||
|
||||
api_client()->post('/account/deletion', [
|
||||
'password' => 'incorrect',
|
||||
'confirmation' => 'SLET MIN KONTO',
|
||||
'acknowledge_legal_retention' => true,
|
||||
], $session['headers'])
|
||||
->assertStatus(401)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Invalid credentials');
|
||||
|
||||
for ($attempt = 0; $attempt < 2; $attempt++) {
|
||||
api_client()->post('/account/deletion', [
|
||||
'password' => 'incorrect',
|
||||
'confirmation' => 'SLET MIN KONTO',
|
||||
'acknowledge_legal_retention' => true,
|
||||
], $session['headers'])
|
||||
->assertStatus(401)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false);
|
||||
}
|
||||
|
||||
api_client()->post('/account/deletion', [
|
||||
'password' => 'incorrect',
|
||||
'confirmation' => 'SLET MIN KONTO',
|
||||
'acknowledge_legal_retention' => true,
|
||||
], $session['headers'])
|
||||
->assertStatus(429)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Too many deletion confirmation attempts');
|
||||
});
|
||||
|
||||
it('deletes only the customer login identity and preserves shared tenant data', function (): void {
|
||||
enableAccountDeletionForApiTest();
|
||||
api_test_covers('POST /account/deletion', 'happy');
|
||||
api_fixtures()->setModuleConfig('email', 'mailersend_enabled', 'false', 'bool');
|
||||
$session = api_fixtures()->createUserSession([], [
|
||||
'display_name' => 'Deletion Customer',
|
||||
'password_plaintext' => 'Secret123!',
|
||||
]);
|
||||
$driver = api_fixtures()->createSubuser(['name' => 'Independent Driver']);
|
||||
$grantId = api_fixtures()->grantSubuser(
|
||||
(int)$driver['id'],
|
||||
(int)$session['user']['customer_number'],
|
||||
['SELFSERVE_LIST'],
|
||||
);
|
||||
$passkey = api_fixtures()->createPasskey([
|
||||
'user_id' => (int)$session['user']['id'],
|
||||
'is_subuser' => false,
|
||||
]);
|
||||
$challengeToken = bin2hex(random_bytes(32));
|
||||
$resetToken = bin2hex(random_bytes(16));
|
||||
api_test_runtime()->db()->query(
|
||||
"INSERT INTO tokens (user_id, type, token) VALUES ("
|
||||
. (int)$session['user']['id'] . ", 'PASSKEY_CHALLENGE', '$challengeToken')"
|
||||
);
|
||||
api_test_runtime()->db()->query(
|
||||
"INSERT INTO customer_password_reset_keys (customer_id, token, note) VALUES ("
|
||||
. (int)$session['user']['customer_number'] . ", '$resetToken', 'Deletion revocation test')"
|
||||
);
|
||||
api_test_runtime()->db()->query(
|
||||
"INSERT INTO bookings (customer_number, contact_email, washCertificateEmail, date, status, notes, data)
|
||||
VALUES (" . (int)$session['user']['customer_number'] . ", 'booking@example.test',
|
||||
'certificate@example.test', DATE_ADD(NOW(), INTERVAL 1 DAY), 'pending', 'Private note', '{\"phone\":\"123\"}')"
|
||||
);
|
||||
$bookingId = (int)api_test_runtime()->db()->insert_id;
|
||||
api_test_runtime()->db()->query(
|
||||
"INSERT INTO order_bookings (customer_number, department, reg_1, datetime, note, reference, po, items)
|
||||
VALUES (" . (int)$session['user']['customer_number'] . ", 1, 'PRIVATE-PLATE',
|
||||
DATE_ADD(NOW(), INTERVAL 1 DAY), 'Private note', 'Private reference', 'Private PO', '[]')"
|
||||
);
|
||||
$orderBookingId = (int)api_test_runtime()->db()->insert_id;
|
||||
api_test_runtime()->db()->query(
|
||||
"INSERT INTO customer_vehicles (customer_id, type, reg, wash_subscription, notes, reference)
|
||||
VALUES (" . (int)$session['user']['customer_number'] . ", 1, 'PRIVATE-REG', 1,
|
||||
'Private vehicle note', 'Private vehicle reference')"
|
||||
);
|
||||
$vehicleId = (int)api_test_runtime()->db()->insert_id;
|
||||
|
||||
$response = api_client()->post('/account/deletion', [
|
||||
'password' => 'Secret123!',
|
||||
'confirmation' => 'SLET MIN KONTO',
|
||||
'acknowledge_legal_retention' => true,
|
||||
], $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(202)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
expect($response->data())
|
||||
->toHaveKey('status', 'requested')
|
||||
->toHaveKey('access_revoked', true)
|
||||
->and($response->data()['retained_data_categories'] ?? [])
|
||||
->toContain('orders_wash_history')
|
||||
->toContain('customer_reference')
|
||||
->and($response->data()['request_id'] ?? null)
|
||||
->toBeString()
|
||||
->toHaveLength(36);
|
||||
|
||||
api_client()->get('/auth/session', $session['headers'])
|
||||
->assertStatus(401)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false);
|
||||
|
||||
$request = api_test_runtime()->queryOne(
|
||||
"SELECT * FROM account_deletion_requests WHERE principal_type = 'customer'"
|
||||
. ' AND principal_id = ' . (int)$session['user']['id'] . ' LIMIT 1'
|
||||
);
|
||||
$user = api_test_runtime()->queryOne(
|
||||
'SELECT id, deleted_at FROM users WHERE id = ' . (int)$session['user']['id'] . ' LIMIT 1'
|
||||
);
|
||||
$token = api_test_runtime()->queryOne(
|
||||
'SELECT id FROM tokens WHERE id > 0 AND token = ' . "'" . $session['token'] . "' LIMIT 1"
|
||||
);
|
||||
$grant = api_test_runtime()->queryOne('SELECT * FROM subuser_grants WHERE id = ' . $grantId . ' LIMIT 1');
|
||||
$storedPasskey = api_test_runtime()->queryOne('SELECT * FROM passkeys WHERE id = ' . (int)$passkey['id'] . ' LIMIT 1');
|
||||
$storedDriver = api_test_runtime()->queryOne('SELECT id, name FROM subusers WHERE id = ' . (int)$driver['id'] . ' LIMIT 1');
|
||||
$challenge = api_test_runtime()->queryOne(
|
||||
"SELECT id FROM tokens WHERE token = '$challengeToken' LIMIT 1"
|
||||
);
|
||||
$resetKey = api_test_runtime()->queryOne(
|
||||
"SELECT deleted_at FROM customer_password_reset_keys WHERE token = '$resetToken' LIMIT 1"
|
||||
);
|
||||
|
||||
expect($request)
|
||||
->not->toBeNull()
|
||||
->toHaveKey('status', 'requested')
|
||||
->and($user['deleted_at'] ?? null)->not->toBeNull()
|
||||
->and($token)->toBeNull()
|
||||
->and((int)($grant['enabled'] ?? 0))->toBe(1)
|
||||
->and($grant['deleted_at'] ?? null)->toBeNull()
|
||||
->and($storedPasskey['deleted_at'] ?? null)->not->toBeNull()
|
||||
->and($challenge)->toBeNull()
|
||||
->and($resetKey['deleted_at'] ?? null)->toBeNull()
|
||||
->and($storedDriver['name'] ?? null)->toBe('Independent Driver');
|
||||
|
||||
expect(runAccountDeletionWorkerForApiTest())
|
||||
->toMatchArray(['processed' => 1, 'completed' => 1, 'failed' => 0]);
|
||||
|
||||
$completedRequest = api_test_runtime()->queryOne(
|
||||
'SELECT status, active_principal_key, completed_at FROM account_deletion_requests WHERE id = '
|
||||
. (int)$request['id'] . ' LIMIT 1'
|
||||
);
|
||||
$anonymizedUser = api_test_runtime()->queryOne(
|
||||
'SELECT customer_number, display_name, email, phone, password FROM users WHERE id = '
|
||||
. (int)$session['user']['id'] . ' LIMIT 1'
|
||||
);
|
||||
$anonymizedBooking = api_test_runtime()->queryOne(
|
||||
'SELECT contact_email, washCertificateEmail, status, notes, data FROM bookings WHERE id = '
|
||||
. $bookingId . ' LIMIT 1'
|
||||
);
|
||||
$cancelledOrderBooking = api_test_runtime()->queryOne(
|
||||
'SELECT reg_1, note, reference, po, items, deleted_at FROM order_bookings WHERE id = '
|
||||
. $orderBookingId . ' LIMIT 1'
|
||||
);
|
||||
$anonymizedVehicle = api_test_runtime()->queryOne(
|
||||
'SELECT reg, wash_subscription, notes, reference, deleted_at FROM customer_vehicles WHERE id = '
|
||||
. $vehicleId . ' LIMIT 1'
|
||||
);
|
||||
expect($completedRequest)
|
||||
->toHaveKey('status', 'completed')
|
||||
->and($completedRequest['active_principal_key'] ?? null)->toBeNull()
|
||||
->and($completedRequest['completed_at'] ?? null)->not->toBeNull()
|
||||
->and((int)($anonymizedUser['customer_number'] ?? 0))->toBe((int)$session['user']['customer_number'])
|
||||
->and($anonymizedUser['display_name'] ?? null)->toBe('Slettet konto')
|
||||
->and($anonymizedUser['email'] ?? null)->toBeNull()
|
||||
->and($anonymizedUser['phone'] ?? null)->toBeNull()
|
||||
->and($anonymizedUser['password'] ?? null)->toBeNull()
|
||||
->and($anonymizedBooking['contact_email'] ?? null)->toBe('booking@example.test')
|
||||
->and($anonymizedBooking['status'] ?? null)->toBe('pending')
|
||||
->and($anonymizedBooking['notes'] ?? null)->toBe('Private note')
|
||||
->and($cancelledOrderBooking['reg_1'] ?? null)->toBe('PRIVATE-PLATE')
|
||||
->and($cancelledOrderBooking['note'] ?? null)->toBe('Private note')
|
||||
->and($cancelledOrderBooking['deleted_at'] ?? null)->toBeNull()
|
||||
->and($anonymizedVehicle['reg'] ?? null)->toBe('PRIVATE-REG')
|
||||
->and((int)($anonymizedVehicle['wash_subscription'] ?? 0))->toBe(1)
|
||||
->and($anonymizedVehicle['notes'] ?? null)->toBe('Private vehicle note')
|
||||
->and($anonymizedVehicle['deleted_at'] ?? null)->toBeNull();
|
||||
});
|
||||
|
||||
it('reclaims a stale processing lease after a worker crash', function (): void {
|
||||
enableAccountDeletionForApiTest();
|
||||
api_fixtures()->setModuleConfig('email', 'mailersend_enabled', 'false', 'bool');
|
||||
$user = api_fixtures()->createUser([
|
||||
'display_name' => 'Stale Processing Customer',
|
||||
'password_plaintext' => 'Secret123!',
|
||||
]);
|
||||
$requestId = 'stale-' . bin2hex(random_bytes(15));
|
||||
$principalKey = 'customer:' . (int)$user['id'];
|
||||
api_test_runtime()->db()->query(
|
||||
'UPDATE users SET deleted_at = NOW() WHERE id = ' . (int)$user['id']
|
||||
);
|
||||
api_test_runtime()->db()->query(
|
||||
"INSERT INTO account_deletion_requests
|
||||
(request_id, principal_type, principal_id, customer_number_snapshot, active_principal_key,
|
||||
status, policy_version, retained_data_json, requested_at, processing_at, next_attempt_at)
|
||||
VALUES ('$requestId', 'customer', " . (int)$user['id'] . ', '
|
||||
. (int)$user['customer_number'] . ", '$principalKey', 'processing', '2026-07-20', '[]',
|
||||
DATE_SUB(NOW(), INTERVAL 1 HOUR), DATE_SUB(NOW(), INTERVAL 1 HOUR), NULL)"
|
||||
);
|
||||
|
||||
expect(runAccountDeletionWorkerForApiTest())
|
||||
->toMatchArray(['processed' => 1, 'completed' => 1, 'failed' => 0]);
|
||||
|
||||
$request = api_test_runtime()->queryOne(
|
||||
"SELECT status, active_principal_key, completed_at FROM account_deletion_requests
|
||||
WHERE request_id = '$requestId' LIMIT 1"
|
||||
);
|
||||
$anonymized = api_test_runtime()->queryOne(
|
||||
'SELECT display_name, email, password FROM users WHERE id = ' . (int)$user['id'] . ' LIMIT 1'
|
||||
);
|
||||
expect($request)
|
||||
->toHaveKey('status', 'completed')
|
||||
->and($request['active_principal_key'] ?? null)->toBeNull()
|
||||
->and($request['completed_at'] ?? null)->not->toBeNull()
|
||||
->and($anonymized['display_name'] ?? null)->toBe('Slettet konto')
|
||||
->and($anonymized['email'] ?? null)->toBeNull()
|
||||
->and($anonymized['password'] ?? null)->toBeNull();
|
||||
});
|
||||
|
||||
it('revokes a chauffeur across all customer grants without deleting either customer', function (): void {
|
||||
enableAccountDeletionForApiTest();
|
||||
$firstCustomer = api_fixtures()->createUser(['display_name' => 'First Driver Customer']);
|
||||
$secondCustomer = api_fixtures()->createUser(['display_name' => 'Second Driver Customer']);
|
||||
$session = api_fixtures()->createSubuserSession(
|
||||
(int)$firstCustomer['customer_number'],
|
||||
['SELFSERVE_LIST'],
|
||||
['name' => 'Deletion Driver', 'password_plaintext' => 'Secret123!'],
|
||||
);
|
||||
$secondGrant = api_fixtures()->grantSubuser(
|
||||
(int)$session['subuser']['id'],
|
||||
(int)$secondCustomer['customer_number'],
|
||||
['ORDERS_LIST'],
|
||||
);
|
||||
|
||||
$response = api_client()->post('/account/deletion', [
|
||||
'password' => 'Secret123!',
|
||||
'confirmation' => 'SLET MIN KONTO',
|
||||
'acknowledge_legal_retention' => true,
|
||||
], $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(202)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
expect($response->data()['retained_data_categories'] ?? [])
|
||||
->toContain('driver_reference');
|
||||
|
||||
api_client()->get('/subusers/me', $session['headers'])
|
||||
->assertStatus(401)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false);
|
||||
|
||||
$activeGrants = api_test_runtime()->queryOne(
|
||||
'SELECT COUNT(*) AS aggregate FROM subuser_grants WHERE subuser = '
|
||||
. (int)$session['subuser']['id'] . ' AND enabled = 1'
|
||||
);
|
||||
$driver = api_test_runtime()->queryOne(
|
||||
'SELECT id, deleted_at FROM subusers WHERE id = ' . (int)$session['subuser']['id'] . ' LIMIT 1'
|
||||
);
|
||||
$first = api_test_runtime()->queryOne('SELECT id FROM users WHERE id = ' . (int)$firstCustomer['id'] . ' LIMIT 1');
|
||||
$second = api_test_runtime()->queryOne('SELECT id FROM users WHERE id = ' . (int)$secondCustomer['id'] . ' LIMIT 1');
|
||||
$otherGrant = api_test_runtime()->queryOne('SELECT enabled, deleted_at FROM subuser_grants WHERE id = ' . $secondGrant . ' LIMIT 1');
|
||||
|
||||
expect((int)($activeGrants['aggregate'] ?? -1))->toBe(0)
|
||||
->and($driver['deleted_at'] ?? null)->not->toBeNull()
|
||||
->and($first)->not->toBeNull()
|
||||
->and($second)->not->toBeNull()
|
||||
->and((int)($otherGrant['enabled'] ?? 1))->toBe(0)
|
||||
->and($otherGrant['deleted_at'] ?? null)->not->toBeNull();
|
||||
|
||||
$staleSetupToken = bin2hex(random_bytes(16));
|
||||
$setupKey = '`subusers`_subuser_setup_token_setup_token:' . $staleSetupToken;
|
||||
$reverseSetupKey = '`subusers`_subuser_setup_token_setup_token_for_subuser:' . (int)$session['subuser']['id'];
|
||||
api_test_runtime()->redis()?->set($setupKey, (string)$session['subuser']['id']);
|
||||
api_test_runtime()->redis()?->set($reverseSetupKey, $staleSetupToken);
|
||||
|
||||
api_client()->get('/subusers/setup?token=' . $staleSetupToken)
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Invalid or expired token');
|
||||
|
||||
api_test_runtime()->redis()?->set($setupKey, (string)$session['subuser']['id']);
|
||||
api_test_runtime()->redis()?->set($reverseSetupKey, $staleSetupToken);
|
||||
api_client()->post('/subusers/setup', [
|
||||
'token' => $staleSetupToken,
|
||||
'password' => 'Replacement123!',
|
||||
'name' => 'Repopulated Driver',
|
||||
'email' => 'repopulated@example.test',
|
||||
])
|
||||
->assertStatus(400)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false)
|
||||
->assertMessage('Invalid or expired token');
|
||||
|
||||
$stillBlockedDriver = api_test_runtime()->queryOne(
|
||||
'SELECT name, email, deleted_at FROM subusers WHERE id = ' . (int)$session['subuser']['id'] . ' LIMIT 1'
|
||||
);
|
||||
expect($stillBlockedDriver['name'] ?? null)->toBe('Deletion Driver')
|
||||
->and($stillBlockedDriver['email'] ?? null)->not->toBe('repopulated@example.test')
|
||||
->and($stillBlockedDriver['deleted_at'] ?? null)->not->toBeNull();
|
||||
});
|
||||
@@ -6,6 +6,8 @@ return [
|
||||
'openapi_operations' => [
|
||||
'POST /auth/login',
|
||||
'GET /auth/session',
|
||||
'GET /account/deletion',
|
||||
'POST /account/deletion',
|
||||
'GET /auth/logout',
|
||||
'GET /departments',
|
||||
'POST /departments',
|
||||
|
||||
@@ -1054,6 +1054,10 @@ final class ApiFixtures
|
||||
]);
|
||||
|
||||
$this->cleanup->add(function () use ($subuserId): void {
|
||||
$this->deleteWhereIfPossible('account_deletion_requests', [
|
||||
'principal_type' => 'subuser',
|
||||
'principal_id' => $subuserId,
|
||||
]);
|
||||
$this->deleteWhere('subuser_grants', ['subuser' => $subuserId]);
|
||||
$this->deleteWhere('tokens', ['user_id' => $subuserId, 'type' => 'AUTH_TOKEN_SUBUSER']);
|
||||
$this->deleteById('subusers', $subuserId);
|
||||
@@ -1839,6 +1843,10 @@ final class ApiFixtures
|
||||
|
||||
private function purgeCustomerTraceData(int $userId, int $customerNumber): void
|
||||
{
|
||||
$this->deleteWhereIfPossible('account_deletion_requests', [
|
||||
'principal_type' => 'customer',
|
||||
'principal_id' => $userId,
|
||||
]);
|
||||
$invoiceCollectionIds = $this->fetchIntColumnWhere('collected_order_invoices', 'id', [
|
||||
'customer_number' => $customerNumber,
|
||||
]);
|
||||
@@ -1874,6 +1882,7 @@ final class ApiFixtures
|
||||
|
||||
$this->deleteWhereIfPossible('customer_attributes', ['user_id' => $userId]);
|
||||
$this->deleteWhereIfPossible('tokens', ['user_id' => $userId]);
|
||||
$this->deleteWhereIfPossible('customer_password_reset_keys', ['customer_id' => $customerNumber]);
|
||||
$this->deleteWhereIfPossible('user_key_value_pairs', ['user_id' => $userId]);
|
||||
$this->deleteWhereIfPossible('price_overrides', ['user_id' => $userId]);
|
||||
$this->deleteWhereIfPossible('department_customer_price_overrides', ['user_id' => $userId]);
|
||||
@@ -1892,6 +1901,7 @@ final class ApiFixtures
|
||||
'object_id' => $userId,
|
||||
]);
|
||||
$this->deleteWhereIfPossible('bookings', ['customer_number' => $customerNumber]);
|
||||
$this->deleteWhereIfPossible('order_bookings', ['customer_number' => $customerNumber]);
|
||||
$this->deleteWhereIfPossible('orders', ['customer_id' => $customerNumber]);
|
||||
$this->deleteWhereIfPossible('customer_vehicles', ['customer_id' => $customerNumber]);
|
||||
$this->deleteWhereIfPossible('collected_order_invoices', ['customer_number' => $customerNumber]);
|
||||
|
||||
@@ -831,9 +831,58 @@ CREATE TABLE IF NOT EXISTS `subusers` (
|
||||
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`suspended_at` DATETIME NULL,
|
||||
`deleted_at` DATETIME NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_subusers_username` (`username`),
|
||||
KEY `idx_subusers_phone` (`phone`)
|
||||
KEY `idx_subusers_phone` (`phone`),
|
||||
KEY `idx_subusers_deleted_at` (`deleted_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'account_deletion_requests' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `account_deletion_requests` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`request_id` CHAR(36) NOT NULL,
|
||||
`principal_type` VARCHAR(16) NOT NULL,
|
||||
`principal_id` BIGINT UNSIGNED NOT NULL,
|
||||
`customer_number_snapshot` INT NULL,
|
||||
`active_principal_key` VARCHAR(191) NULL,
|
||||
`status` VARCHAR(32) NOT NULL DEFAULT 'requested',
|
||||
`policy_version` VARCHAR(32) NOT NULL,
|
||||
`retained_data_json` LONGTEXT NOT NULL,
|
||||
`request_ip` VARCHAR(45) NULL,
|
||||
`request_user_agent` VARCHAR(512) NULL,
|
||||
`retry_count` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`failure_code` VARCHAR(191) NULL,
|
||||
`requested_at` DATETIME NOT NULL,
|
||||
`processing_at` DATETIME NULL,
|
||||
`completed_at` DATETIME NULL,
|
||||
`next_attempt_at` DATETIME NULL,
|
||||
`manual_review_required_at` DATETIME NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_account_deletion_request_id` (`request_id`),
|
||||
UNIQUE KEY `uniq_account_deletion_active_principal` (`active_principal_key`),
|
||||
KEY `idx_account_deletion_worker` (`status`, `next_attempt_at`, `requested_at`),
|
||||
KEY `idx_account_deletion_principal` (`principal_type`, `principal_id`, `requested_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'account_deletion_credential_attempts' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `account_deletion_credential_attempts` (
|
||||
`throttle_key` CHAR(64) NOT NULL, `attempt_count` INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
`window_started_at` DATETIME NOT NULL, `blocked_until` DATETIME NULL,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`throttle_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'account_deletion_outbox' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `account_deletion_outbox` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `request_id` CHAR(36) NOT NULL,
|
||||
`event_type` VARCHAR(64) NOT NULL, `payload_json` LONGTEXT NOT NULL,
|
||||
`status` VARCHAR(16) NOT NULL DEFAULT 'pending', `attempts` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`available_at` DATETIME NOT NULL, `processing_at` DATETIME NULL, `delivered_at` DATETIME NULL, `last_error` VARCHAR(191) NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_account_deletion_outbox_event` (`request_id`, `event_type`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'subuser_grants' => <<<'SQL'
|
||||
@@ -867,6 +916,20 @@ CREATE TABLE IF NOT EXISTS `tokens` (
|
||||
KEY `idx_tokens_user_id` (`user_id`),
|
||||
KEY `idx_tokens_type` (`type`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'customer_password_reset_keys' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `customer_password_reset_keys` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`customer_id` INT NOT NULL,
|
||||
`note` VARCHAR(255) NULL,
|
||||
`token` VARCHAR(64) NOT NULL,
|
||||
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`deleted_at` DATETIME NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_customer_password_reset_token` (`token`),
|
||||
KEY `idx_customer_password_reset_customer` (`customer_id`, `deleted_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL,
|
||||
'passkeys' => <<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `passkeys` (
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
it('wires self-service deletion, authentication guards, and the scheduled worker', function (): void {
|
||||
$root = dirname(__DIR__, 3);
|
||||
$route = (string)file_get_contents($root . '/routes/accountDeletionRoute.php');
|
||||
$intimidateRoute = (string)file_get_contents($root . '/routes/intimidateRoute.php');
|
||||
$service = (string)file_get_contents($root . '/classes/account_deletion_service.php');
|
||||
$authentication = (string)file_get_contents($root . '/classes/authentication.php');
|
||||
$subusers = (string)file_get_contents($root . '/objects/subusers_o.php');
|
||||
$passkeys = (string)file_get_contents($root . '/objects/passkeys_o.php');
|
||||
$passwordReset = (string)file_get_contents($root . '/objects/customer_password_reset_keys_o.php');
|
||||
$tasks = (string)file_get_contents($root . '/modules/account/cron/tasks.php');
|
||||
$cron = (string)file_get_contents($root . '/cron/Cron.php');
|
||||
|
||||
expect($route)
|
||||
->toContain("\$this->get('/account/deletion'")
|
||||
->toContain("\$this->post('/account/deletion'")
|
||||
->toContain('account_deletion_service::apiEnabled()')
|
||||
->toContain('$response->success($payload, 202)')
|
||||
->and($service)
|
||||
->toContain("public const CONFIRMATION_PHRASE = 'SLET MIN KONTO'")
|
||||
->toContain('acknowledge_legal_retention')
|
||||
->toContain("active_principal_key = NULL")
|
||||
->toContain("status = 'completed'")
|
||||
->toContain('processPending')
|
||||
->toContain('PROCESSING_LEASE_SECONDS')
|
||||
->toContain("status = 'processing' AND (processing_at IS NULL OR processing_at <=")
|
||||
->toContain("'ACCOUNT_DELETION_PASSKEY_CHALLENGE'")
|
||||
->toContain('verifyDeletionPasskey')
|
||||
->toContain('private const MAX_CREDENTIAL_ATTEMPTS = 4')
|
||||
->toContain('recordCredentialAttempt')
|
||||
->toContain('rejectImpersonationToken')
|
||||
->toContain('account_deletion_credential_attempts')
|
||||
->toContain('enqueueOutbox')
|
||||
->toContain("'manual_review'")
|
||||
->toContain('Unable to inspect principal deletion state.')
|
||||
->toContain('ACCOUNT_DELETION_REQUIRES_SUPPORT')
|
||||
->toContain('manual_support_review')
|
||||
->and($authentication)
|
||||
->toContain("principalIsBlocked('customer'")
|
||||
->toContain("principalIsBlocked('subuser'")
|
||||
->toContain('create_impersonation_token')
|
||||
->and($intimidateRoute)
|
||||
->toContain('create_impersonation_token')
|
||||
->and($subusers)
|
||||
->toContain("principalIsBlocked('subuser'")
|
||||
->and($passkeys)
|
||||
->toContain('deleted_at IS NULL')
|
||||
->and($passwordReset)
|
||||
->toContain("principalIsBlocked('customer'")
|
||||
->and($tasks)
|
||||
->toContain("'id' => 'account.process_deletion_requests'")
|
||||
->and($cron)
|
||||
->toContain('function ProcessAccountDeletionRequestsCron(): array')
|
||||
->toContain('account_deletion_service::workerEnabled()');
|
||||
});
|
||||
|
||||
it('uses a one-active-request constraint and keeps legal records out of anonymization deletes', function (): void {
|
||||
$root = dirname(__DIR__, 3);
|
||||
$schema = (string)file_get_contents($root . '/classes/account_deletion_schema_bootstrap.php');
|
||||
$service = (string)file_get_contents($root . '/classes/account_deletion_service.php');
|
||||
|
||||
expect($schema)
|
||||
->toContain('UNIQUE KEY uniq_account_deletion_active_principal')
|
||||
->toContain("self::ensureColumn('users', 'deleted_at'")
|
||||
->toContain("self::ensureColumn('subusers', 'deleted_at'")
|
||||
->toContain('public static function check(): array')
|
||||
->toContain("if (PHP_SAPI !== 'cli')")
|
||||
->and($service)->not->toContain('ensureTables()')
|
||||
->and($service)
|
||||
->toContain('invoices_payments_accounting')
|
||||
->toContain('orders_wash_history')
|
||||
->toContain('customer_reference')
|
||||
->toContain('driver_reference')
|
||||
->not->toContain("DELETE FROM orders")
|
||||
->not->toContain("DELETE FROM collected_order_invoices");
|
||||
});
|
||||
@@ -51,6 +51,11 @@ if (!class_exists('PasswordResetTokenExpiryProbe')) {
|
||||
{
|
||||
}
|
||||
|
||||
protected function selectedCustomerCanResetPassword(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function forceSelectedId(int $id): void
|
||||
{
|
||||
$this->id = $id;
|
||||
|
||||
@@ -7,7 +7,7 @@ it('discovers module-owned cron task definitions', function (): void {
|
||||
$registry = new cron_task_registry(app_path('modules'));
|
||||
$definitions = $registry->definitions();
|
||||
|
||||
expect($definitions)->toHaveCount(22);
|
||||
expect($definitions)->toHaveCount(23);
|
||||
expect(array_keys($definitions))->toContain(
|
||||
'system.sync_logs',
|
||||
'backups.process_jobs',
|
||||
@@ -16,6 +16,7 @@ it('discovers module-owned cron task definitions', function (): void {
|
||||
'dynamicimages.pre_render',
|
||||
'weatherapi.preload_department_responses',
|
||||
'goals.progress_alerts',
|
||||
'account.process_deletion_requests',
|
||||
'selfserve.activate_opening_cleaner_relays'
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user