Files
api/services/nginx/app/tests/Api/AccountDeletionApiTest.php
T
Jeppe B 0060fb45ca 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.
2026-07-22 19:22:17 +02:00

520 lines
22 KiB
PHP

<?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();
});