Files
api/services/nginx/app/tests/Unit/Subusers/SubuserActionTokenServiceContractTest.php
T
Jeppe BandJeppe Bundgaard d3e4798b11 Complete subuser notification and recovery lifecycle (#325)
## 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>
2026-07-27 18:40:40 +02:00

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