## 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.
111 lines
3.1 KiB
PHP
111 lines
3.1 KiB
PHP
<?php
|
|
|
|
use objects\customer_password_reset_keys_o;
|
|
|
|
app_require('objects/customer_password_reset_keys_o.php');
|
|
|
|
if (!class_exists('PasswordResetTokenExpiryFakeResult')) {
|
|
class PasswordResetTokenExpiryFakeResult
|
|
{
|
|
public int $num_rows;
|
|
|
|
public function __construct(private readonly array $rows)
|
|
{
|
|
$this->num_rows = count($rows);
|
|
}
|
|
|
|
public function fetch_assoc(): ?array
|
|
{
|
|
return $this->rows[0] ?? null;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!class_exists('PasswordResetTokenExpiryFakeDb')) {
|
|
class PasswordResetTokenExpiryFakeDb
|
|
{
|
|
public array $queries = [];
|
|
|
|
public function __construct(private readonly array $results)
|
|
{
|
|
}
|
|
|
|
public function escape_string(string $string): string
|
|
{
|
|
return addslashes($string);
|
|
}
|
|
|
|
public function query(string $sql): PasswordResetTokenExpiryFakeResult
|
|
{
|
|
$this->queries[] = $sql;
|
|
|
|
return $this->results[count($this->queries) - 1] ?? new PasswordResetTokenExpiryFakeResult([]);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!class_exists('PasswordResetTokenExpiryProbe')) {
|
|
class PasswordResetTokenExpiryProbe extends customer_password_reset_keys_o
|
|
{
|
|
public function getObjectProperties(): void
|
|
{
|
|
}
|
|
|
|
protected function selectedCustomerCanResetPassword(): bool
|
|
{
|
|
return true;
|
|
}
|
|
|
|
public function forceSelectedId(int $id): void
|
|
{
|
|
$this->id = $id;
|
|
}
|
|
}
|
|
}
|
|
|
|
beforeEach(function (): void {
|
|
$this->previousDb = $GLOBALS['db'] ?? null;
|
|
});
|
|
|
|
afterEach(function (): void {
|
|
if ($this->previousDb !== null) {
|
|
$GLOBALS['db'] = $this->previousDb;
|
|
return;
|
|
}
|
|
|
|
unset($GLOBALS['db']);
|
|
});
|
|
|
|
it('keeps password reset tokens valid for 72 hours', function (): void {
|
|
expect(customer_password_reset_keys_o::TOKEN_EXPIRY_SECONDS)->toBe(72 * 60 * 60);
|
|
});
|
|
|
|
it('looks up reset tokens using the database 72 hour validity window', function (): void {
|
|
$GLOBALS['db'] = new PasswordResetTokenExpiryFakeDb([
|
|
new PasswordResetTokenExpiryFakeResult([['id' => 42]]),
|
|
]);
|
|
|
|
$token = str_repeat('a', customer_password_reset_keys_o::TOKEN_LENGTH);
|
|
$probe = new PasswordResetTokenExpiryProbe();
|
|
|
|
$found = $probe->findValidByToken($token);
|
|
|
|
expect($found)->toBe($probe)
|
|
->and($probe->id)->toBe(42)
|
|
->and($GLOBALS['db']->queries[0])->toContain('created_at >= DATE_SUB(NOW(), INTERVAL 259200 SECOND)')
|
|
->and($GLOBALS['db']->queries[0])->not->toContain("DATE_SUB('");
|
|
});
|
|
|
|
it('uses the same database 72 hour window for the selected token guard', function (): void {
|
|
$GLOBALS['db'] = new PasswordResetTokenExpiryFakeDb([
|
|
new PasswordResetTokenExpiryFakeResult([['id' => 42]]),
|
|
]);
|
|
|
|
$probe = new PasswordResetTokenExpiryProbe();
|
|
$probe->forceSelectedId(42);
|
|
|
|
expect($probe->isValidToken())->toBeTrue()
|
|
->and($GLOBALS['db']->queries[0])->toContain('id = 42')
|
|
->and($GLOBALS['db']->queries[0])->toContain('created_at >= DATE_SUB(NOW(), INTERVAL 259200 SECOND)');
|
|
});
|