diff --git a/composer.json b/composer.json index ea1174f..e49d165 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,7 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.26", + "mockery/mockery": "^1.6", "nunomaduro/larastan": "^2.0", "orchestra/testbench": "^7.0|^8.0", "pestphp/pest": "^1.23|^2.18", diff --git a/src/AuthResult.php b/src/AuthResult.php index 3821f0a..8619da2 100644 --- a/src/AuthResult.php +++ b/src/AuthResult.php @@ -58,6 +58,9 @@ public function getUser(): ?AuthenticatableInterface public function throwIfError(): self { + /** + * @phpstan-ignore-next-line + */ return ! $this->isSuccessful() ? throw $this->getException() : $this; } } diff --git a/src/Infrastructure/OneTimePassword/Repositories/OneTimePasswordVerifierRepository.php b/src/Infrastructure/OneTimePassword/Repositories/OneTimePasswordVerifierRepository.php index a084e32..e55a29e 100644 --- a/src/Infrastructure/OneTimePassword/Repositories/OneTimePasswordVerifierRepository.php +++ b/src/Infrastructure/OneTimePassword/Repositories/OneTimePasswordVerifierRepository.php @@ -29,9 +29,6 @@ public function getFailedAttemptsCount(OneTimePasswordEntityInterface $entity): public function incrementFailAttemptsCount(OneTimePasswordEntityInterface $entity, int $value = 1): int { - /** - * @phpstan-ignore-next-line - */ $value = $this->connection->incr($key = self::getKey($entity->getKey()), $value); $this->connection->expire($key, (int)$entity->getValidInterval()->addDays(1)->totalSeconds); diff --git a/src/Infrastructure/OneTimePassword/Support/CodeGenerator.php b/src/Infrastructure/OneTimePassword/Support/CodeGenerator.php index 1dbd42b..3b78af8 100644 --- a/src/Infrastructure/OneTimePassword/Support/CodeGenerator.php +++ b/src/Infrastructure/OneTimePassword/Support/CodeGenerator.php @@ -2,27 +2,15 @@ namespace LaravelAuthPro\Infrastructure\OneTimePassword\Support; -use Illuminate\Contracts\Config\Repository; use Illuminate\Support\Str; use LaravelAuthPro\Contracts\Base\GeneratorInterface; use LaravelAuthPro\Infrastructure\OneTimePassword\Enum\OneTimePasswordCodeType; class CodeGenerator implements GeneratorInterface { - protected readonly int $length; - protected readonly OneTimePasswordCodeType $type; - - public function __construct(Repository $configRepository) + public function __construct(protected readonly OneTimePasswordCodeType $type = OneTimePasswordCodeType::DIGIT, protected readonly int $length = 6) { - /** - * @phpstan-ignore-next-line - */ - $this->length = $configRepository->get('auth_pro.one_time_password.code.length', 6); - - /** - * @phpstan-ignore-next-line - */ - $this->type = $configRepository->get('auth_pro.one_time_password.code.type', OneTimePasswordCodeType::DIGIT); + // } public function generate(int $length = null): string diff --git a/src/Infrastructure/OneTimePassword/Support/TokenGenerator.php b/src/Infrastructure/OneTimePassword/Support/TokenGenerator.php index 0073b2f..80ddc84 100644 --- a/src/Infrastructure/OneTimePassword/Support/TokenGenerator.php +++ b/src/Infrastructure/OneTimePassword/Support/TokenGenerator.php @@ -2,27 +2,15 @@ namespace LaravelAuthPro\Infrastructure\OneTimePassword\Support; -use Illuminate\Contracts\Config\Repository; use Illuminate\Support\Str; use LaravelAuthPro\Contracts\Base\GeneratorInterface; use LaravelAuthPro\Infrastructure\OneTimePassword\Enum\OneTimePasswordTokenType; class TokenGenerator implements GeneratorInterface { - protected readonly int $length; - protected readonly OneTimePasswordTokenType $type; - - public function __construct(Repository $configRepository) + public function __construct(protected readonly OneTimePasswordTokenType $type = OneTimePasswordTokenType::RANDOM_STRING, protected readonly int $length = 8) { - /** - * @phpstan-ignore-next-line - */ - $this->length = $configRepository->get('one_time_password.token.length', 8); - - /** - * @phpstan-ignore-next-line - */ - $this->type = OneTimePasswordTokenType::from($configRepository->get('one_time_password.token.type', 'random_string')); + // } /** @@ -42,6 +30,14 @@ public function generate(int $length = null): string private function generateRandomInt(int $length): int { + if ($length > 18) { + // It's reached PHP_MAX_INT value and will convert to float, but random_int method accept int as min and max. + throw new \RuntimeException('$length is too large. Length should not above 18'); + } + + /** + * @phpstan-ignore-next-line + */ return random_int(10 ** ($length - 1), (10 ** $length) - 1); } } diff --git a/src/Traits/HasPayload.php b/src/Traits/HasPayload.php index c58a130..71df3f2 100644 --- a/src/Traits/HasPayload.php +++ b/src/Traits/HasPayload.php @@ -11,7 +11,7 @@ trait HasPayload private function fillAttributes(array $payload): void { $staticProperties = array_keys(get_class_vars(static::class)); - $staticProperties = array_diff($staticProperties, array_keys(get_object_vars($this))); + // $staticProperties = array_diff($staticProperties, array_keys(get_object_vars($this))); foreach ($staticProperties as $property) { if (! empty($payload[$property])) { diff --git a/tests/Unit/Base/BaseServiceTest.php b/tests/Unit/Base/BaseServiceTest.php new file mode 100644 index 0000000..663ef29 --- /dev/null +++ b/tests/Unit/Base/BaseServiceTest.php @@ -0,0 +1,20 @@ +hasRepository())->toBeTrue(); + + $class = new $class(); + expect($class->hasRepository()) + ->toBeFalse() + ->and(fn () => $class->throwIfRepositoryNotProvided()) + ->toThrow(\InvalidArgumentException::class); + }); +}); diff --git a/tests/Unit/Contracts/Exceptions/AuthExceptionTest.php b/tests/Unit/Contracts/Exceptions/AuthExceptionTest.php new file mode 100644 index 0000000..9e495bb --- /dev/null +++ b/tests/Unit/Contracts/Exceptions/AuthExceptionTest.php @@ -0,0 +1,42 @@ + 'bar']); + + $translatorMock = Mockery::mock(\Illuminate\Translation\Translator::class) + ->shouldReceive('get') + ->andReturn('My Error') + ->getMock(); + + $reposeFactory = Mockery::mock($rf = \Illuminate\Contracts\Routing\ResponseFactory::class) + ->shouldReceive('make') + ->andReturn(new \Illuminate\Http\Response()) + ->getMock(); + + app()['translator'] = $translatorMock; + app()[$rf] = $reposeFactory; + + expect($e->getErrorMessage()) + ->toEqual('my_error') + ->and($e->getCode()) + ->toEqual(500) + ->and($e->report()) + ->toBeFalse() + ->and($e->render(new \Illuminate\Http\Request())) + ->toBeInstanceOf(\Illuminate\Http\Response::class); + }); + + it('custom render function', function () { + $e = new \LaravelAuthPro\Contracts\Exceptions\AuthException('my_error', 500, ['foo' => 'bar']); + + \LaravelAuthPro\Contracts\Exceptions\AuthException::setRenderClosure(function (\LaravelAuthPro\Contracts\Exceptions\AuthException $exception) use ($e) { + expect($exception)->toBe($e); + + return ['error' => $e->getErrorMessage(), 'code' => $e->getCode()]; + }); + + expect($e->render(new \Illuminate\Http\Request())) + ->toBe(['error' => 'my_error', 'code' => 500]); + }); +}); diff --git a/tests/Unit/Credential/AuthCredentialTest.php b/tests/Unit/Credential/AuthCredentialTest.php new file mode 100644 index 0000000..7859469 --- /dev/null +++ b/tests/Unit/Credential/AuthCredentialTest.php @@ -0,0 +1,48 @@ +andReturn([ + \LaravelAuthPro\Contracts\Providers\EmailProviderInterface::class => \LaravelAuthPro\Contracts\Credentials\EmailCredentialInterface::class, + \LaravelAuthPro\Contracts\Providers\PhoneProviderInterface::class => \LaravelAuthPro\Contracts\Credentials\PhoneCredentialInterface::class, + ]); + + \LaravelAuthPro\AuthPro::shouldReceive('getAuthProvidersMapper')->andReturn([ + \LaravelAuthPro\Contracts\Providers\EmailProviderInterface::class => \LaravelAuthPro\Providers\EmailProvider::class, + \LaravelAuthPro\Contracts\Providers\PhoneProviderInterface::class => \LaravelAuthPro\Providers\PhoneProvider::class, + ]); +}); + +describe('test auth credential model class', function () { + it('throw if identifier type not supported', function () { + $identifier = Mockery::mock(\LaravelAuthPro\Contracts\AuthIdentifierInterface::class) + ->shouldReceive('getIdentifierType')->andReturn(\LaravelAuthPro\Enums\AuthIdentifierType::EMAIL) + ->getMock(); + + app()->bind(\LaravelAuthPro\Contracts\Credentials\PhoneCredentialInterface::class, \LaravelAuthPro\Credentials\PhoneCredential::class); + + (new \LaravelAuthPro\Credentials\Builder\AuthCredentialBuilder()) + ->with('phone') + ->as($identifier) + ->by(\LaravelAuthPro\Enums\AuthProviderSignInMethod::PASSWORD) + ->withPayload() + ->build(); + })->throws(\InvalidArgumentException::class, 'Invalid identifier type [EMAIL] in PhoneCredential'); + + it('payload rule must be available according to base interface parents', function () { + expect(\LaravelAuthPro\Credentials\EmailCredential::class)->toImplement(\LaravelAuthPro\Contracts\Credentials\EmailCredentialInterface::class); + + expect(\LaravelAuthPro\Contracts\Credentials\EmailCredentialInterface::class)->toExtend(\LaravelAuthPro\Contracts\Credentials\Base\HasPasswordInterface::class) + ->and(\LaravelAuthPro\Credentials\EmailCredential::class)->toExtend(\LaravelAuthPro\Contracts\Credentials\Base\HasOneTimePasswordInterface::class); + + expect(\LaravelAuthPro\Credentials\EmailCredential::getPayloadRules()) + ->toHaveKeys(['password', 'code', 'token']); + + expect(\LaravelAuthPro\Credentials\PhoneCredential::getPayloadRules()) + ->toHaveKeys(['password', 'code', 'token']); + }); + + it('return correct builder class', function () { + expect(\LaravelAuthPro\Credentials\EmailCredential::getBuilder()) + ->toBeInstanceOf(\LaravelAuthPro\Credentials\Builder\AuthCredentialBuilder::class); + }); +}); diff --git a/tests/Unit/Credential/Builder/AuthCredentialBuilderTest.php b/tests/Unit/Credential/Builder/AuthCredentialBuilderTest.php new file mode 100644 index 0000000..881f6b4 --- /dev/null +++ b/tests/Unit/Credential/Builder/AuthCredentialBuilderTest.php @@ -0,0 +1,66 @@ +andReturn([ + \LaravelAuthPro\Contracts\Providers\EmailProviderInterface::class => \LaravelAuthPro\Contracts\Credentials\EmailCredentialInterface::class, + \LaravelAuthPro\Contracts\Providers\PhoneProviderInterface::class => \LaravelAuthPro\Contracts\Credentials\PhoneCredentialInterface::class, + ]); + + \LaravelAuthPro\AuthPro::shouldReceive('getAuthProvidersMapper')->andReturn([ + \LaravelAuthPro\Contracts\Providers\EmailProviderInterface::class => \LaravelAuthPro\Providers\EmailProvider::class, + \LaravelAuthPro\Contracts\Providers\PhoneProviderInterface::class => \LaravelAuthPro\Providers\PhoneProvider::class, + ]); +}); + +describe('test auth credential builder', function () { + it('build email credential class', function () { + $authIdentifier = Mockery::mock(\LaravelAuthPro\Contracts\AuthIdentifierInterface::class) + ->shouldReceive('getIdentifierType')->andReturn(\LaravelAuthPro\Enums\AuthIdentifierType::EMAIL) + ->getMock(); + + app()->bind(\LaravelAuthPro\Contracts\Credentials\EmailCredentialInterface::class, \LaravelAuthPro\Credentials\EmailCredential::class); + + $model = (new \LaravelAuthPro\Credentials\Builder\AuthCredentialBuilder()) + ->with('email') + ->by(\LaravelAuthPro\Enums\AuthProviderSignInMethod::PASSWORD) + ->as($authIdentifier) + ->withPayload(['password' => 'foo', 'token' => 'bar', 'code' => 'test']) + ->build(); + + expect($model)->toBeInstanceOf(\LaravelAuthPro\Contracts\Credentials\EmailCredentialInterface::class) + ->and($model->getIdentifier())->toBe($authIdentifier) + ->and($model->getProviderId())->toBe('email') + ->and($model->getSignInMethod())->toBe(\LaravelAuthPro\Enums\AuthProviderSignInMethod::PASSWORD) + ->and($model->getPassword())->toBe('foo') + ->and($model->getOneTimePasswordToken())->toBe('bar') + ->and($model->getOneTimePassword())->toBe('test'); + }); + + it('build phone credential class', function () { + $authIdentifier = Mockery::mock(\LaravelAuthPro\Contracts\AuthIdentifierInterface::class) + ->shouldReceive('getIdentifierType')->andReturn(\LaravelAuthPro\Enums\AuthIdentifierType::MOBILE) + ->getMock(); + + app()->bind(\LaravelAuthPro\Contracts\Credentials\PhoneCredentialInterface::class, \LaravelAuthPro\Credentials\PhoneCredential::class); + + $model = (new \LaravelAuthPro\Credentials\Builder\AuthCredentialBuilder()) + ->with('phone') + ->by(\LaravelAuthPro\Enums\AuthProviderSignInMethod::PASSWORD) + ->as($authIdentifier) + ->withPayload(['password' => 'foo', 'token' => 'bar', 'code' => 'test']) + ->build(); + + expect($model)->toBeInstanceOf(\LaravelAuthPro\Contracts\Credentials\PhoneCredentialInterface::class) + ->and($model->getIdentifier())->toBe($authIdentifier) + ->and($model->getProviderId())->toBe('phone') + ->and($model->getSignInMethod())->toBe(\LaravelAuthPro\Enums\AuthProviderSignInMethod::PASSWORD) + ->and($model->getPassword())->toBe('foo') + ->and($model->getOneTimePasswordToken())->toBe('bar') + ->and($model->getOneTimePassword())->toBe('test'); + }); + + it('throw error when provider is null', function () { + (new \LaravelAuthPro\Credentials\Builder\AuthCredentialBuilder()) + ->build(); + })->throws(\InvalidArgumentException::class); +}); diff --git a/tests/Unit/Credential/EmailCredentialTest.php b/tests/Unit/Credential/EmailCredentialTest.php new file mode 100644 index 0000000..449f952 --- /dev/null +++ b/tests/Unit/Credential/EmailCredentialTest.php @@ -0,0 +1,36 @@ +flush(); + + \LaravelAuthPro\AuthPro::shouldReceive('getCredentialsMapper')->andReturn([ + \LaravelAuthPro\Contracts\Providers\EmailProviderInterface::class => \LaravelAuthPro\Contracts\Credentials\EmailCredentialInterface::class, + \LaravelAuthPro\Contracts\Providers\PhoneProviderInterface::class => \LaravelAuthPro\Contracts\Credentials\PhoneCredentialInterface::class, + ]); + + \LaravelAuthPro\AuthPro::shouldReceive('getAuthProvidersMapper')->andReturn([ + \LaravelAuthPro\Contracts\Providers\EmailProviderInterface::class => \LaravelAuthPro\Providers\EmailProvider::class, + \LaravelAuthPro\Contracts\Providers\PhoneProviderInterface::class => \LaravelAuthPro\Providers\PhoneProvider::class, + ]); +}); + +describe('test email credential', function () { + it('have correct email according to identifier', function () { + $identifier = Mockery::mock(\LaravelAuthPro\Contracts\AuthIdentifierInterface::class) + ->shouldReceive('getIdentifierValue')->andReturn('someone@example.com') + ->shouldReceive('getIdentifierType')->andReturn(\LaravelAuthPro\Enums\AuthIdentifierType::EMAIL) + ->getMock(); + + app()->bind(\LaravelAuthPro\Contracts\Credentials\EmailCredentialInterface::class, \LaravelAuthPro\Credentials\EmailCredential::class); + + $credential = (new \LaravelAuthPro\Credentials\Builder\AuthCredentialBuilder()) + ->with('email') + ->as($identifier) + ->by(\LaravelAuthPro\Enums\AuthProviderSignInMethod::PASSWORD) + ->withPayload() + ->build(); + + expect($credential)->toBeInstanceOf(\LaravelAuthPro\Credentials\EmailCredential::class) + ->and($credential->getEmail())->toBe('someone@example.com'); + }); +}); diff --git a/tests/Unit/Credential/PhoneCredentialTest.php b/tests/Unit/Credential/PhoneCredentialTest.php new file mode 100644 index 0000000..18eff92 --- /dev/null +++ b/tests/Unit/Credential/PhoneCredentialTest.php @@ -0,0 +1,59 @@ +flush(); + + \LaravelAuthPro\AuthPro::shouldReceive('getCredentialsMapper')->andReturn([ + \LaravelAuthPro\Contracts\Providers\EmailProviderInterface::class => \LaravelAuthPro\Contracts\Credentials\EmailCredentialInterface::class, + \LaravelAuthPro\Contracts\Providers\PhoneProviderInterface::class => \LaravelAuthPro\Contracts\Credentials\PhoneCredentialInterface::class, + ]); + + \LaravelAuthPro\AuthPro::shouldReceive('getAuthProvidersMapper')->andReturn([ + \LaravelAuthPro\Contracts\Providers\EmailProviderInterface::class => \LaravelAuthPro\Providers\EmailProvider::class, + \LaravelAuthPro\Contracts\Providers\PhoneProviderInterface::class => \LaravelAuthPro\Providers\PhoneProvider::class, + ]); +}); + +describe('test phone credential', function () { + it('have correct phone according to identifier', function () { + $authIdentifier = Mockery::mock(\LaravelAuthPro\Contracts\AuthIdentifierInterface::class) + ->shouldReceive('getIdentifierValue')->andReturn('111222333') + ->shouldReceive('getIdentifierType')->andReturn(\LaravelAuthPro\Enums\AuthIdentifierType::MOBILE) + ->getMock(); + + app()->bind(\LaravelAuthPro\Contracts\Credentials\PhoneCredentialInterface::class, \LaravelAuthPro\Credentials\PhoneCredential::class); + + $credential = (new \LaravelAuthPro\Credentials\Builder\AuthCredentialBuilder()) + ->with('phone') + ->by(\LaravelAuthPro\Enums\AuthProviderSignInMethod::PASSWORD) + ->as($authIdentifier) + ->withPayload(['password' => 'foo', 'token' => 'bar', 'code' => 'test']) + ->build(); + + expect($credential)->toBeInstanceOf(\LaravelAuthPro\Credentials\PhoneCredential::class) + ->and($credential->getPhone())->toBe('111222333'); + }); + + it('load signature from encrypted string', function () { + $signature = new \LaravelAuthPro\AuthSignature('id', 'ip', 'user_id', now()); + + $authIdentifier = Mockery::mock(\LaravelAuthPro\Contracts\AuthIdentifierInterface::class) + ->shouldReceive('getIdentifierValue')->andReturn('111222333') + ->shouldReceive('getIdentifierType')->andReturn(\LaravelAuthPro\Enums\AuthIdentifierType::MOBILE) + ->getMock(); + + + \Illuminate\Support\Facades\Crypt::shouldReceive('encrypt')->andReturn('encrypted_string'); + \Illuminate\Support\Facades\Crypt::shouldReceive('decrypt')->andReturn($signature->toArray()); + + $credential = (new \LaravelAuthPro\Credentials\Builder\AuthCredentialBuilder()) + ->with('phone') + ->by(\LaravelAuthPro\Enums\AuthProviderSignInMethod::PASSWORD) + ->as($authIdentifier) + ->withPayload(['signature' => $signature->__toString()]) + ->build(); + + expect($credential->getSignature()->toArray())->toBe($signature->toArray()); + }); +}); diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php deleted file mode 100644 index 61cd84c..0000000 --- a/tests/Unit/ExampleTest.php +++ /dev/null @@ -1,5 +0,0 @@ -toBeTrue(); -}); diff --git a/tests/Unit/Support/CodeGeneratorTest.php b/tests/Unit/Support/CodeGeneratorTest.php new file mode 100644 index 0000000..cab9e0c --- /dev/null +++ b/tests/Unit/Support/CodeGeneratorTest.php @@ -0,0 +1,61 @@ +map(fn (int $i) => $i * 2) + ->mapWithKeys(fn (int $length) => [$length => $generator->generate($length)]); + + expect($values) + ->sequence( + fn ($code, $length) => $code->toHaveLength((int)$length->value), + fn ($code) => match ($type) { + OneTimePasswordCodeType::DIGIT => $code->toBeDigits(), + OneTimePasswordCodeType::ALPHA => $code->toBeAlpha(), + } + ); + }) + ->with([ + 'digits' => [OneTimePasswordCodeType::DIGIT], + 'alpha' => [OneTimePasswordCodeType::ALPHA], + ]); +}); + +describe('test token generator', function () { + it('generate random token with specified type', function (OneTimePasswordTokenType $type, int $endRange = 16) { + $generator = new TokenGenerator($type); + + $values = collect(range(1, $endRange)) + ->map(fn (int $i) => $i * 2) + ->mapWithKeys(fn (int $length) => [$length => $generator->generate($length)]); + + expect($values) + ->each( + fn ($code, $length) => match ($type) { + OneTimePasswordTokenType::RANDOM_STRING => $code->toBeString() && $code->toHaveLength($length), + OneTimePasswordTokenType::RANDOM_INT => $code->toBeNumeric() && $code->toHaveLength($length), + OneTimePasswordTokenType::ULID => expect(Ulid::isValid($code->value))->toBeTrue(), + OneTimePasswordTokenType::UUID => $code->toBeUuid(), + } + ); + }) + ->with([ + 'string' => [OneTimePasswordTokenType::RANDOM_STRING], + 'integer' => [OneTimePasswordTokenType::RANDOM_INT, 9], + 'ulid' => [OneTimePasswordTokenType::ULID], + 'uuid' => [OneTimePasswordTokenType::UUID], + ]); + + it('throw error when length is too large for integer type', function () { + (new TokenGenerator(OneTimePasswordTokenType::RANDOM_INT, 20)) + ->generate(); + })->throws(\RuntimeException::class); +});