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:
Nyan Lin Paing
2026-08-30 14:53:18 +07:00
parent b6934e1fb5
commit 914b7f97f3
14 changed files with 677 additions and 0 deletions
@@ -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();
});