Files
api/services/nginx/app/tests/Unit/Auth/PasswordResetTokenExpiryTest.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

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)');
});