## Summary - notify customers by SMS with approve/deny links when a subuser requests access - notify subusers by SMS after approval or denial, including manual grant changes - support subuser password reset and authenticated password changes - add read-only token previews followed by explicit POST confirmation - store short-lived one-time purpose-bound action tokens only as SHA-256 digests - serialize grant decisions transactionally to prevent conflicting concurrent actions - document the API contract in OpenAPI ## Security - generic reset responses reduce account enumeration - URL tokens are removed from browser history after frontend bootstrap - approval previews never mutate state - concurrent decisions lock the exact grant row - SMS failures remain non-fatal and are returned as delivery status Residual risk: existing subuser sessions cannot all be centrally invalidated after password reset because there is no per-subuser session index; they expire normally within the existing session lifetime. ## Verification - backend Pest: 14 tests, 91 assertions - PHP syntax checks passed - focused PHPStan passed - OpenAPI YAML parsed successfully - `git diff --check` passed Database-backed API integration tests were unavailable because the local environment lacks the required database configuration. ## Paired delivery Paired Frontend PR: https://github.com/copenhagentruckwash/pleno-vue/pull/231 Both PRs are required before completion. The frontend PR contains the responsive visual comparisons. Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
38 lines
1.9 KiB
PHP
38 lines
1.9 KiB
PHP
<?php
|
|
|
|
it('defines isolated token purposes and short expiry windows', function (): void {
|
|
$code = (string)file_get_contents(app_path('classes/subuser_action_token_service.php'));
|
|
|
|
expect($code)->toContain("PURPOSE_GRANT_APPROVE = 'grant_approve'")
|
|
->toContain("PURPOSE_GRANT_DENY = 'grant_deny'")
|
|
->toContain("PURPOSE_PASSWORD_RESET = 'password_reset'")
|
|
->toContain('TOKEN_BYTES = 32')
|
|
->toContain('PASSWORD_RESET_TTL_SECONDS = 60 * 60')
|
|
->toContain('GRANT_DECISION_TTL_SECONDS = 24 * 60 * 60');
|
|
});
|
|
|
|
it('stores token digests and consumes each token with a conditional one-time update', function (): void {
|
|
$code = (string)file_get_contents(app_path('classes/subuser_action_token_service.php'));
|
|
$normalized = preg_replace('/\s+/', ' ', $code);
|
|
|
|
expect($normalized)->toContain("\$tokenHash = hash('sha256', \$token)")
|
|
->toContain('WHERE id = ? AND used_at IS NULL AND expires_at > UTC_TIMESTAMP()')
|
|
->toContain('$statement->affected_rows === 1')
|
|
->toContain('revokeGrantDecisions')
|
|
->toContain('purpose IN (?, ?)')
|
|
->toContain('consumeGrantDecision')
|
|
->toContain('billing_customer_number = ? AND deleted_at IS NULL')
|
|
->toContain('UPDATE subuser_grants SET enabled = ? WHERE id = ?')
|
|
->toContain('$db->conn->begin_transaction()')
|
|
->not->toContain('(token, purpose, subuser_id');
|
|
});
|
|
|
|
it('creates the action-token table with hash uniqueness and subject and expiry indexes', function (): void {
|
|
$schema = (string)file_get_contents(app_path('classes/subusers_schema_bootstrap.php'));
|
|
|
|
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS `subuser_action_tokens`')
|
|
->toContain('UNIQUE KEY `uniq_subuser_action_token_hash` (`token_hash`)')
|
|
->toContain('KEY `idx_subuser_action_token_subject` (`subuser_id`, `purpose`, `used_at`)')
|
|
->toContain('KEY `idx_subuser_action_token_expiry` (`expires_at`, `used_at`)');
|
|
});
|