From 11f2823a308da5a207878c0b2faf9ef0afec65d5 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 6 May 2026 22:08:25 +0300 Subject: [PATCH 1/2] test SignupUser two-step confirm flow --- .../Unit/User/UseCases/SignupUserTest.php | 107 ++++++++---------- 1 file changed, 49 insertions(+), 58 deletions(-) diff --git a/backend/tests/Unit/User/UseCases/SignupUserTest.php b/backend/tests/Unit/User/UseCases/SignupUserTest.php index 9babb7e..501eaf2 100644 --- a/backend/tests/Unit/User/UseCases/SignupUserTest.php +++ b/backend/tests/Unit/User/UseCases/SignupUserTest.php @@ -8,8 +8,13 @@ use App\User\CreateUserDto; use App\User\UseCases\SignupUser\SignupUser; use App\User\UseCases\SignupUser\SignupUserRequest; use App\User\User; +use DateTimeImmutable; +use DateTimeZone; use DomainException; -use Tests\Fakes\FakePasswordHasher; +use Tests\Fakes\FakeClock; +use Tests\Fakes\FakeEmailConfirmationTokenRepository; +use Tests\Fakes\FakeEmailer; +use Tests\Fakes\FakeEmailFactory; use Tests\Fakes\FakeUserRepository; use Tests\TestCase; @@ -17,17 +22,38 @@ class SignupUserTest extends TestCase { private FakeUserRepository $userRepo; - private FakePasswordHasher $hasher; + private FakeEmailConfirmationTokenRepository $tokenRepo; + + private FakeEmailer $emailer; + + private FakeEmailFactory $emailFactory; + + private FakeClock $clock; + + private DateTimeImmutable $now; private SignupUser $useCase; protected function setUp(): void { + $this->now = new DateTimeImmutable( + '2026-05-06T12:00:00', + new DateTimeZone('UTC'), + ); $this->userRepo = new FakeUserRepository; - $this->hasher = new FakePasswordHasher; + $this->tokenRepo = new FakeEmailConfirmationTokenRepository( + $this->userRepo, + ); + $this->emailer = new FakeEmailer; + $this->emailFactory = new FakeEmailFactory; + $this->clock = new FakeClock($this->now); $this->useCase = new SignupUser( $this->userRepo, - $this->hasher, + $this->tokenRepo, + $this->emailer, + $this->emailFactory, + $this->clock, + 'noreply@tide.test', ); } @@ -37,7 +63,6 @@ class SignupUserTest extends TestCase $this->useCase->execute(new SignupUserRequest( email: null, displayName: 'alice', - password: 'longenoughpassword', )); } @@ -47,7 +72,6 @@ class SignupUserTest extends TestCase $this->useCase->execute(new SignupUserRequest( email: '', displayName: 'alice', - password: 'longenoughpassword', )); } @@ -57,7 +81,6 @@ class SignupUserTest extends TestCase $this->useCase->execute(new SignupUserRequest( email: 'not-an-email', displayName: 'alice', - password: 'longenoughpassword', )); } @@ -67,7 +90,6 @@ class SignupUserTest extends TestCase $this->useCase->execute(new SignupUserRequest( email: 'user@example.com', displayName: null, - password: 'longenoughpassword', )); } @@ -77,7 +99,6 @@ class SignupUserTest extends TestCase $this->useCase->execute(new SignupUserRequest( email: 'user@example.com', displayName: 'ab', - password: 'longenoughpassword', )); } @@ -87,27 +108,6 @@ class SignupUserTest extends TestCase $this->useCase->execute(new SignupUserRequest( email: 'user@example.com', displayName: 'Has Spaces', - password: 'longenoughpassword', - )); - } - - public function test_null_password_throws_bad_request(): void - { - $this->expectException(BadRequestException::class); - $this->useCase->execute(new SignupUserRequest( - email: 'user@example.com', - displayName: 'alice', - password: null, - )); - } - - public function test_short_password_throws_bad_request(): void - { - $this->expectException(BadRequestException::class); - $this->useCase->execute(new SignupUserRequest( - email: 'user@example.com', - displayName: 'alice', - password: 'short', )); } @@ -116,7 +116,7 @@ class SignupUserTest extends TestCase $this->userRepo->create(new CreateUserDto( email: new EmailAddress('user@example.com'), displayName: 'first', - passwordHash: $this->hasher->hash('original-password'), + passwordHash: '', isAdmin: false, emailConfirmedAt: null, )); @@ -125,7 +125,6 @@ class SignupUserTest extends TestCase $this->useCase->execute(new SignupUserRequest( email: 'user@example.com', displayName: 'second', - password: 'second-attempt-password', )); } @@ -134,7 +133,7 @@ class SignupUserTest extends TestCase $this->userRepo->create(new CreateUserDto( email: new EmailAddress('first@example.com'), displayName: 'taken', - passwordHash: $this->hasher->hash('original-password'), + passwordHash: '', isAdmin: false, emailConfirmedAt: null, )); @@ -143,55 +142,48 @@ class SignupUserTest extends TestCase $this->useCase->execute(new SignupUserRequest( email: 'second@example.com', displayName: 'taken', - password: 'second-attempt-password', )); } - public function test_valid_signup_returns_user_with_hashed_password(): void + public function test_valid_signup_creates_unconfirmed_user(): void { $created = $this->useCase->execute(new SignupUserRequest( email: 'new@example.com', displayName: 'newuser', - password: 'longenoughpassword', )); $this->assertInstanceOf(User::class, $created); $this->assertSame('new@example.com', $created->getEmail()->value()); $this->assertSame('newuser', $created->getDisplayName()); - $this->assertSame( - $this->hasher->hash('longenoughpassword'), - $created->getPasswordHash(), - ); + $this->assertSame('', $created->getPasswordHash()); $this->assertFalse($created->isAdmin()); $this->assertFalse($created->isEmailConfirmed()); } - public function test_created_user_is_findable_by_email(): void + public function test_valid_signup_creates_confirmation_token(): void { $created = $this->useCase->execute(new SignupUserRequest( - email: 'lookup@example.com', - displayName: 'lookup', - password: 'longenoughpassword', + email: 'new@example.com', + displayName: 'newuser', )); - $found = $this->userRepo->findByEmail( - new EmailAddress('lookup@example.com') - ); - $this->assertNotNull($found); - $this->assertSame($created->getId(), $found->getId()); + $token = $this->tokenRepo->findByUser($created); + $this->assertNotNull($token); + $this->assertGreaterThan($this->now, $token->getAvailableTo()); } - public function test_created_user_is_findable_by_display_name(): void + public function test_valid_signup_sends_confirmation_email(): void { - $created = $this->useCase->execute(new SignupUserRequest( - email: 'lookup@example.com', - displayName: 'lookupbyname', - password: 'longenoughpassword', + $this->useCase->execute(new SignupUserRequest( + email: 'new@example.com', + displayName: 'newuser', )); - $found = $this->userRepo->findByDisplayName('lookupbyname'); - $this->assertNotNull($found); - $this->assertSame($created->getId(), $found->getId()); + $this->assertSame(1, $this->emailer->getNumberOfEmailsSent()); + $sent = $this->emailer->getSentEmails()[0]; + $this->assertSame('noreply@tide.test', $sent['from']); + $this->assertSame('new@example.com', $sent['to']); + $this->assertStringContainsString('confirm:', $sent['body']); } public function test_signup_normalizes_email_domain(): void @@ -199,7 +191,6 @@ class SignupUserTest extends TestCase $created = $this->useCase->execute(new SignupUserRequest( email: 'Mixed@CASE.com', displayName: 'mixed', - password: 'longenoughpassword', )); $this->assertSame('Mixed@case.com', $created->getEmail()->value()); From f3c6e2e000536caf00c2a1f34c1d0f5dfca77f52 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 6 May 2026 22:08:54 +0300 Subject: [PATCH 2/2] implement SignupUser two-step confirm flow Signup now collects only email + displayName, creates an unconfirmed user with empty password hash, mints an EmailConfirmationToken, and dispatches a confirmation email. Password is set during ConfirmUserEmail. --- .../User/UseCases/SignupUser/SignupUser.php | 41 ++++++++++++------- .../UseCases/SignupUser/SignupUserRequest.php | 1 - 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/backend/app/User/UseCases/SignupUser/SignupUser.php b/backend/app/User/UseCases/SignupUser/SignupUser.php index 7228e87..007bab0 100644 --- a/backend/app/User/UseCases/SignupUser/SignupUser.php +++ b/backend/app/User/UseCases/SignupUser/SignupUser.php @@ -2,7 +2,11 @@ namespace App\User\UseCases\SignupUser; -use App\Auth\PasswordHasher; +use App\Auth\Clock; +use App\Email\EmailConfirmationToken\CreateEmailConfirmationTokenDto; +use App\Email\EmailConfirmationToken\EmailConfirmationTokenRepository; +use App\Email\Emailer; +use App\Email\EmailFactory; use App\Exceptions\BadRequestException; use App\Shared\ValueObject\EmailAddress; use App\User\CreateUserDto; @@ -13,13 +17,17 @@ use InvalidArgumentException; class SignupUser { - private const MIN_PASSWORD_LENGTH = 8; - private const DISPLAY_NAME_PATTERN = '/^[a-z0-9_-]{3,30}$/'; + private const TOKEN_LIFETIME = '+1 day'; + public function __construct( private UserRepository $userRepo, - private PasswordHasher $hasher, + private EmailConfirmationTokenRepository $tokenRepo, + private Emailer $emailer, + private EmailFactory $emailFactory, + private Clock $clock, + private string $fromAddress, ) {} /** @@ -44,14 +52,6 @@ class SignupUser 'displayName must be 3-30 chars of [a-z0-9_-]' ); } - if ($request->password === null || $request->password === '') { - throw new BadRequestException('password is required'); - } - if (strlen($request->password) < self::MIN_PASSWORD_LENGTH) { - throw new BadRequestException( - 'password must be at least '.self::MIN_PASSWORD_LENGTH.' characters' - ); - } try { $email = new EmailAddress($request->email); @@ -66,12 +66,25 @@ class SignupUser throw new DomainException('displayName already taken'); } - return $this->userRepo->create(new CreateUserDto( + $user = $this->userRepo->create(new CreateUserDto( email: $email, displayName: $request->displayName, - passwordHash: $this->hasher->hash($request->password), + passwordHash: '', isAdmin: false, emailConfirmedAt: null, )); + + $token = $this->tokenRepo->create(new CreateEmailConfirmationTokenDto( + user: $user, + availableTo: $this->clock->now()->modify(self::TOKEN_LIFETIME), + )); + + $this->emailer->send( + $this->fromAddress, + $user->getEmail()->value(), + $this->emailFactory->makeConfirmationEmail($token->getToken()), + ); + + return $user; } } diff --git a/backend/app/User/UseCases/SignupUser/SignupUserRequest.php b/backend/app/User/UseCases/SignupUser/SignupUserRequest.php index e202131..f39d861 100644 --- a/backend/app/User/UseCases/SignupUser/SignupUserRequest.php +++ b/backend/app/User/UseCases/SignupUser/SignupUserRequest.php @@ -7,6 +7,5 @@ class SignupUserRequest public function __construct( public ?string $email, public ?string $displayName, - public ?string $password, ) {} }