feat(auth): harden login flows and finalize quality-check coverage
This commit is contained in:
315
tests/Service/Auth/EmailVerificationServiceTest.php
Normal file
315
tests/Service/Auth/EmailVerificationServiceTest.php
Normal file
@@ -0,0 +1,315 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Service\Auth;
|
||||
|
||||
use MintyPHP\Repository\Auth\EmailVerificationRepositoryInterface;
|
||||
use MintyPHP\Repository\User\UserReadRepositoryInterface;
|
||||
use MintyPHP\Repository\User\UserWriteRepositoryInterface;
|
||||
use MintyPHP\Service\Auth\EmailVerificationService;
|
||||
use MintyPHP\Service\Mail\MailService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class EmailVerificationServiceTest extends TestCase
|
||||
{
|
||||
public function testSendVerificationReturnsUserNotFoundWhenUserDoesNotExist(): void
|
||||
{
|
||||
$userReadRepository = $this->createMock(UserReadRepositoryInterface::class);
|
||||
$userReadRepository->expects($this->once())->method('find')->with(9)->willReturn(null);
|
||||
|
||||
$service = $this->newService(userReadRepository: $userReadRepository);
|
||||
$result = $service->sendVerification(9);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('user_not_found', $result['error']);
|
||||
}
|
||||
|
||||
public function testSendVerificationReturnsEmailRequiredWhenEmailIsMissing(): void
|
||||
{
|
||||
$userReadRepository = $this->createMock(UserReadRepositoryInterface::class);
|
||||
$userReadRepository->expects($this->once())->method('find')->with(10)->willReturn([
|
||||
'id' => 10,
|
||||
'email' => '',
|
||||
]);
|
||||
|
||||
$service = $this->newService(userReadRepository: $userReadRepository);
|
||||
$result = $service->sendVerification(10);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('email_required', $result['error']);
|
||||
}
|
||||
|
||||
public function testSendVerificationReturnsCreateFailedWhenRepositoryCreateFails(): void
|
||||
{
|
||||
$userReadRepository = $this->createMock(UserReadRepositoryInterface::class);
|
||||
$userReadRepository->expects($this->once())->method('find')->with(11)->willReturn([
|
||||
'id' => 11,
|
||||
'email' => 'user@example.com',
|
||||
'locale' => 'en',
|
||||
'first_name' => 'Ada',
|
||||
'last_name' => 'Lovelace',
|
||||
]);
|
||||
|
||||
$verificationRepository = $this->createMock(EmailVerificationRepositoryInterface::class);
|
||||
$verificationRepository->expects($this->once())->method('invalidateForUser')->with(11);
|
||||
$verificationRepository->expects($this->once())
|
||||
->method('create')
|
||||
->with(
|
||||
11,
|
||||
$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,
|
||||
emailVerificationRepository: $verificationRepository,
|
||||
mailService: $mailService
|
||||
);
|
||||
$result = $service->sendVerification(11, 'en');
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('create_failed', $result['error']);
|
||||
}
|
||||
|
||||
public function testSendVerificationCreatesCodeAndSendsMail(): void
|
||||
{
|
||||
$userReadRepository = $this->createMock(UserReadRepositoryInterface::class);
|
||||
$userReadRepository->expects($this->once())->method('find')->with(12)->willReturn([
|
||||
'id' => 12,
|
||||
'email' => 'user@example.com',
|
||||
'locale' => 'en',
|
||||
'first_name' => 'Ada',
|
||||
'last_name' => 'Lovelace',
|
||||
]);
|
||||
|
||||
$verificationRepository = $this->createMock(EmailVerificationRepositoryInterface::class);
|
||||
$verificationRepository->expects($this->once())->method('invalidateForUser')->with(12);
|
||||
$verificationRepository->expects($this->once())->method('create')->willReturn(120);
|
||||
|
||||
$mailService = $this->createMock(MailService::class);
|
||||
$mailService->expects($this->once())
|
||||
->method('sendTemplate')
|
||||
->with(
|
||||
'email_verification',
|
||||
$this->callback(static function (array $vars): bool {
|
||||
return preg_match('/^\d{6}$/', (string) ($vars['code'] ?? '')) === 1
|
||||
&& (int) ($vars['expires_minutes'] ?? 0) === 30
|
||||
&& str_contains((string) ($vars['verify_url'] ?? ''), 'verify-email');
|
||||
}),
|
||||
'user@example.com',
|
||||
$this->isString(),
|
||||
'en'
|
||||
)
|
||||
->willReturn(['ok' => true]);
|
||||
|
||||
$service = $this->newService(
|
||||
userReadRepository: $userReadRepository,
|
||||
emailVerificationRepository: $verificationRepository,
|
||||
mailService: $mailService
|
||||
);
|
||||
$result = $service->sendVerification(12, '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 testVerifyCodeReturnsAlreadyVerifiedWhenUserIsAlreadyVerified(): void
|
||||
{
|
||||
$userReadRepository = $this->createMock(UserReadRepositoryInterface::class);
|
||||
$userReadRepository->method('findByEmail')->willReturn([
|
||||
'id' => 13,
|
||||
'email_verified_at' => gmdate('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
$service = $this->newService(userReadRepository: $userReadRepository);
|
||||
$result = $service->verifyCode('user@example.com', '123456');
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('already_verified', $result['error']);
|
||||
}
|
||||
|
||||
public function testVerifyCodeReturnsTooManyAttemptsWhenLimitReached(): void
|
||||
{
|
||||
$userReadRepository = $this->createMock(UserReadRepositoryInterface::class);
|
||||
$userReadRepository->method('findByEmail')->willReturn([
|
||||
'id' => 14,
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
|
||||
$verificationRepository = $this->createMock(EmailVerificationRepositoryInterface::class);
|
||||
$verificationRepository->method('findActiveByUserId')->willReturn([
|
||||
'id' => 140,
|
||||
'attempts' => 5,
|
||||
'code_hash' => password_hash('123456', PASSWORD_DEFAULT),
|
||||
]);
|
||||
$verificationRepository->expects($this->never())->method('incrementAttempts');
|
||||
|
||||
$service = $this->newService(
|
||||
userReadRepository: $userReadRepository,
|
||||
emailVerificationRepository: $verificationRepository
|
||||
);
|
||||
$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' => 15,
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
|
||||
$verificationRepository = $this->createMock(EmailVerificationRepositoryInterface::class);
|
||||
$verificationRepository->method('findActiveByUserId')->willReturn([
|
||||
'id' => 150,
|
||||
'attempts' => 0,
|
||||
'code_hash' => password_hash('654321', PASSWORD_DEFAULT),
|
||||
]);
|
||||
$verificationRepository->expects($this->once())->method('incrementAttempts')->with(150)->willReturn(true);
|
||||
|
||||
$service = $this->newService(
|
||||
userReadRepository: $userReadRepository,
|
||||
emailVerificationRepository: $verificationRepository
|
||||
);
|
||||
$result = $service->verifyCode('user@example.com', '123456');
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('invalid', $result['error']);
|
||||
}
|
||||
|
||||
public function testVerifyCodeMarksRequestUsedAndUserAsVerifiedOnSuccess(): void
|
||||
{
|
||||
$userReadRepository = $this->createMock(UserReadRepositoryInterface::class);
|
||||
$userReadRepository->method('findByEmail')->willReturn([
|
||||
'id' => 16,
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
|
||||
$verificationRepository = $this->createMock(EmailVerificationRepositoryInterface::class);
|
||||
$verificationRepository->method('findActiveByUserId')->willReturn([
|
||||
'id' => 160,
|
||||
'attempts' => 1,
|
||||
'code_hash' => password_hash('123456', PASSWORD_DEFAULT),
|
||||
]);
|
||||
$verificationRepository->expects($this->once())->method('markUsed')->with(160)->willReturn(true);
|
||||
|
||||
$userWriteRepository = $this->createMock(UserWriteRepositoryInterface::class);
|
||||
$userWriteRepository->expects($this->once())->method('setEmailVerified')->with(16)->willReturn(true);
|
||||
|
||||
$service = $this->newService(
|
||||
userReadRepository: $userReadRepository,
|
||||
userWriteRepository: $userWriteRepository,
|
||||
emailVerificationRepository: $verificationRepository
|
||||
);
|
||||
$result = $service->verifyCode('user@example.com', '123456');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame(16, (int) ($result['user_id'] ?? 0));
|
||||
}
|
||||
|
||||
public function testResendVerificationRejectsEmptyEmail(): void
|
||||
{
|
||||
$service = $this->newService();
|
||||
|
||||
$result = $service->resendVerification(' ');
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('email_required', $result['error']);
|
||||
}
|
||||
|
||||
public function testResendVerificationReturnsOkForUnknownEmailWithoutLeakingIdentity(): void
|
||||
{
|
||||
$userReadRepository = $this->createMock(UserReadRepositoryInterface::class);
|
||||
$userReadRepository->expects($this->once())->method('findByEmail')->with('missing@example.com')->willReturn(null);
|
||||
|
||||
$verificationRepository = $this->createMock(EmailVerificationRepositoryInterface::class);
|
||||
$verificationRepository->expects($this->never())->method('create');
|
||||
|
||||
$service = $this->newService(
|
||||
userReadRepository: $userReadRepository,
|
||||
emailVerificationRepository: $verificationRepository
|
||||
);
|
||||
$result = $service->resendVerification('missing@example.com');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
}
|
||||
|
||||
public function testResendVerificationReturnsAlreadyVerifiedWhenUserIsAlreadyVerified(): void
|
||||
{
|
||||
$userReadRepository = $this->createMock(UserReadRepositoryInterface::class);
|
||||
$userReadRepository->method('findByEmail')->willReturn([
|
||||
'id' => 17,
|
||||
'email_verified_at' => gmdate('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
$service = $this->newService(userReadRepository: $userReadRepository);
|
||||
$result = $service->resendVerification('user@example.com');
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('already_verified', $result['error']);
|
||||
}
|
||||
|
||||
public function testResendVerificationDelegatesToSendVerificationForUnverifiedUsers(): void
|
||||
{
|
||||
$userReadRepository = $this->createMock(UserReadRepositoryInterface::class);
|
||||
$userReadRepository->expects($this->once())->method('findByEmail')->with('user@example.com')->willReturn([
|
||||
'id' => 18,
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
$userReadRepository->expects($this->once())->method('find')->with(18)->willReturn([
|
||||
'id' => 18,
|
||||
'email' => 'user@example.com',
|
||||
'locale' => 'en',
|
||||
]);
|
||||
|
||||
$verificationRepository = $this->createMock(EmailVerificationRepositoryInterface::class);
|
||||
$verificationRepository->expects($this->once())->method('invalidateForUser')->with(18);
|
||||
$verificationRepository->expects($this->once())->method('create')->willReturn(180);
|
||||
|
||||
$mailService = $this->createMock(MailService::class);
|
||||
$mailService->expects($this->once())->method('sendTemplate')->willReturn(['ok' => true]);
|
||||
|
||||
$service = $this->newService(
|
||||
userReadRepository: $userReadRepository,
|
||||
emailVerificationRepository: $verificationRepository,
|
||||
mailService: $mailService
|
||||
);
|
||||
$result = $service->resendVerification('user@example.com', 'en');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
}
|
||||
|
||||
private function newService(
|
||||
?UserReadRepositoryInterface $userReadRepository = null,
|
||||
?UserWriteRepositoryInterface $userWriteRepository = null,
|
||||
?EmailVerificationRepositoryInterface $emailVerificationRepository = null,
|
||||
?MailService $mailService = null
|
||||
): EmailVerificationService {
|
||||
$userReadRepository ??= $this->createMock(UserReadRepositoryInterface::class);
|
||||
$userWriteRepository ??= $this->createMock(UserWriteRepositoryInterface::class);
|
||||
$emailVerificationRepository ??= $this->createMock(EmailVerificationRepositoryInterface::class);
|
||||
$mailService ??= $this->createMock(MailService::class);
|
||||
|
||||
return new EmailVerificationService(
|
||||
$userReadRepository,
|
||||
$userWriteRepository,
|
||||
$emailVerificationRepository,
|
||||
$mailService
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user