914b7f97f3
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.
189 lines
7.1 KiB
PHP
189 lines
7.1 KiB
PHP
<?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();
|
|
});
|