From a7630f4c8cd467fd546c4206558ded409b30ec7f Mon Sep 17 00:00:00 2001 From: Jesse Rushlow Date: Fri, 17 May 2024 16:07:35 -0400 Subject: [PATCH] refactor helper test w/ new interfaces --- src/Generator/ResetPasswordTokenGenerator.php | 2 +- .../ResetPasswordTokenGeneratorInterface.php | 20 ++ src/ResetPasswordHelper.php | 8 +- src/Util/ResetPasswordCleaner.php | 2 +- src/Util/ResetPasswordCleanerInterface.php | 18 ++ tests/Unit/ResetPasswordHelperTest.php | 277 +++++++++++------- 6 files changed, 210 insertions(+), 117 deletions(-) create mode 100644 src/Generator/ResetPasswordTokenGeneratorInterface.php create mode 100644 src/Util/ResetPasswordCleanerInterface.php diff --git a/src/Generator/ResetPasswordTokenGenerator.php b/src/Generator/ResetPasswordTokenGenerator.php index 85b0a236..67dc6bad 100644 --- a/src/Generator/ResetPasswordTokenGenerator.php +++ b/src/Generator/ResetPasswordTokenGenerator.php @@ -18,7 +18,7 @@ * * @internal */ -final class ResetPasswordTokenGenerator +final class ResetPasswordTokenGenerator implements ResetPasswordTokenGeneratorInterface { /** * @param string $signingKey Unique, random, cryptographically secure string diff --git a/src/Generator/ResetPasswordTokenGeneratorInterface.php b/src/Generator/ResetPasswordTokenGeneratorInterface.php new file mode 100644 index 00000000..9128f15b --- /dev/null +++ b/src/Generator/ResetPasswordTokenGeneratorInterface.php @@ -0,0 +1,20 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SymfonyCasts\Bundle\ResetPassword\Generator; + +use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordTokenComponents; + +/** + * @author Jesse Rushlow + */ +interface ResetPasswordTokenGeneratorInterface +{ + public function createToken(\DateTimeInterface $expiresAt, int|string $userId, ?string $verifier = null): ResetPasswordTokenComponents; +} diff --git a/src/ResetPasswordHelper.php b/src/ResetPasswordHelper.php index b750ea85..82c40b6f 100644 --- a/src/ResetPasswordHelper.php +++ b/src/ResetPasswordHelper.php @@ -12,11 +12,11 @@ use SymfonyCasts\Bundle\ResetPassword\Exception\ExpiredResetPasswordTokenException; use SymfonyCasts\Bundle\ResetPassword\Exception\InvalidResetPasswordTokenException; use SymfonyCasts\Bundle\ResetPassword\Exception\TooManyPasswordRequestsException; -use SymfonyCasts\Bundle\ResetPassword\Generator\ResetPasswordTokenGenerator; +use SymfonyCasts\Bundle\ResetPassword\Generator\ResetPasswordTokenGeneratorInterface; use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestInterface; use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordToken; use SymfonyCasts\Bundle\ResetPassword\Persistence\ResetPasswordRequestRepositoryInterface; -use SymfonyCasts\Bundle\ResetPassword\Util\ResetPasswordCleaner; +use SymfonyCasts\Bundle\ResetPassword\Util\ResetPasswordCleanerInterface; /** * @author Jesse Rushlow @@ -34,8 +34,8 @@ final class ResetPasswordHelper implements ResetPasswordHelperInterface * @param int $requestThrottleTime Another password reset cannot be made faster than this throttle time in seconds */ public function __construct( - private ResetPasswordTokenGenerator $generator, - private ResetPasswordCleaner $cleaner, + private ResetPasswordTokenGeneratorInterface $generator, + private ResetPasswordCleanerInterface $cleaner, private ResetPasswordRequestRepositoryInterface $repository, private int $resetRequestLifetime, private int $requestThrottleTime, diff --git a/src/Util/ResetPasswordCleaner.php b/src/Util/ResetPasswordCleaner.php index f81bb661..077fdd19 100644 --- a/src/Util/ResetPasswordCleaner.php +++ b/src/Util/ResetPasswordCleaner.php @@ -19,7 +19,7 @@ * * @final */ -class ResetPasswordCleaner +class ResetPasswordCleaner implements ResetPasswordCleanerInterface { /** * @param bool $enabled Enable/disable garbage collection diff --git a/src/Util/ResetPasswordCleanerInterface.php b/src/Util/ResetPasswordCleanerInterface.php new file mode 100644 index 00000000..3e481c1d --- /dev/null +++ b/src/Util/ResetPasswordCleanerInterface.php @@ -0,0 +1,18 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SymfonyCasts\Bundle\ResetPassword\Util; + +/** + * @author Jesse Rushlow + */ +interface ResetPasswordCleanerInterface +{ + public function handleGarbageCollection(bool $force = false): int; +} diff --git a/tests/Unit/ResetPasswordHelperTest.php b/tests/Unit/ResetPasswordHelperTest.php index 8670f0ee..d0fe87b4 100644 --- a/tests/Unit/ResetPasswordHelperTest.php +++ b/tests/Unit/ResetPasswordHelperTest.php @@ -14,13 +14,13 @@ use SymfonyCasts\Bundle\ResetPassword\Exception\ExpiredResetPasswordTokenException; use SymfonyCasts\Bundle\ResetPassword\Exception\InvalidResetPasswordTokenException; use SymfonyCasts\Bundle\ResetPassword\Exception\TooManyPasswordRequestsException; -use SymfonyCasts\Bundle\ResetPassword\Generator\ResetPasswordRandomGenerator; -use SymfonyCasts\Bundle\ResetPassword\Generator\ResetPasswordTokenGenerator; +use SymfonyCasts\Bundle\ResetPassword\Generator\ResetPasswordTokenGeneratorInterface; use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestInterface; +use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordTokenComponents; use SymfonyCasts\Bundle\ResetPassword\Persistence\ResetPasswordRequestRepositoryInterface; use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelper; use SymfonyCasts\Bundle\ResetPassword\Tests\Fixtures\Entity\ResetPasswordTestFixtureRequest; -use SymfonyCasts\Bundle\ResetPassword\Util\ResetPasswordCleaner; +use SymfonyCasts\Bundle\ResetPassword\Util\ResetPasswordCleanerInterface; /** * @author Jesse Rushlow @@ -29,62 +29,96 @@ class ResetPasswordHelperTest extends TestCase { private MockObject&ResetPasswordRequestRepositoryInterface $mockRepo; - private ResetPasswordTokenGenerator $tokenGenerator; + private MockObject&ResetPasswordTokenGeneratorInterface $tokenGenerator; private MockObject&ResetPasswordRequestInterface $mockResetRequest; - private MockObject&ResetPasswordCleaner $mockCleaner; + private MockObject&ResetPasswordCleanerInterface $mockCleaner; private string $randomToken; + private int $requestLifetime = 99999999; + private int $requestThrottleTime = 99999999; protected function setUp(): void { $this->mockRepo = $this->createMock(ResetPasswordRequestRepositoryInterface::class); - $this->tokenGenerator = new ResetPasswordTokenGenerator('secret-key', new ResetPasswordRandomGenerator()); - $this->mockCleaner = $this->createMock(ResetPasswordCleaner::class); + $this->tokenGenerator = $this->createMock(ResetPasswordTokenGeneratorInterface::class); + $this->mockCleaner = $this->createMock(ResetPasswordCleanerInterface::class); $this->mockResetRequest = $this->createMock(ResetPasswordRequestInterface::class); $this->randomToken = bin2hex(random_bytes(20)); + $this->requestLifetime = 99999999; + $this->requestThrottleTime = 99999999; } - /** - * @covers \SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelper::hasUserHitThrottling - */ - public function testHasUserThrottlingReturnsFalseWithNoLastRequestDate(): void + public function testGenerateResetTokenCallsGarbageCollector(): void { - $this->mockRepo + $this->mockCleaner ->expects($this->once()) - ->method('getUserIdentifier') - ->willReturn('1234') + ->method('handleGarbageCollection') + ; + + // We don't care about the mock configuration below, we're only testing if garbage collection is called. + $this->tokenGenerator + ->expects(self::once()) + ->method('createToken') + ->willReturn(new ResetPasswordTokenComponents('', '', '')) ; + $this->getPasswordResetHelper()->generateResetToken(new \stdClass()); + } + + public function testHasUserThrottlingReturnsNullWithNoLastRequestDate(): void + { + $user = new \stdClass(); + $this->mockRepo ->expects($this->once()) ->method('getMostRecentNonExpiredRequestDate') + ->with($user) ->willReturn(null) ; + // We don't care about the mock configuration below, we're only testing the helpers hasUserItThrottling method. + $this->mockRepo + ->expects($this->once()) + ->method('getUserIdentifier') + ->willReturn('1234') + ; + + $this->tokenGenerator + ->expects(self::once()) + ->method('createToken') + ->willReturn(new ResetPasswordTokenComponents('', '', '')) + ; + $this->mockRepo ->expects($this->once()) ->method('createResetPasswordRequest') ->willReturn(new ResetPasswordTestFixtureRequest()) ; - $helper = $this->getPasswordResetHelper(); - $helper->generateResetToken(new \stdClass()); + $this->getPasswordResetHelper()->generateResetToken(new \stdClass()); } - /** - * @covers \SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelper::hasUserHitThrottling - */ public function testHasUserThrottlingReturnsNullIfNotBeforeThrottleTime(): void { + $user = new \stdClass(); + $this->mockRepo ->expects($this->once()) - ->method('getUserIdentifier') - ->willReturn('1234') + ->method('getMostRecentNonExpiredRequestDate') + ->with($user) + ->willReturn(new \DateTime('-3 hours')) ; + // We don't care about the mock configuration below, we're only testing the helpers hasUserItThrottling method. $this->mockRepo ->expects($this->once()) - ->method('getMostRecentNonExpiredRequestDate') - ->willReturn(new \DateTime('-3 hours')) + ->method('getUserIdentifier') + ->willReturn('1234') + ; + + $this->tokenGenerator + ->expects(self::once()) + ->method('createToken') + ->willReturn(new ResetPasswordTokenComponents('', '', '')) ; $this->mockRepo @@ -93,35 +127,25 @@ public function testHasUserThrottlingReturnsNullIfNotBeforeThrottleTime(): void ->willReturn(new ResetPasswordTestFixtureRequest()) ; - $helper = new ResetPasswordHelper( - $this->tokenGenerator, - $this->mockCleaner, - $this->mockRepo, - 99999999, - 7200 // 2 hours - ); - - $helper->generateResetToken(new \stdClass()); + $this->requestThrottleTime = 7200; // 2 hours + $this->getPasswordResetHelper()->generateResetToken(new \stdClass()); } public function testExceptionThrownIfRequestBeforeThrottleLimit(): void { + $user = new \stdClass(); + $this->mockRepo ->expects($this->once()) ->method('getMostRecentNonExpiredRequestDate') + ->with($user) ->willReturn(new \DateTime('-1 hour')) ; - $helper = new ResetPasswordHelper( - $this->tokenGenerator, - $this->mockCleaner, - $this->mockRepo, - 99999999, - 7200 // 2 hours - ); + $this->requestThrottleTime = 7200; // 2 hours try { - $helper->generateResetToken(new \stdClass()); + $this->getPasswordResetHelper()->generateResetToken($user); } catch (TooManyPasswordRequestsException $exception) { // account for time changes during test self::assertGreaterThanOrEqual(3599, $exception->getRetryAfter()); @@ -133,43 +157,55 @@ public function testExceptionThrownIfRequestBeforeThrottleLimit(): void $this->fail('Exception was not thrown.'); } - public function testRemoveResetRequestThrowsExceptionWithEmptyToken(): void + public function testExpiresAtUsesCurrentTimeZone(): void { - $this->expectException(InvalidResetPasswordTokenException::class); + // We don't care about the mock configuration below, we're only testing if the correct timezone is used. + $this->tokenGenerator + ->expects(self::once()) + ->method('createToken') + ->willReturn(new ResetPasswordTokenComponents('', '', '')) + ; - $helper = $this->getPasswordResetHelper(); - $helper->removeResetRequest(''); + $token = $this->getPasswordResetHelper()->generateResetToken(new \stdClass()); + + $expiresAt = $token->getExpiresAt(); + self::assertSame(date_default_timezone_get(), $expiresAt->getTimezone()->getName()); } - public function testRemoveResetRequestRetrievesTokenFromRepository(): void + public function testExpiresAtUsingDefaultLifetime(): void { - $this->mockRepo - ->expects($this->once()) - ->method('findResetPasswordRequest') - ->with(substr($this->randomToken, 0, 20)) - ->willReturn($this->mockResetRequest) + // We don't care about the mock configuration below, we're only testing . + $this->tokenGenerator + ->expects(self::once()) + ->method('createToken') + ->willReturn(new ResetPasswordTokenComponents('', '', '')) ; - $helper = $this->getPasswordResetHelper(); - $helper->removeResetRequest($this->randomToken); + $this->requestLifetime = 60; + + $token = $this->getPasswordResetHelper()->generateResetToken(new \stdClass()); + $expiresAt = $token->getExpiresAt(); + + self::assertGreaterThan(new \DateTimeImmutable('+55 seconds'), $expiresAt); + self::assertLessThan(new \DateTimeImmutable('+65 seconds'), $expiresAt); } - public function testRemoveResetRequestCallsRepositoryToRemoveResetRequestObject(): void + public function testExpiresAtUsingOverrideLifetime(): void { - $this->mockRepo - ->expects($this->once()) - ->method('findResetPasswordRequest') - ->willReturn($this->mockResetRequest) + // We don't care about the mock configuration below, we're only testing . + $this->tokenGenerator + ->expects(self::once()) + ->method('createToken') + ->willReturn(new ResetPasswordTokenComponents('', '', '')) ; - $this->mockRepo - ->expects($this->once()) - ->method('removeResetPasswordRequest') - ->with($this->mockResetRequest) - ; + $this->requestLifetime = 60; - $helper = $this->getPasswordResetHelper(); - $helper->removeResetRequest('1234'); + $token = $this->getPasswordResetHelper()->generateResetToken(new \stdClass(), 30); + $expiresAt = $token->getExpiresAt(); + + self::assertGreaterThan(new \DateTimeImmutable('+25 seconds'), $expiresAt); + self::assertLessThan(new \DateTimeImmutable('+35 seconds'), $expiresAt); } public function testExceptionThrownIfTokenLengthIsNotOfCorrectSize(): void @@ -217,6 +253,9 @@ public function testValidateTokenThrowsExceptionOnExpiredResetRequest(): void public function testValidateTokenFetchesUserIfTokenNotExpired(): void { + $user = new \stdClass(); + $expiresAt = new \DateTimeImmutable(); + $this->mockResetRequest ->expects($this->once()) ->method('isExpired') @@ -226,13 +265,13 @@ public function testValidateTokenFetchesUserIfTokenNotExpired(): void $this->mockResetRequest ->expects($this->once()) ->method('getUser') - ->willReturn(new \stdClass()) + ->willReturn($user) ; $this->mockResetRequest ->expects($this->once()) ->method('getExpiresAt') - ->willReturn(new \DateTimeImmutable()) + ->willReturn($expiresAt) ; $this->mockRepo @@ -242,22 +281,39 @@ public function testValidateTokenFetchesUserIfTokenNotExpired(): void ->willReturn($this->mockResetRequest) ; + $this->mockRepo + ->expects(self::once()) + ->method('getUserIdentifier') + ->with($user) + ->willReturn('1234') + ; + + $this->tokenGenerator + ->expects(self::once()) + ->method('createToken') + ->with($expiresAt, '1234', substr($this->randomToken, 20, 20)) + ->willReturn(new ResetPasswordTokenComponents('', '', '')) + ; + $helper = $this->getPasswordResetHelper(); $helper->validateTokenAndFetchUser($this->randomToken); } public function testValidateTokenThrowsExceptionIfTokenAndVerifierDoNotMatch(): void { + $user = new \stdClass(); + $expiresAt = new \DateTimeImmutable(); + $this->mockResetRequest ->expects($this->once()) ->method('getExpiresAt') - ->willReturn(new \DateTimeImmutable()) + ->willReturn($expiresAt) ; $this->mockResetRequest ->expects($this->once()) ->method('getUser') - ->willReturn(new \stdClass()) + ->willReturn($user) ; $this->mockResetRequest @@ -272,21 +328,24 @@ public function testValidateTokenThrowsExceptionIfTokenAndVerifierDoNotMatch(): ->willReturn($this->mockResetRequest) ; - $this->expectException(InvalidResetPasswordTokenException::class); - - $helper = $this->getPasswordResetHelper(); - $helper->validateTokenAndFetchUser($this->randomToken); - } + $this->mockRepo + ->expects(self::once()) + ->method('getUserIdentifier') + ->with($user) + ->willReturn('1234') + ; - public function testGenerateResetTokenCallsGarbageCollector(): void - { - $this->mockCleaner - ->expects($this->once()) - ->method('handleGarbageCollection') + $this->tokenGenerator + ->expects(self::once()) + ->method('createToken') + ->with($expiresAt, '1234', substr($this->randomToken, 20, 20)) + ->willReturn(new ResetPasswordTokenComponents('', '', '')) ; + $this->expectException(InvalidResetPasswordTokenException::class); + $helper = $this->getPasswordResetHelper(); - $helper->generateResetToken(new \stdClass()); + $helper->validateTokenAndFetchUser($this->randomToken); } public function testGarbageCollectorCalledDuringValidation(): void @@ -302,47 +361,43 @@ public function testGarbageCollectorCalledDuringValidation(): void $helper->validateTokenAndFetchUser($this->randomToken); } - public function testExpiresAtUsesCurrentTimeZone(): void + public function testRemoveResetRequestThrowsExceptionWithEmptyToken(): void { - $helper = $this->getPasswordResetHelper(); - $token = $helper->generateResetToken(new \stdClass()); + $this->expectException(InvalidResetPasswordTokenException::class); - $expiresAt = $token->getExpiresAt(); - self::assertSame(date_default_timezone_get(), $expiresAt->getTimezone()->getName()); + $helper = $this->getPasswordResetHelper(); + $helper->removeResetRequest(''); } - public function testExpiresAtUsingDefault(): void + public function testRemoveResetRequestRetrievesTokenFromRepository(): void { - $helper = new ResetPasswordHelper( - $this->tokenGenerator, - $this->mockCleaner, - $this->mockRepo, - 60, - 99999999 - ); - - $token = $helper->generateResetToken(new \stdClass()); - $expiresAt = $token->getExpiresAt(); + $this->mockRepo + ->expects($this->once()) + ->method('findResetPasswordRequest') + ->with(substr($this->randomToken, 0, 20)) + ->willReturn($this->mockResetRequest) + ; - self::assertGreaterThan(new \DateTimeImmutable('+55 seconds'), $expiresAt); - self::assertLessThan(new \DateTimeImmutable('+65 seconds'), $expiresAt); + $helper = $this->getPasswordResetHelper(); + $helper->removeResetRequest($this->randomToken); } - public function testExpiresAtUsingOverride(): void + public function testRemoveResetRequestCallsRepositoryToRemoveResetRequestObject(): void { - $helper = new ResetPasswordHelper( - $this->tokenGenerator, - $this->mockCleaner, - $this->mockRepo, - 60, - 99999999 - ); + $this->mockRepo + ->expects($this->once()) + ->method('findResetPasswordRequest') + ->willReturn($this->mockResetRequest) + ; - $token = $helper->generateResetToken(new \stdClass(), 30); - $expiresAt = $token->getExpiresAt(); + $this->mockRepo + ->expects($this->once()) + ->method('removeResetPasswordRequest') + ->with($this->mockResetRequest) + ; - self::assertGreaterThan(new \DateTimeImmutable('+25 seconds'), $expiresAt); - self::assertLessThan(new \DateTimeImmutable('+35 seconds'), $expiresAt); + $helper = $this->getPasswordResetHelper(); + $helper->removeResetRequest('1234'); } public function testFakeTokenExpiresAtUsingDefault(): void @@ -385,8 +440,8 @@ private function getPasswordResetHelper(): ResetPasswordHelper $this->tokenGenerator, $this->mockCleaner, $this->mockRepo, - 99999999, - 99999999 + $this->requestLifetime, + $this->requestThrottleTime ); } }