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:
Jeppe B
2026-07-22 19:22:17 +02:00
committed by GitHub
parent 34cf804d75
commit 0060fb45ca
25 changed files with 2429 additions and 16 deletions
+150
View File
@@ -2579,6 +2579,156 @@ paths:
'401': '401':
$ref: '#/components/responses/Unauthorized' $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':
description: Too many deletion confirmation attempts
'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: /auth/2fa/setup:
post: post:
tags: tags:
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env php
<?php
if (PHP_SAPI !== 'cli') {
fwrite(STDERR, "This command is CLI-only.\n");
exit(2);
}
const WD = __DIR__ . '/../services/nginx/app';
require_once WD . '/vendor/autoload.php';
require_once WD . '/config.php';
require_once WD . '/classes/db.php';
require_once WD . '/classes/account_deletion_schema_bootstrap.php';
$response = null;
$db = new \classes\db($CONFIG_DB);
$db->connect();
$command = $argv[1] ?? 'check';
if ($command === 'apply') {
if (($argv[2] ?? '') !== '--yes') {
fwrite(STDERR, "Refusing schema mutation without: apply --yes\n");
exit(2);
}
\classes\account_deletion_schema_bootstrap::apply();
}
if (!in_array($command, ['check', 'apply'], true)) {
fwrite(STDERR, "Usage: scripts/account-deletion-schema.php check|apply --yes\n");
exit(2);
}
$status = \classes\account_deletion_schema_bootstrap::check();
fwrite(STDOUT, json_encode($status, JSON_UNESCAPED_SLASHES) . PHP_EOL);
exit($status['ready'] ? 0 : 1);
@@ -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
+43 -4
View File
@@ -2,6 +2,8 @@
namespace classes; namespace classes;
require_once WD . '/classes/account_deletion_service.php';
use classes\totp; use classes\totp;
use Exception; use Exception;
use interfaces\authentication_i; use interfaces\authentication_i;
@@ -69,6 +71,10 @@ class authentication implements authentication_i
public function create_2fa_token(int $id, string $type): string 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 // Create a temporary 2FA token
$token = bin2hex(random_bytes(32)); $token = bin2hex(random_bytes(32));
(new tokens_o())->create($id, $token, $type); (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); throw new \Exception('User not found for customer number: ' . $customer_number);
} }
$user_id = $user->id; $user_id = $user->id;
if (account_deletion_service::principalIsBlocked('customer', (int)$user_id)) {
throw new Exception('Account unavailable');
}
// Save the token in the database // Save the token in the database
(new tokens_o())->create($user_id, $token, 'AUTH_TOKEN'); (new tokens_o())->create($user_id, $token, 'AUTH_TOKEN');
return $token; return $token;
@@ -107,6 +116,9 @@ class authentication implements authentication_i
public function create_token_by_user_id(int $user_id): string 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 // Create a token
$token = bin2hex(random_bytes(32)); $token = bin2hex(random_bytes(32));
// Save the token in the database // Save the token in the database
@@ -116,6 +128,9 @@ class authentication implements authentication_i
public function create_employee_token(int $employee_id): string 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 // Create a token
$token = bin2hex(random_bytes(32)); $token = bin2hex(random_bytes(32));
// Save the token in the database // Save the token in the database
@@ -123,13 +138,26 @@ class authentication implements authentication_i
return $token; 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 public function validate_token(string $token): bool
{ {
// First: try validating as a classic user auth token // First: try validating as a classic user auth token
try { try {
$dbToken = (new tokens_o())->getToken($token); $dbToken = (new tokens_o())->getToken($token);
if ($dbToken && $dbToken->id && $dbToken->type->value() === 'AUTH_TOKEN') { if ($dbToken && $dbToken->id && $this->isClassicAuthTokenType((string)$dbToken->type->value())) {
return true; return !account_deletion_service::principalIsBlocked(
'customer',
(int)$dbToken->user_id->value()
);
} }
} catch (Exception) { } catch (Exception) {
// Ignore and continue to subuser session validation // Ignore and continue to subuser session validation
@@ -137,7 +165,7 @@ class authentication implements authentication_i
// Fallback: try validating as a subuser session token // Fallback: try validating as a subuser session token
$subuser = (new subusers_o())->getSubuserBySessionToken($token); $subuser = (new subusers_o())->getSubuserBySessionToken($token);
if ($subuser !== null) { if ($subuser !== null) {
return true; return !account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id);
} }
return false; return false;
} }
@@ -168,7 +196,10 @@ class authentication implements authentication_i
if (!$token->id) { if (!$token->id) {
return false; 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; return false;
} }
// Get the user from the database // Get the user from the database
@@ -177,6 +208,11 @@ class authentication implements authentication_i
return $user; 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 public function get_plate_scanner(): plate_scanners_o|false
{ {
// Get the token from the headers // Get the token from the headers
@@ -227,6 +263,9 @@ class authentication implements authentication_i
if ($subuser === null) { if ($subuser === null) {
return false; return false;
} }
if (account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id)) {
return false;
}
$customerNumberContext = null; $customerNumberContext = null;
if (isset($headers['X-Customer-Number'])) { if (isset($headers['X-Customer-Number'])) {
$customerNumberContext = (int)$headers['X-Customer-Number']; $customerNumberContext = (int)$headers['X-Customer-Number'];
+18
View File
@@ -2,6 +2,7 @@
// prevent direct access // prevent direct access
use classes\backup_store; use classes\backup_store;
use classes\account_deletion_service;
use classes\economic; use classes\economic;
use classes\economic_transfer_queue; use classes\economic_transfer_queue;
use classes\invoice_period_flag_service; use classes\invoice_period_flag_service;
@@ -63,6 +64,12 @@ $response_cron = [];
// Define the cron tasks // Define the cron tasks
$cron_tasks = [ $cron_tasks = [
'ProcessAccountDeletionRequestsCron' => [
'interval' => 300,
'last_run' => 0,
'next_run' => 0,
'function' => 'ProcessAccountDeletionRequestsCron',
],
// 'CheckUnfulfilledBookings' => [ // 'CheckUnfulfilledBookings' => [
// 'interval' => 86400, // 24 hours // 'interval' => 86400, // 24 hours
// 'last_run' => 0, // '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 function ReplicaFailoverMonitorCron(): void
{ {
global $db; 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; namespace objects;
use classes\account_deletion_service;
use classes\db; use classes\db;
use classes\object_property; use classes\object_property;
use Exception; use Exception;
@@ -56,6 +57,13 @@ class customer_password_reset_keys_o extends db
return bin2hex(random_bytes($length / 2)); 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 * Find a valid reset key by token
* @param string $token The token to search for * @param string $token The token to search for
@@ -79,6 +87,10 @@ class customer_password_reset_keys_o extends db
$row = $result->fetch_assoc(); $row = $result->fetch_assoc();
$this->id = (int)$row['id']; $this->id = (int)$row['id'];
$this->getObjectProperties(); $this->getObjectProperties();
if (!$this->selectedCustomerCanResetPassword()) {
$this->delete();
return null;
}
return $this; return $this;
} }
@@ -121,6 +133,10 @@ class customer_password_reset_keys_o extends db
$customer = new users_o(); $customer = new users_o();
$customer->getUserByCustomerNumber((int)$this->customer_id->value()); $customer->getUserByCustomerNumber((int)$this->customer_id->value());
$customer->requireSelected(); $customer->requireSelected();
if (account_deletion_service::principalIsBlocked('customer', (int)$customer->id)) {
$this->delete();
throw new Exception('Invalid or expired token');
}
$customer->setPassword($new_password); $customer->setPassword($new_password);
$this->delete(); $this->delete();
} }
+1 -1
View File
@@ -37,7 +37,7 @@ class passkeys_o extends db
{ {
global $db; global $db;
$credentialId = $db->escape_string($credentialId); $credentialId = $db->escape_string($credentialId);
$where = "credential_id = '" . $credentialId . "'"; $where = "credential_id = '" . $credentialId . "' AND deleted_at IS NULL";
if ($userId !== null) { if ($userId !== null) {
$where .= ' AND user_id = ' . (int)$userId; $where .= ' AND user_id = ' . (int)$userId;
} }
@@ -2,6 +2,7 @@
namespace objects; namespace objects;
use classes\account_deletion_service;
use classes\db; use classes\db;
use classes\object_property; use classes\object_property;
use Exception; 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 public function add(int $billing_customer_number, int $subuser, bool $enabled, ?string $note, ?array $permissions = self::defaultPermissions): subuser_grants_o
{ {
global $db; global $db;
if (account_deletion_service::principalIsBlocked('subuser', $subuser)) {
throw new Exception('Subuser account is unavailable');
}
$permissions = self::normalizePermissionsValue($permissions); $permissions = self::normalizePermissionsValue($permissions);
$tmp = $this->add_object([ $tmp = $this->add_object([
'billing_customer_number' => (int)$billing_customer_number, 'billing_customer_number' => (int)$billing_customer_number,
+18
View File
@@ -3,6 +3,7 @@
namespace objects; namespace objects;
use classes\authentication; use classes\authentication;
use classes\account_deletion_service;
use classes\db; use classes\db;
use classes\object_property; use classes\object_property;
use Exception; use Exception;
@@ -187,6 +188,9 @@ class subusers_o extends db
public function setPassword(string $password): self public function setPassword(string $password): self
{ {
self::requireSelected(); self::requireSelected();
if (account_deletion_service::principalIsBlocked('subuser', (int)$this->id)) {
throw new Exception('Account unavailable');
}
self::assertValidPassword($password); self::assertValidPassword($password);
$this->password->set((string)password_hash($password, PASSWORD_DEFAULT)); $this->password->set((string)password_hash($password, PASSWORD_DEFAULT));
return $this; return $this;
@@ -215,6 +219,9 @@ class subusers_o extends db
public function generateSetupToken(): string public function generateSetupToken(): string
{ {
self::requireSelected(); self::requireSelected();
if (account_deletion_service::principalIsBlocked('subuser', (int)$this->id)) {
throw new Exception('Account unavailable');
}
try { try {
$token = bin2hex(random_bytes(16)); $token = bin2hex(random_bytes(16));
} catch (Exception $e) { } catch (Exception $e) {
@@ -265,6 +272,10 @@ class subusers_o extends db
} }
$subuser = (new subusers_o())->select((int)$subuser_id); $subuser = (new subusers_o())->select((int)$subuser_id);
$subuser->getObjectProperties(); $subuser->getObjectProperties();
if (account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id)) {
$this->invalidateSetupToken($token);
return null;
}
return $subuser; return $subuser;
} }
@@ -351,6 +362,9 @@ class subusers_o extends db
public function generateSession(): string public function generateSession(): string
{ {
self::requireSelected(); self::requireSelected();
if (account_deletion_service::principalIsBlocked('subuser', (int)$this->id)) {
throw new Exception('Account unavailable');
}
$session_token = bin2hex(random_bytes(32)); $session_token = bin2hex(random_bytes(32));
$this->cache('session_token:' . $session_token, $this->id, 'subuser_sessions'); $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 $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) { if ($subuser_id !== null) {
$subuser = (new subusers_o())->select((int)$subuser_id); $subuser = (new subusers_o())->select((int)$subuser_id);
$subuser->getObjectProperties(); $subuser->getObjectProperties();
if (account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id)) {
$this->invalidateSessionToken($token);
return null;
}
return $subuser; return $subuser;
} }
return null; return null;
+4
View File
@@ -2,6 +2,7 @@
namespace objects; namespace objects;
use classes\account_deletion_service;
use classes\db; use classes\db;
use classes\customer_rule_product_restriction_schema_bootstrap; use classes\customer_rule_product_restriction_schema_bootstrap;
use classes\customer_name_cache_payload_builder; use classes\customer_name_cache_payload_builder;
@@ -2049,6 +2050,9 @@ class users_o extends db
public function generatePasswordResetLink(): string public function generatePasswordResetLink(): string
{ {
self::requireSelected(); self::requireSelected();
if (account_deletion_service::principalIsBlocked('customer', (int)$this->id)) {
throw new Exception('Account unavailable');
}
// Generate token // Generate token
$token = customer_password_reset_keys_o::generateToken(); $token = customer_password_reset_keys_o::generateToken();
+150
View File
@@ -2935,6 +2935,156 @@ paths:
'401': '401':
$ref: '#/components/responses/Unauthorized' $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: /auth/2fa/setup:
post: post:
tags: 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);
}
});
}
}
+32 -7
View File
@@ -3,6 +3,7 @@
namespace routes; namespace routes;
use classes\authentication; use classes\authentication;
use classes\account_deletion_service;
use classes\economic; use classes\economic;
use classes\email; use classes\email;
use classes\release_manager; use classes\release_manager;
@@ -22,6 +23,7 @@ use objects\passkeys_o;
use traits\route_t; use traits\route_t;
require_once WD . '/classes/security_policy_service.php'; require_once WD . '/classes/security_policy_service.php';
require_once WD . '/classes/account_deletion_service.php';
class authRoute class authRoute
{ {
@@ -67,7 +69,7 @@ class authRoute
$passkeys = new passkeys_o(); $passkeys = new passkeys_o();
$passkeys->setAdditionalWhereClause( $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) { $list = $passkeys->listObjectsWithPaginationIfSet(function ($o) {
$transports = null; $transports = null;
@@ -125,6 +127,10 @@ class authRoute
$this->observeLoginFailure('customer', $data['customer_number'], ['reason' => 'missing_user_or_password']); $this->observeLoginFailure('customer', $data['customer_number'], ['reason' => 'missing_user_or_password']);
$response->error('Invalid credentials', 401); $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; $isCredentialsValid = false;
try { try {
@@ -312,14 +318,21 @@ class authRoute
if ($token_type === '2FA_VERIFICATION_USER') { if ($token_type === '2FA_VERIFICATION_USER') {
$user = (new users_o())->getUserById($user_id); $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); $token_o->delete($token_str);
$new_token = $auth->create_employee_token($user_id); // Works for both users and employees $new_token = $auth->create_employee_token($user_id); // Works for both users and employees
$response->success(['token' => $new_token]); $response->success(['token' => $new_token]);
} }
} elseif ($token_type === '2FA_VERIFICATION_SUBUSER') { } elseif ($token_type === '2FA_VERIFICATION_SUBUSER') {
$subuser = (new subusers_o())->select($user_id); $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); $token_o->delete($token_str);
$new_token = $subuser->generateSession(); $new_token = $subuser->generateSession();
$response->success(['session' => $new_token]); $response->success(['session' => $new_token]);
@@ -626,6 +639,9 @@ class authRoute
// For security reasons, don't reveal if the user exists // For security reasons, don't reveal if the user exists
$response->success(['message' => 'If the customer exists, a password reset email has been sent.']); $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(); $email_address = $user->email->value();
if (empty($email_address)) { if (empty($email_address)) {
@@ -746,8 +762,10 @@ class authRoute
} }
if ($subuser !== null) { if ($subuser !== null) {
$user_id = (int)$subuser->id; if (!account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id)) {
$allowCredentials = $this->passkeyAllowCredentials($user_id, true); $user_id = (int)$subuser->id;
$allowCredentials = $this->passkeyAllowCredentials($user_id, true);
}
} }
} elseif ($customer_number !== null) { } elseif ($customer_number !== null) {
$principal_type = 'user'; $principal_type = 'user';
@@ -759,8 +777,10 @@ class authRoute
$user = (new users_o())->getUserByCustomerNumber($customer_number); $user = (new users_o())->getUserByCustomerNumber($customer_number);
if ($user->exists()) { if ($user->exists()) {
$user_id = (int)$user->id; if (!account_deletion_service::principalIsBlocked('customer', (int)$user->id)) {
$allowCredentials = $this->passkeyAllowCredentials($user_id, false); $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 // Success → issue session token accordingly and delete the challenge token
$issued_to_user_id = (int)$passkey->user_id->value(); $issued_to_user_id = (int)$passkey->user_id->value();
$is_subuser = (bool)$passkey->is_subuser->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 ( if (
($challengePrincipalType === 'subuser' && !$is_subuser) ($challengePrincipalType === 'subuser' && !$is_subuser)
|| ($challengePrincipalType === 'user' && $is_subuser) || ($challengePrincipalType === 'user' && $is_subuser)
@@ -31,7 +31,10 @@ class intimidateRoute
// Log the incident // Log the incident
(new logs_o())->add('auth', 'global', 1, $user->id, 'AUTH_SUCCESS_INTIMIDATE', 'Created intimidate token for customer: ' . $data['user_id']); (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.) // 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 // Return the token
$response->success(['token' => $token]); $response->success(['token' => $token]);
}, },
@@ -3,6 +3,7 @@
namespace routes; namespace routes;
use classes\authentication; use classes\authentication;
use classes\account_deletion_service;
use classes\economic; use classes\economic;
use classes\email; use classes\email;
use classes\gatewayapi; use classes\gatewayapi;
@@ -386,6 +387,10 @@ class subusersRoute
{ {
global $response; global $response;
if (account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id)) {
$response->error('Chauffeur account is unavailable', 409);
}
$customerNumber = $this->resolveDirectLoginCustomerNumber($subuser); $customerNumber = $this->resolveDirectLoginCustomerNumber($subuser);
try { try {
$sessionToken = $subuser->generateSession(); $sessionToken = $subuser->generateSession();
@@ -770,8 +775,17 @@ class subusersRoute
$response->error('Invalid credentials', 401); $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 private function issueSetupInvite(subusers_o $subuser): array
{ {
$this->rejectBlockedSubuser((int)$subuser->id);
if (!$subuser->requiresSetup()) { if (!$subuser->requiresSetup()) {
return [ return [
'setup_token' => null, 'setup_token' => null,
@@ -1455,6 +1469,7 @@ class subusersRoute
global $response; global $response;
$grant = $this->getGrantForScopedUserOrFail($grantId, $customerNumber); $grant = $this->getGrantForScopedUserOrFail($grantId, $customerNumber);
$this->rejectBlockedSubuser((int)$grant->subuser->value());
$updates = []; $updates = [];
$templateAccess = $this->parseAccessTemplatePayload(); $templateAccess = $this->parseAccessTemplatePayload();
if ($templateAccess !== null) { if ($templateAccess !== null) {
@@ -1735,6 +1750,7 @@ class subusersRoute
$subuser_id = (int)self::getParameter('subuser_id'); $subuser_id = (int)self::getParameter('subuser_id');
self::requireType($customer_number, self::type_int()); self::requireType($customer_number, self::type_int());
self::requireType($subuser_id, self::type_int()); self::requireType($subuser_id, self::type_int());
$this->rejectBlockedSubuser($subuser_id);
if (!self::hasPermission($permission_other, (int)$customer_number)) { if (!self::hasPermission($permission_other, (int)$customer_number)) {
$this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_ADD, (int)$customer_number); $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_ADD, (int)$customer_number);
@@ -1793,6 +1809,7 @@ class subusersRoute
if (!$grant->exists()) { if (!$grant->exists()) {
$response->error('Grant not found', 404); $response->error('Grant not found', 404);
} }
$this->rejectBlockedSubuser((int)$grant->subuser->value());
$targetCustomer = (int)$grant->billing_customer_number->value(); $targetCustomer = (int)$grant->billing_customer_number->value();
if (!self::hasPermission($permission_other, $targetCustomer)) { if (!self::hasPermission($permission_other, $targetCustomer)) {
@@ -2103,6 +2120,9 @@ class subusersRoute
if ($subuser === null) { if ($subuser === null) {
$this->subuserAuthFailure(); $this->subuserAuthFailure();
} }
if (account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id)) {
$this->subuserAuthFailure();
}
self::requireParameters(['password']); self::requireParameters(['password']);
$password = (string)self::getParameter('password'); $password = (string)self::getParameter('password');
try { 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' => [ 'openapi_operations' => [
'POST /auth/login', 'POST /auth/login',
'GET /auth/session', 'GET /auth/session',
'GET /account/deletion',
'POST /account/deletion',
'GET /auth/logout', 'GET /auth/logout',
'GET /departments', 'GET /departments',
'POST /departments', 'POST /departments',
@@ -1054,6 +1054,10 @@ final class ApiFixtures
]); ]);
$this->cleanup->add(function () use ($subuserId): void { $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('subuser_grants', ['subuser' => $subuserId]);
$this->deleteWhere('tokens', ['user_id' => $subuserId, 'type' => 'AUTH_TOKEN_SUBUSER']); $this->deleteWhere('tokens', ['user_id' => $subuserId, 'type' => 'AUTH_TOKEN_SUBUSER']);
$this->deleteById('subusers', $subuserId); $this->deleteById('subusers', $subuserId);
@@ -1839,6 +1843,10 @@ final class ApiFixtures
private function purgeCustomerTraceData(int $userId, int $customerNumber): void 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', [ $invoiceCollectionIds = $this->fetchIntColumnWhere('collected_order_invoices', 'id', [
'customer_number' => $customerNumber, 'customer_number' => $customerNumber,
]); ]);
@@ -1874,6 +1882,7 @@ final class ApiFixtures
$this->deleteWhereIfPossible('customer_attributes', ['user_id' => $userId]); $this->deleteWhereIfPossible('customer_attributes', ['user_id' => $userId]);
$this->deleteWhereIfPossible('tokens', ['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('user_key_value_pairs', ['user_id' => $userId]);
$this->deleteWhereIfPossible('price_overrides', ['user_id' => $userId]); $this->deleteWhereIfPossible('price_overrides', ['user_id' => $userId]);
$this->deleteWhereIfPossible('department_customer_price_overrides', ['user_id' => $userId]); $this->deleteWhereIfPossible('department_customer_price_overrides', ['user_id' => $userId]);
@@ -1892,6 +1901,7 @@ final class ApiFixtures
'object_id' => $userId, 'object_id' => $userId,
]); ]);
$this->deleteWhereIfPossible('bookings', ['customer_number' => $customerNumber]); $this->deleteWhereIfPossible('bookings', ['customer_number' => $customerNumber]);
$this->deleteWhereIfPossible('order_bookings', ['customer_number' => $customerNumber]);
$this->deleteWhereIfPossible('orders', ['customer_id' => $customerNumber]); $this->deleteWhereIfPossible('orders', ['customer_id' => $customerNumber]);
$this->deleteWhereIfPossible('customer_vehicles', ['customer_id' => $customerNumber]); $this->deleteWhereIfPossible('customer_vehicles', ['customer_id' => $customerNumber]);
$this->deleteWhereIfPossible('collected_order_invoices', ['customer_number' => $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, `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`suspended_at` DATETIME NULL, `suspended_at` DATETIME NULL,
`deleted_at` DATETIME NULL,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
KEY `idx_subusers_username` (`username`), 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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL, SQL,
'subuser_grants' => <<<'SQL' 'subuser_grants' => <<<'SQL'
@@ -867,6 +916,20 @@ CREATE TABLE IF NOT EXISTS `tokens` (
KEY `idx_tokens_user_id` (`user_id`), KEY `idx_tokens_user_id` (`user_id`),
KEY `idx_tokens_type` (`type`) KEY `idx_tokens_type` (`type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) 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, SQL,
'passkeys' => <<<'SQL' 'passkeys' => <<<'SQL'
CREATE TABLE IF NOT EXISTS `passkeys` ( 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 public function forceSelectedId(int $id): void
{ {
$this->id = $id; $this->id = $id;
@@ -7,7 +7,7 @@ it('discovers module-owned cron task definitions', function (): void {
$registry = new cron_task_registry(app_path('modules')); $registry = new cron_task_registry(app_path('modules'));
$definitions = $registry->definitions(); $definitions = $registry->definitions();
expect($definitions)->toHaveCount(22); expect($definitions)->toHaveCount(23);
expect(array_keys($definitions))->toContain( expect(array_keys($definitions))->toContain(
'system.sync_logs', 'system.sync_logs',
'backups.process_jobs', 'backups.process_jobs',
@@ -16,6 +16,7 @@ it('discovers module-owned cron task definitions', function (): void {
'dynamicimages.pre_render', 'dynamicimages.pre_render',
'weatherapi.preload_department_responses', 'weatherapi.preload_department_responses',
'goals.progress_alerts', 'goals.progress_alerts',
'account.process_deletion_requests',
'selfserve.activate_opening_cleaner_relays' 'selfserve.activate_opening_cleaner_relays'
); );