363 lines
14 KiB
PHP
363 lines
14 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
namespace MintyPHP\Tests\Service\Auth;
|
||
|
|
|
||
|
|
use MintyPHP\Repository\Auth\PasswordResetRepositoryInterface;
|
||
|
|
use MintyPHP\Repository\User\UserReadRepositoryInterface;
|
||
|
|
use MintyPHP\Service\Auth\PasswordResetService;
|
||
|
|
use MintyPHP\Service\Auth\RememberMeService;
|
||
|
|
use MintyPHP\Service\Mail\MailService;
|
||
|
|
use MintyPHP\Service\User\UserPasswordService;
|
||
|
|
use PHPUnit\Framework\TestCase;
|
||
|
|
|
||
|
|
class PasswordResetServiceTest extends TestCase
|
||
|
|
{
|
||
|
|
public function testRequestResetRejectsEmptyEmail(): void
|
||
|
|
{
|
||
|
|
$service = $this->newService();
|
||
|
|
|
||
|
|
$result = $service->requestReset(' ');
|
||
|
|
|
||
|
|
$this->assertFalse($result['ok']);
|
||
|
|
$this->assertSame('email_required', $result['error']);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function testRequestResetReturnsOkForUnknownEmailWithoutCreatingReset(): void
|
||
|
|
{
|
||
|
|
$userReadRepository = $this->createMock(UserReadRepositoryInterface::class);
|
||
|
|
$userReadRepository->expects($this->once())
|
||
|
|
->method('findByEmail')
|
||
|
|
->with('missing@example.com')
|
||
|
|
->willReturn(null);
|
||
|
|
|
||
|
|
$passwordResetRepository = $this->createMock(PasswordResetRepositoryInterface::class);
|
||
|
|
$passwordResetRepository->expects($this->never())->method('create');
|
||
|
|
$passwordResetRepository->expects($this->never())->method('invalidateForUser');
|
||
|
|
|
||
|
|
$mailService = $this->createMock(MailService::class);
|
||
|
|
$mailService->expects($this->never())->method('sendTemplate');
|
||
|
|
|
||
|
|
$service = $this->newService(
|
||
|
|
userReadRepository: $userReadRepository,
|
||
|
|
passwordResetRepository: $passwordResetRepository,
|
||
|
|
mailService: $mailService
|
||
|
|
);
|
||
|
|
|
||
|
|
$result = $service->requestReset('missing@example.com');
|
||
|
|
|
||
|
|
$this->assertTrue($result['ok']);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function testRequestResetReturnsCreateFailedWhenRepositoryCreateFails(): void
|
||
|
|
{
|
||
|
|
$userReadRepository = $this->createMock(UserReadRepositoryInterface::class);
|
||
|
|
$userReadRepository->method('findByEmail')->willReturn([
|
||
|
|
'id' => 7,
|
||
|
|
'email' => 'user@example.com',
|
||
|
|
'locale' => 'en',
|
||
|
|
'first_name' => 'Ada',
|
||
|
|
'last_name' => 'Lovelace',
|
||
|
|
]);
|
||
|
|
|
||
|
|
$passwordResetRepository = $this->createMock(PasswordResetRepositoryInterface::class);
|
||
|
|
$passwordResetRepository->expects($this->once())->method('invalidateForUser')->with(7);
|
||
|
|
$passwordResetRepository->expects($this->once())
|
||
|
|
->method('create')
|
||
|
|
->with(
|
||
|
|
7,
|
||
|
|
$this->callback(static fn (string $hash): bool => (password_get_info($hash)['algo'] ?? null) !== null),
|
||
|
|
$this->callback(static fn (string $expiresAt): bool => strtotime($expiresAt . ' UTC') !== false)
|
||
|
|
)
|
||
|
|
->willReturn(null);
|
||
|
|
|
||
|
|
$mailService = $this->createMock(MailService::class);
|
||
|
|
$mailService->expects($this->never())->method('sendTemplate');
|
||
|
|
|
||
|
|
$service = $this->newService(
|
||
|
|
userReadRepository: $userReadRepository,
|
||
|
|
passwordResetRepository: $passwordResetRepository,
|
||
|
|
mailService: $mailService
|
||
|
|
);
|
||
|
|
|
||
|
|
$result = $service->requestReset('user@example.com', 'en');
|
||
|
|
|
||
|
|
$this->assertFalse($result['ok']);
|
||
|
|
$this->assertSame('create_failed', $result['error']);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function testRequestResetCreatesResetAndSendsMailWhenUserExists(): void
|
||
|
|
{
|
||
|
|
$userReadRepository = $this->createMock(UserReadRepositoryInterface::class);
|
||
|
|
$userReadRepository->method('findByEmail')->willReturn([
|
||
|
|
'id' => 8,
|
||
|
|
'email' => 'user@example.com',
|
||
|
|
'locale' => 'en',
|
||
|
|
'first_name' => 'Ada',
|
||
|
|
'last_name' => 'Lovelace',
|
||
|
|
]);
|
||
|
|
|
||
|
|
$passwordResetRepository = $this->createMock(PasswordResetRepositoryInterface::class);
|
||
|
|
$passwordResetRepository->expects($this->once())->method('invalidateForUser')->with(8);
|
||
|
|
$passwordResetRepository->expects($this->once())->method('create')->willReturn(42);
|
||
|
|
|
||
|
|
$mailService = $this->createMock(MailService::class);
|
||
|
|
$mailService->expects($this->once())
|
||
|
|
->method('sendTemplate')
|
||
|
|
->with(
|
||
|
|
'reset_code',
|
||
|
|
$this->callback(static function (array $vars): bool {
|
||
|
|
return preg_match('/^\d{6}$/', (string) ($vars['code'] ?? '')) === 1
|
||
|
|
&& (int) ($vars['expires_minutes'] ?? 0) === 15
|
||
|
|
&& str_contains((string) ($vars['verify_url'] ?? ''), 'password/verify');
|
||
|
|
}),
|
||
|
|
'user@example.com',
|
||
|
|
$this->isString(),
|
||
|
|
'en'
|
||
|
|
)
|
||
|
|
->willReturn(['ok' => true]);
|
||
|
|
|
||
|
|
$service = $this->newService(
|
||
|
|
userReadRepository: $userReadRepository,
|
||
|
|
passwordResetRepository: $passwordResetRepository,
|
||
|
|
mailService: $mailService
|
||
|
|
);
|
||
|
|
|
||
|
|
$result = $service->requestReset('user@example.com', 'en');
|
||
|
|
|
||
|
|
$this->assertTrue($result['ok']);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function testVerifyCodeRejectsEmptyInput(): void
|
||
|
|
{
|
||
|
|
$service = $this->newService();
|
||
|
|
|
||
|
|
$result = $service->verifyCode('', '');
|
||
|
|
|
||
|
|
$this->assertFalse($result['ok']);
|
||
|
|
$this->assertSame('invalid', $result['error']);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function testVerifyCodeReturnsTooManyAttemptsWhenLimitReached(): void
|
||
|
|
{
|
||
|
|
$userReadRepository = $this->createMock(UserReadRepositoryInterface::class);
|
||
|
|
$userReadRepository->method('findByEmail')->willReturn(['id' => 9]);
|
||
|
|
|
||
|
|
$passwordResetRepository = $this->createMock(PasswordResetRepositoryInterface::class);
|
||
|
|
$passwordResetRepository->method('findActiveByUserId')->willReturn([
|
||
|
|
'id' => 91,
|
||
|
|
'attempts' => 5,
|
||
|
|
'code_hash' => password_hash('123456', PASSWORD_DEFAULT),
|
||
|
|
]);
|
||
|
|
$passwordResetRepository->expects($this->never())->method('incrementAttempts');
|
||
|
|
|
||
|
|
$service = $this->newService(
|
||
|
|
userReadRepository: $userReadRepository,
|
||
|
|
passwordResetRepository: $passwordResetRepository
|
||
|
|
);
|
||
|
|
|
||
|
|
$result = $service->verifyCode('user@example.com', '123456');
|
||
|
|
|
||
|
|
$this->assertFalse($result['ok']);
|
||
|
|
$this->assertSame('too_many_attempts', $result['error']);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function testVerifyCodeIncrementsAttemptsOnWrongCode(): void
|
||
|
|
{
|
||
|
|
$userReadRepository = $this->createMock(UserReadRepositoryInterface::class);
|
||
|
|
$userReadRepository->method('findByEmail')->willReturn(['id' => 11]);
|
||
|
|
|
||
|
|
$passwordResetRepository = $this->createMock(PasswordResetRepositoryInterface::class);
|
||
|
|
$passwordResetRepository->method('findActiveByUserId')->willReturn([
|
||
|
|
'id' => 111,
|
||
|
|
'attempts' => 1,
|
||
|
|
'code_hash' => password_hash('654321', PASSWORD_DEFAULT),
|
||
|
|
]);
|
||
|
|
$passwordResetRepository->expects($this->once())->method('incrementAttempts')->with(111)->willReturn(true);
|
||
|
|
|
||
|
|
$service = $this->newService(
|
||
|
|
userReadRepository: $userReadRepository,
|
||
|
|
passwordResetRepository: $passwordResetRepository
|
||
|
|
);
|
||
|
|
|
||
|
|
$result = $service->verifyCode('user@example.com', '123456');
|
||
|
|
|
||
|
|
$this->assertFalse($result['ok']);
|
||
|
|
$this->assertSame('invalid', $result['error']);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function testVerifyCodeReturnsOkForValidCode(): void
|
||
|
|
{
|
||
|
|
$userReadRepository = $this->createMock(UserReadRepositoryInterface::class);
|
||
|
|
$userReadRepository->method('findByEmail')->willReturn(['id' => 12]);
|
||
|
|
|
||
|
|
$passwordResetRepository = $this->createMock(PasswordResetRepositoryInterface::class);
|
||
|
|
$passwordResetRepository->method('findActiveByUserId')->willReturn([
|
||
|
|
'id' => 121,
|
||
|
|
'attempts' => 0,
|
||
|
|
'code_hash' => password_hash('123456', PASSWORD_DEFAULT),
|
||
|
|
]);
|
||
|
|
$passwordResetRepository->expects($this->never())->method('incrementAttempts');
|
||
|
|
|
||
|
|
$service = $this->newService(
|
||
|
|
userReadRepository: $userReadRepository,
|
||
|
|
passwordResetRepository: $passwordResetRepository
|
||
|
|
);
|
||
|
|
|
||
|
|
$result = $service->verifyCode('user@example.com', '123456');
|
||
|
|
|
||
|
|
$this->assertTrue($result['ok']);
|
||
|
|
$this->assertSame(121, (int) ($result['reset_id'] ?? 0));
|
||
|
|
$this->assertSame(12, (int) ($result['user_id'] ?? 0));
|
||
|
|
}
|
||
|
|
|
||
|
|
public function testResetPasswordReturnsErrorWhenResetRequestMissing(): void
|
||
|
|
{
|
||
|
|
$passwordResetRepository = $this->createMock(PasswordResetRepositoryInterface::class);
|
||
|
|
$passwordResetRepository->method('findById')->willReturn(null);
|
||
|
|
|
||
|
|
$service = $this->newService(passwordResetRepository: $passwordResetRepository);
|
||
|
|
|
||
|
|
$result = $service->resetPassword(77, 'StrongPass1!', 'StrongPass1!');
|
||
|
|
|
||
|
|
$this->assertFalse($result['ok']);
|
||
|
|
$this->assertSame([t('Reset request not found')], $result['errors']);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function testResetPasswordReturnsErrorWhenResetRequestAlreadyUsed(): void
|
||
|
|
{
|
||
|
|
$passwordResetRepository = $this->createMock(PasswordResetRepositoryInterface::class);
|
||
|
|
$passwordResetRepository->method('findById')->willReturn([
|
||
|
|
'id' => 80,
|
||
|
|
'user_id' => 5,
|
||
|
|
'used_at' => gmdate('Y-m-d H:i:s'),
|
||
|
|
]);
|
||
|
|
|
||
|
|
$service = $this->newService(passwordResetRepository: $passwordResetRepository);
|
||
|
|
|
||
|
|
$result = $service->resetPassword(80, 'StrongPass1!', 'StrongPass1!');
|
||
|
|
|
||
|
|
$this->assertFalse($result['ok']);
|
||
|
|
$this->assertSame([t('Reset request already used')], $result['errors']);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function testResetPasswordReturnsErrorWhenResetRequestExpired(): void
|
||
|
|
{
|
||
|
|
$passwordResetRepository = $this->createMock(PasswordResetRepositoryInterface::class);
|
||
|
|
$passwordResetRepository->method('findById')->willReturn([
|
||
|
|
'id' => 81,
|
||
|
|
'user_id' => 5,
|
||
|
|
'used_at' => null,
|
||
|
|
'expires_at' => gmdate('Y-m-d H:i:s', time() - 60),
|
||
|
|
]);
|
||
|
|
|
||
|
|
$service = $this->newService(passwordResetRepository: $passwordResetRepository);
|
||
|
|
|
||
|
|
$result = $service->resetPassword(81, 'StrongPass1!', 'StrongPass1!');
|
||
|
|
|
||
|
|
$this->assertFalse($result['ok']);
|
||
|
|
$this->assertSame([t('Reset request expired')], $result['errors']);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function testResetPasswordReturnsErrorWhenResetUserIdIsInvalid(): void
|
||
|
|
{
|
||
|
|
$passwordResetRepository = $this->createMock(PasswordResetRepositoryInterface::class);
|
||
|
|
$passwordResetRepository->method('findById')->willReturn([
|
||
|
|
'id' => 82,
|
||
|
|
'user_id' => 0,
|
||
|
|
'used_at' => null,
|
||
|
|
'expires_at' => gmdate('Y-m-d H:i:s', time() + 300),
|
||
|
|
]);
|
||
|
|
|
||
|
|
$service = $this->newService(passwordResetRepository: $passwordResetRepository);
|
||
|
|
|
||
|
|
$result = $service->resetPassword(82, 'StrongPass1!', 'StrongPass1!');
|
||
|
|
|
||
|
|
$this->assertFalse($result['ok']);
|
||
|
|
$this->assertSame([t('Reset request not found')], $result['errors']);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function testResetPasswordReturnsValidationErrorsFromUserPasswordService(): void
|
||
|
|
{
|
||
|
|
$passwordResetRepository = $this->createMock(PasswordResetRepositoryInterface::class);
|
||
|
|
$passwordResetRepository->method('findById')->willReturn([
|
||
|
|
'id' => 83,
|
||
|
|
'user_id' => 17,
|
||
|
|
'used_at' => null,
|
||
|
|
'expires_at' => gmdate('Y-m-d H:i:s', time() + 300),
|
||
|
|
]);
|
||
|
|
$passwordResetRepository->expects($this->never())->method('markUsed');
|
||
|
|
|
||
|
|
$userPasswordService = $this->createMock(UserPasswordService::class);
|
||
|
|
$userPasswordService->expects($this->once())
|
||
|
|
->method('resetPassword')
|
||
|
|
->with(17, 'weak', 'weak')
|
||
|
|
->willReturn(['ok' => false, 'errors' => ['weak']]);
|
||
|
|
|
||
|
|
$rememberMeService = $this->createMock(RememberMeService::class);
|
||
|
|
$rememberMeService->expects($this->never())->method('forgetAllForUser');
|
||
|
|
|
||
|
|
$service = $this->newService(
|
||
|
|
passwordResetRepository: $passwordResetRepository,
|
||
|
|
userPasswordService: $userPasswordService,
|
||
|
|
rememberMeService: $rememberMeService
|
||
|
|
);
|
||
|
|
|
||
|
|
$result = $service->resetPassword(83, 'weak', 'weak');
|
||
|
|
|
||
|
|
$this->assertSame(['ok' => false, 'errors' => ['weak']], $result);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function testResetPasswordMarksRequestUsedAndExpiresRememberTokensOnSuccess(): void
|
||
|
|
{
|
||
|
|
$passwordResetRepository = $this->createMock(PasswordResetRepositoryInterface::class);
|
||
|
|
$passwordResetRepository->method('findById')->willReturn([
|
||
|
|
'id' => 84,
|
||
|
|
'user_id' => 18,
|
||
|
|
'used_at' => null,
|
||
|
|
'expires_at' => gmdate('Y-m-d H:i:s', time() + 300),
|
||
|
|
]);
|
||
|
|
$passwordResetRepository->expects($this->once())->method('markUsed')->with(84)->willReturn(true);
|
||
|
|
|
||
|
|
$userPasswordService = $this->createMock(UserPasswordService::class);
|
||
|
|
$userPasswordService->expects($this->once())
|
||
|
|
->method('resetPassword')
|
||
|
|
->with(18, 'StrongPass1!', 'StrongPass1!')
|
||
|
|
->willReturn(['ok' => true]);
|
||
|
|
|
||
|
|
$rememberMeService = $this->createMock(RememberMeService::class);
|
||
|
|
$rememberMeService->expects($this->once())->method('forgetAllForUser')->with(18);
|
||
|
|
|
||
|
|
$service = $this->newService(
|
||
|
|
passwordResetRepository: $passwordResetRepository,
|
||
|
|
userPasswordService: $userPasswordService,
|
||
|
|
rememberMeService: $rememberMeService
|
||
|
|
);
|
||
|
|
|
||
|
|
$result = $service->resetPassword(84, 'StrongPass1!', 'StrongPass1!');
|
||
|
|
|
||
|
|
$this->assertTrue($result['ok']);
|
||
|
|
}
|
||
|
|
|
||
|
|
private function newService(
|
||
|
|
?UserReadRepositoryInterface $userReadRepository = null,
|
||
|
|
?PasswordResetRepositoryInterface $passwordResetRepository = null,
|
||
|
|
?UserPasswordService $userPasswordService = null,
|
||
|
|
?RememberMeService $rememberMeService = null,
|
||
|
|
?MailService $mailService = null
|
||
|
|
): PasswordResetService {
|
||
|
|
$userReadRepository ??= $this->createMock(UserReadRepositoryInterface::class);
|
||
|
|
$passwordResetRepository ??= $this->createMock(PasswordResetRepositoryInterface::class);
|
||
|
|
$userPasswordService ??= $this->createMock(UserPasswordService::class);
|
||
|
|
$rememberMeService ??= $this->createMock(RememberMeService::class);
|
||
|
|
$mailService ??= $this->createMock(MailService::class);
|
||
|
|
|
||
|
|
return new PasswordResetService(
|
||
|
|
$userReadRepository,
|
||
|
|
$passwordResetRepository,
|
||
|
|
$userPasswordService,
|
||
|
|
$rememberMeService,
|
||
|
|
$mailService
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|