Add phone-verified user registration
Two-step flow: request a one-time code by email/phone (RegistrationVerification, mailed via RegistrationCodeMail, rate-limited by the new api-otp limiter keyed to the identifier), then verify the code and register with RegistrationController. User gains a phone column/fillable.
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Modules\Identity\Enums\TokenAbility;
|
||||
use Modules\Identity\Mail\RegistrationCodeMail;
|
||||
use Modules\Identity\Models\RegistrationVerification;
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'services.sms.enabled' => true,
|
||||
'services.sms.sms_poh.server' => 'https://sms.example.test/send',
|
||||
'services.sms.sms_poh.token' => 'test-token',
|
||||
'services.sms.sms_poh.sender' => 'App',
|
||||
]);
|
||||
});
|
||||
|
||||
test('requesting a code for a new email sends a mail and creates a pending verification', function () {
|
||||
Mail::fake();
|
||||
|
||||
$this->postJson('/api/v1/auth/registration/request-code', ['identifier' => 'new@example.com'])
|
||||
->assertSuccessful();
|
||||
|
||||
Mail::assertSent(RegistrationCodeMail::class);
|
||||
|
||||
$verification = RegistrationVerification::where('identifier', 'new@example.com')->sole();
|
||||
expect($verification->type)->toBe('email')
|
||||
->and($verification->verified_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('requesting a code for a new phone number sends an sms', function () {
|
||||
Http::fake(['sms.example.test/*' => Http::response('OK', 200)]);
|
||||
|
||||
$this->postJson('/api/v1/auth/registration/request-code', ['identifier' => '+959123456789'])
|
||||
->assertSuccessful();
|
||||
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://sms.example.test/send'
|
||||
&& $request['to'] === '+959123456789');
|
||||
|
||||
$verification = RegistrationVerification::where('identifier', '+959123456789')->sole();
|
||||
expect($verification->type)->toBe('phone');
|
||||
});
|
||||
|
||||
test('requesting a code rejects an already registered email', function () {
|
||||
Mail::fake();
|
||||
User::factory()->create(['email' => 'taken@example.com']);
|
||||
|
||||
$this->postJson('/api/v1/auth/registration/request-code', ['identifier' => 'taken@example.com'])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('identifier');
|
||||
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
test('requesting a code rejects an already registered phone', function () {
|
||||
User::factory()->create(['phone' => '+959123456789']);
|
||||
|
||||
$this->postJson('/api/v1/auth/registration/request-code', ['identifier' => '+959123456789'])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('identifier');
|
||||
});
|
||||
|
||||
test('verifying with the correct code returns a verification token', function () {
|
||||
Mail::fake();
|
||||
$this->postJson('/api/v1/auth/registration/request-code', ['identifier' => 'new@example.com']);
|
||||
$verification = RegistrationVerification::where('identifier', 'new@example.com')->sole();
|
||||
|
||||
// The plaintext code isn't returned by the API by design, so reach
|
||||
// into the model the same way the real code was generated to recover
|
||||
// it for the test — simplest is to reissue with a known code via the
|
||||
// factory instead of parsing outbound mail content.
|
||||
$verification->forceFill(['code' => Hash::make('654321')])->save();
|
||||
|
||||
$this->postJson('/api/v1/auth/registration/verify-code', [
|
||||
'identifier' => 'new@example.com',
|
||||
'code' => '654321',
|
||||
])
|
||||
->assertSuccessful()
|
||||
->assertJsonStructure(['verification_token']);
|
||||
|
||||
expect($verification->fresh()->verified_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('verifying with the wrong code fails and increments attempts', function () {
|
||||
$verification = RegistrationVerification::factory()->create();
|
||||
|
||||
$this->postJson('/api/v1/auth/registration/verify-code', [
|
||||
'identifier' => $verification->identifier,
|
||||
'code' => '000000',
|
||||
])->assertUnprocessable();
|
||||
|
||||
expect($verification->fresh()->attempts)->toBe(1);
|
||||
});
|
||||
|
||||
test('verifying locks out after too many wrong attempts', function () {
|
||||
$verification = RegistrationVerification::factory()->create(['attempts' => 5]);
|
||||
|
||||
$this->postJson('/api/v1/auth/registration/verify-code', [
|
||||
'identifier' => $verification->identifier,
|
||||
'code' => '000000',
|
||||
])->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('verifying an expired code fails', function () {
|
||||
$verification = RegistrationVerification::factory()->expired()->create();
|
||||
|
||||
$this->postJson('/api/v1/auth/registration/verify-code', [
|
||||
'identifier' => $verification->identifier,
|
||||
'code' => '000000',
|
||||
])->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('registering with a valid verification token creates a user and returns a token', function () {
|
||||
$verification = RegistrationVerification::factory()->verified()->create(['identifier' => 'new@example.com']);
|
||||
|
||||
$response = $this->postJson('/api/v1/auth/register', [
|
||||
'verification_token' => $verification->verification_token,
|
||||
'name' => 'Jane Doe',
|
||||
'password' => 'super-secret-password',
|
||||
'password_confirmation' => 'super-secret-password',
|
||||
'device_name' => 'iphone',
|
||||
]);
|
||||
|
||||
$response->assertSuccessful()->assertJsonStructure(['token']);
|
||||
|
||||
$user = User::where('email', 'new@example.com')->sole();
|
||||
expect($user->name)->toBe('Jane Doe')
|
||||
->and($verification->fresh()->consumed_at)->not->toBeNull();
|
||||
|
||||
$accessToken = $user->tokens()->sole();
|
||||
expect($accessToken->abilities)->toEqualCanonicalizing(TokenAbility::customerAbilities());
|
||||
});
|
||||
|
||||
test('registering fails when the verification token was already consumed', function () {
|
||||
$verification = RegistrationVerification::factory()->verified()->create([
|
||||
'identifier' => 'new@example.com',
|
||||
'consumed_at' => now(),
|
||||
]);
|
||||
|
||||
$this->postJson('/api/v1/auth/register', [
|
||||
'verification_token' => $verification->verification_token,
|
||||
'name' => 'Jane Doe',
|
||||
'password' => 'super-secret-password',
|
||||
'password_confirmation' => 'super-secret-password',
|
||||
'device_name' => 'iphone',
|
||||
])->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('registering fails with an unknown verification token', function () {
|
||||
$this->postJson('/api/v1/auth/register', [
|
||||
'verification_token' => 'not-a-real-token',
|
||||
'name' => 'Jane Doe',
|
||||
'password' => 'super-secret-password',
|
||||
'password_confirmation' => 'super-secret-password',
|
||||
'device_name' => 'iphone',
|
||||
])->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('the full request-code, verify-code, register flow works end to end', function () {
|
||||
Mail::fake();
|
||||
|
||||
$this->postJson('/api/v1/auth/registration/request-code', ['identifier' => 'flow@example.com'])
|
||||
->assertSuccessful();
|
||||
|
||||
$verification = RegistrationVerification::where('identifier', 'flow@example.com')->sole();
|
||||
$verification->forceFill(['code' => Hash::make('111222')])->save();
|
||||
|
||||
$verifyResponse = $this->postJson('/api/v1/auth/registration/verify-code', [
|
||||
'identifier' => 'flow@example.com',
|
||||
'code' => '111222',
|
||||
])->assertSuccessful();
|
||||
|
||||
$registerResponse = $this->postJson('/api/v1/auth/register', [
|
||||
'verification_token' => $verifyResponse->json('verification_token'),
|
||||
'name' => 'Flow User',
|
||||
'password' => 'super-secret-password',
|
||||
'password_confirmation' => 'super-secret-password',
|
||||
'device_name' => 'iphone',
|
||||
])->assertSuccessful();
|
||||
|
||||
$token = $registerResponse->json('token');
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/v1/companies')
|
||||
->assertSuccessful();
|
||||
});
|
||||
Reference in New Issue
Block a user