feat(auth): harden login flows and finalize quality-check coverage

This commit is contained in:
2026-03-07 22:54:33 +01:00
parent aefe80457c
commit 7153fec05c
20 changed files with 1769 additions and 28 deletions

View File

@@ -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);
}
}

View 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);
}
}

View 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
);
}
}

View 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
);
}
}

View File

@@ -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);

View 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
);
}
}