feat(auth): harden login flows and finalize quality-check coverage
This commit is contained in:
362
tests/Service/Auth/PasswordResetServiceTest.php
Normal file
362
tests/Service/Auth/PasswordResetServiceTest.php
Normal file
@@ -0,0 +1,362 @@
|
||||
<?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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user