diff --git a/composer.json b/composer.json index 959d4a8d..29f846c5 100644 --- a/composer.json +++ b/composer.json @@ -36,6 +36,7 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.34.1", + "illuminate/validation": "^10.0", "nunomaduro/collision": "^7.0", "pestphp/pest": "^2.33.2", "phpstan/phpstan": "^1.10.38", diff --git a/scripts/pest-bootstrap.php b/scripts/pest-bootstrap.php index a1355cc6..ffaffe7b 100644 --- a/scripts/pest-bootstrap.php +++ b/scripts/pest-bootstrap.php @@ -291,7 +291,11 @@ function now($tz = null): Illuminate\Support\Carbon } if (!class_exists('Illuminate\Foundation\Http\FormRequest') && class_exists('Illuminate\Http\Request')) { - eval('namespace Illuminate\Foundation\Http; class FormRequest extends \Illuminate\Http\Request { public function authorize() { return true; } public function rules() { return []; } public function responseWithErrors(\Illuminate\Contracts\Validation\Validator $validator) { return $validator; } }'); + // Enough of the real FormRequest for a controller to build one from a plain request and + // validate it the way the router does: setContainer(), setRedirector() and + // validateResolved(), which asks the bound validator factory and throws the usual + // ValidationException on failure. + eval('namespace Illuminate\Foundation\Http; class FormRequest extends \Illuminate\Http\Request { protected $container; public function authorize() { return true; } public function rules() { return []; } public function responseWithErrors(\Illuminate\Contracts\Validation\Validator $validator) { return $validator; } public function setContainer($container) { $this->container = $container; return $this; } public function setRedirector($redirector) { return $this; } public function validateResolved(): void { if (method_exists($this, "prepareForValidation")) { $this->prepareForValidation(); } if (!$this->authorize()) { throw new \Illuminate\Auth\Access\AuthorizationException(); } $factory = $this->container ? $this->container->make("validator") : null; if (!$factory) { return; } $validator = $factory->make($this->all(), $this->rules(), method_exists($this, "messages") ? $this->messages() : [], method_exists($this, "attributes") ? $this->attributes() : []); if ($validator->fails()) { if (method_exists($this, "failedValidation")) { $this->failedValidation($validator); } throw new \Illuminate\Validation\ValidationException($validator); } } }'); } if (!class_exists('Illuminate\Validation\Rules\RequiredIf')) { diff --git a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php index 57ad5fd6..2bfe1640 100644 --- a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php @@ -483,6 +483,25 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode ], ]); bindCustomerNotificationDispatcher(); + // Codes sent by SMS are stored hashed (core-api 1.6.69), so the plain code is only in + // the text itself: record what the provider was given and read the code back from it. + $twilio = new class { + public array $messages = []; + + public function message(string $to, string $message, array $media = [], array $params = []): object + { + $this->messages[] = $message; + + return (object) ['sid' => 'sms_test']; + } + }; + app()->instance('twilio', $twilio); + Illuminate\Support\Facades\Facade::clearResolvedInstance('twilio'); + $lastSmsCode = function () use ($twilio): ?string { + $message = $twilio->messages ? $twilio->messages[array_key_last($twilio->messages)] : ''; + + return preg_match('/(\d{6})$/', trim($message), $match) ? $match[1] : null; + }; $sentry = new class { public array $exceptions = []; @@ -506,19 +525,21 @@ public function captureException(Throwable $exception): void ); $closureDeliveryFailure = $controller->startAccountClosure(Request::create('/customer/closure', 'POST')); $connection->statement('DROP TRIGGER fail_closure_verification_insert'); + // Core texts the code to the phone on file, so the customer needs one for the text + // (and its plain code) to exist at all. + $connection->table('users')->where('uuid', 'user_uuid')->update(['phone' => '+97699112200']); $phoneConflict = $controller->requestPhoneVerification(Request::create('/customer/phone', 'POST', [ 'phone' => '97699112233', ])); $invalidPhoneCode = $controller->verifyPhoneNumber(Request::create('/customer/phone/verify', 'POST', [ 'code' => 'invalid-code', ])); - $generatedCode = $connection->table('verification_codes') - ->where('for', 'storefront_verify_phone') - ->value('code'); + $generatedCode = $lastSmsCode(); $verifiedPhone = $controller->verifyPhoneNumber(Request::create('/customer/phone/verify', 'POST', [ 'code' => $generatedCode, ])); $closureStarted = $controller->startAccountClosure(Request::create('/customer/closure', 'POST')); + $closureCode = $lastSmsCode(); $connection->statement( "CREATE TRIGGER fail_phone_verification_insert BEFORE INSERT ON verification_codes BEGIN SELECT RAISE(ABORT, 'verification insert failed'); END" ); @@ -527,10 +548,6 @@ public function captureException(Throwable $exception): void 'phone' => '+97699112234', ])); $connection->statement('DROP TRIGGER fail_phone_verification_insert'); - $closureCode = $connection->table('verification_codes') - ->where('for', 'storefront_account_closure') - ->where('meta', 'like', '%+97699112233%') - ->value('code'); $invalidClosure = $controller->confirmAccountClosure(Request::create('/customer/closure/confirm', 'POST', [ 'code' => 'invalid-code', ])); @@ -541,8 +558,11 @@ public function captureException(Throwable $exception): void 'code' => $closureCode, ])); $connection->statement('DROP TRIGGER fail_customer_user_delete'); + // A code is used up by the check that accepted it, so the failed deletion burned this + // one and the customer asks for a new code to try again. + $controller->startAccountClosure(Request::create('/customer/closure', 'POST')); $closed = $controller->confirmAccountClosure(Request::create('/customer/closure/confirm', 'POST', [ - 'code' => $closureCode, + 'code' => $lastSmsCode(), ])); app()->offsetUnset(Illuminate\Contracts\Notifications\Dispatcher::class); app()->forgetInstance('sentry'); @@ -590,7 +610,7 @@ public function captureException(Throwable $exception): void ->and($verifiedPhone)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Customer::class) ->and($connection->table('users')->where('uuid', 'user_uuid')->value('phone'))->toBe('+97699112233') ->and($connection->table('contacts')->where('uuid', $customerUuid)->value('phone'))->toBe('+97699112233') - ->and($connection->table('verification_codes')->where('code', $generatedCode)->value('deleted_at'))->not->toBeNull() + ->and($connection->table('verification_codes')->where('for', 'storefront_verify_phone')->value('status'))->toBe('used') ->and(json_encode($closureStarted->getData(true)))->toBe('{"status":"OK"}') ->and($phoneDeliveryFailure->getData(true))->toHaveKey('error') ->and($invalidClosure->getData(true))->toBe(['error' => 'Invalid verification code provided!']) @@ -904,8 +924,10 @@ public function message(string $to, string $message, array $media = [], array $p $schema->dropIfExists('verification_codes'); $schema->create('verification_codes', function ($table) { $table->increments('id'); + $table->string('uuid')->nullable(); $table->string('code')->nullable(); $table->string('for')->nullable(); + $table->string('status')->nullable(); $table->text('meta')->nullable(); $table->timestamp('expires_at')->nullable(); $table->timestamps(); @@ -936,8 +958,10 @@ public function message(string $to, string $message, array $media = [], array $p $schema->dropIfExists('verification_codes'); $schema->create('verification_codes', function ($table) { $table->increments('id'); + $table->string('uuid')->nullable(); $table->string('code')->nullable(); $table->string('for')->nullable(); + $table->string('status')->nullable(); $table->text('meta')->nullable(); $table->timestamp('expires_at')->nullable(); $table->timestamps(); @@ -1741,6 +1765,7 @@ public function sendNow($notifiables, $notification) $table->string('subject_uuid')->nullable(); $table->string('code')->nullable(); $table->string('for')->nullable(); + $table->string('status')->nullable(); $table->text('meta')->nullable(); $table->timestamp('expires_at')->nullable(); $table->timestamps();