feat(auth): harden login flows and finalize quality-check coverage
This commit is contained in:
@@ -59,4 +59,19 @@ class AuthLoginAccessibilityContractTest extends TestCase
|
||||
$this->assertStringContainsString('aria-invalid="true"', $content, $file);
|
||||
}
|
||||
}
|
||||
|
||||
public function testPasswordToggleUsesLocalizedLabelsAndKeyboardFocusableControl(): void
|
||||
{
|
||||
$layoutContent = $this->readProjectFile('templates/login.phtml');
|
||||
$scriptContent = $this->readProjectFile('web/js/components/app-password-toggle.js');
|
||||
|
||||
$this->assertStringContainsString('data-password-toggle-show-label', $layoutContent);
|
||||
$this->assertStringContainsString('data-password-toggle-hide-label', $layoutContent);
|
||||
|
||||
$this->assertStringContainsString('passwordToggleShowLabel', $scriptContent);
|
||||
$this->assertStringContainsString('passwordToggleHideLabel', $scriptContent);
|
||||
$this->assertStringNotContainsString('toggle.tabIndex = -1;', $scriptContent);
|
||||
$this->assertStringNotContainsString("'Show password'", $scriptContent);
|
||||
$this->assertStringNotContainsString("'Hide password'", $scriptContent);
|
||||
}
|
||||
}
|
||||
|
||||
34
tests/Architecture/AuthLoginFrontendCleanupContractTest.php
Normal file
34
tests/Architecture/AuthLoginFrontendCleanupContractTest.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Architecture;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class AuthLoginFrontendCleanupContractTest extends TestCase
|
||||
{
|
||||
use ProjectFileAssertionSupport;
|
||||
|
||||
public function testLoginCssDoesNotContainLegacyBackToLoginSelector(): void
|
||||
{
|
||||
$content = $this->readProjectFile('web/css/pages/app-login.css');
|
||||
|
||||
$this->assertStringNotContainsString('.back_to_login', $content);
|
||||
}
|
||||
|
||||
public function testLoginPasswordToggleUsesDuplicateBindingGuard(): void
|
||||
{
|
||||
$content = $this->readProjectFile('web/js/components/app-password-toggle.js');
|
||||
|
||||
$this->assertStringContainsString("input.dataset.passwordToggleBound === '1'", $content);
|
||||
$this->assertStringContainsString("input.dataset.passwordToggleBound = '1';", $content);
|
||||
$this->assertStringContainsString("toggle.dataset.passwordToggleBound = '1';", $content);
|
||||
}
|
||||
|
||||
public function testLoginPasswordHintsUsesDuplicateBindingGuard(): void
|
||||
{
|
||||
$content = $this->readProjectFile('web/js/components/app-password-hints.js');
|
||||
|
||||
$this->assertStringContainsString("container.dataset.passwordHintsBound === '1'", $content);
|
||||
$this->assertStringContainsString("container.dataset.passwordHintsBound = '1';", $content);
|
||||
}
|
||||
}
|
||||
23
tests/Architecture/AuthRegisterSecurityContractTest.php
Normal file
23
tests/Architecture/AuthRegisterSecurityContractTest.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Architecture;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class AuthRegisterSecurityContractTest extends TestCase
|
||||
{
|
||||
use ProjectFileAssertionSupport;
|
||||
|
||||
public function testRegisterPageVerifiesCsrfBeforeHandlingPostPayload(): void
|
||||
{
|
||||
$content = $this->readProjectFile('pages/auth/register().php');
|
||||
|
||||
$this->assertStringContainsString("if (\$request->isMethod('POST')) {", $content);
|
||||
$this->assertStringContainsString("if (!Session::checkCsrfToken()) {", $content);
|
||||
$this->assertStringContainsString("t('Form expired, please try again')", $content);
|
||||
$this->assertMatchesRegularExpression(
|
||||
'/if \(\$request->isMethod\(\'POST\'\)\) \{\s*if \(!Session::checkCsrfToken\(\)\) \{/s',
|
||||
$content
|
||||
);
|
||||
}
|
||||
}
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -397,6 +397,83 @@ class MicrosoftOidcServiceTest extends TestCase
|
||||
$this->assertSame('tenant-id-123', (string) (($result['claims'] ?? [])['tid'] ?? ''));
|
||||
}
|
||||
|
||||
public function testHandleCallbackReturnsEmailDomainNotAllowedWhenTenantDomainPolicyRejectsUser(): void
|
||||
{
|
||||
[$idToken, $jwk] = $this->buildValidIdToken('client-id', 'tenant-id-123', 'nonce-123');
|
||||
|
||||
$stateStore = $this->createMock(MicrosoftOidcStateStoreService::class);
|
||||
$stateStore->method('consume')->willReturn([
|
||||
'ok' => true,
|
||||
'entry' => [
|
||||
'tenant_id' => 12,
|
||||
'nonce' => 'nonce-123',
|
||||
'code_verifier' => 'verifier-123',
|
||||
'redirect_uri' => 'https://app.example/auth/microsoft/callback',
|
||||
'locale' => 'de',
|
||||
],
|
||||
]);
|
||||
|
||||
$tenantGateway = $this->createMock(AuthTenantGateway::class);
|
||||
$tenantGateway->expects($this->once())
|
||||
->method('findById')
|
||||
->with(12)
|
||||
->willReturn([
|
||||
'id' => 12,
|
||||
'status' => 'active',
|
||||
'uuid' => 'tenant-uuid',
|
||||
]);
|
||||
|
||||
$tenantSsoService = $this->createMock(TenantSsoService::class);
|
||||
$tenantSsoService->method('getEffectiveMicrosoftProviderConfig')->willReturn([
|
||||
'ok' => true,
|
||||
'config' => [
|
||||
'authority' => 'https://login.microsoftonline.com/tenant-id-123/v2.0',
|
||||
'client_id' => 'client-id',
|
||||
'client_secret' => 'client-secret',
|
||||
'tenant_id' => 'tenant-id-123',
|
||||
'allowed_domains' => 'allowed.example.com',
|
||||
'sync_profile_on_login' => 0,
|
||||
'sync_profile_fields' => [],
|
||||
],
|
||||
]);
|
||||
$tenantSsoService->method('normalizeProfileSyncFields')->willReturn([]);
|
||||
$tenantSsoService->expects($this->once())
|
||||
->method('isEmailDomainAllowed')
|
||||
->with('user@example.com', ['allowed.example.com'])
|
||||
->willReturn(false);
|
||||
|
||||
$oidcGateway = $this->createMock(OidcHttpGateway::class);
|
||||
$oidcGateway->expects($this->exactly(3))
|
||||
->method('call')
|
||||
->willReturnOnConsecutiveCalls(
|
||||
[
|
||||
'status' => 200,
|
||||
'data' => json_encode([
|
||||
'token_endpoint' => 'https://login.microsoftonline.com/tenant-id-123/oauth2/v2.0/token',
|
||||
'issuer' => 'https://login.microsoftonline.com/{tenantid}/v2.0',
|
||||
'jwks_uri' => 'https://login.microsoftonline.com/tenant-id-123/discovery/v2.0/keys',
|
||||
]) ?: '{}',
|
||||
],
|
||||
[
|
||||
'status' => 200,
|
||||
'data' => json_encode([
|
||||
'id_token' => $idToken,
|
||||
'access_token' => '',
|
||||
]) ?: '{}',
|
||||
],
|
||||
[
|
||||
'status' => 200,
|
||||
'data' => json_encode(['keys' => [$jwk]]) ?: '{}',
|
||||
],
|
||||
);
|
||||
|
||||
$service = $this->newService($tenantSsoService, $tenantGateway, $oidcGateway, $stateStore);
|
||||
$result = $service->handleCallback('state-ok', 'code-ok');
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('email_domain_not_allowed', $result['error']);
|
||||
}
|
||||
|
||||
private function newServiceForTokenFlow(array $discoveryResponse, ?array $tokenResponse = null): MicrosoftOidcService
|
||||
{
|
||||
$stateStore = $this->createMock(MicrosoftOidcStateStoreService::class);
|
||||
|
||||
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