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