diff --git a/backend/app/Email/EmailConfirmationToken/CreateEmailConfirmationTokenDto.php b/backend/app/Email/EmailConfirmationToken/CreateEmailConfirmationTokenDto.php new file mode 100644 index 0000000..58a17ed --- /dev/null +++ b/backend/app/Email/EmailConfirmationToken/CreateEmailConfirmationTokenDto.php @@ -0,0 +1,14 @@ + $dto->user->getId(), + 'token' => bin2hex(random_bytes(32)), + 'available_to' => $dto->availableTo, + ]); + + return $this->toDomain($model); + } + + public function findByToken(string $token): ?EmailConfirmationToken + { + $model = EmailConfirmationTokenModel::where( + 'token', $token, + )->first(); + + return $model === null ? null : $this->toDomain($model); + } + + public function findByUser(User $user): ?EmailConfirmationToken + { + $model = EmailConfirmationTokenModel::where( + 'user_id', $user->getId(), + )->first(); + + return $model === null ? null : $this->toDomain($model); + } + + public function delete(int $id): void + { + EmailConfirmationTokenModel::query()->where('id', $id)->delete(); + } + + private function toDomain( + EmailConfirmationTokenModel $model, + ): EmailConfirmationToken { + $user = $this->userRepo->find($model->user_id); + if ($user === null) { + throw new DomainException( + "User with id {$model->user_id} not found" + ); + } + $availableTo = new DateTimeImmutable( + $model->available_to->toDateTimeString(), + new DateTimeZone('UTC'), + ); + + return new EmailConfirmationToken( + id: $model->id, + user: $user, + availableTo: $availableTo, + token: $model->token, + ); + } +} diff --git a/backend/app/Email/EmailConfirmationToken/EmailConfirmationToken.php b/backend/app/Email/EmailConfirmationToken/EmailConfirmationToken.php new file mode 100644 index 0000000..d2fb638 --- /dev/null +++ b/backend/app/Email/EmailConfirmationToken/EmailConfirmationToken.php @@ -0,0 +1,36 @@ +id; + } + + public function getUser(): User + { + return $this->user; + } + + public function getAvailableTo(): DateTimeImmutable + { + return $this->availableTo; + } + + public function getToken(): string + { + return $this->token; + } +} diff --git a/backend/app/Email/EmailConfirmationToken/EmailConfirmationTokenModel.php b/backend/app/Email/EmailConfirmationToken/EmailConfirmationTokenModel.php new file mode 100644 index 0000000..b8214cd --- /dev/null +++ b/backend/app/Email/EmailConfirmationToken/EmailConfirmationTokenModel.php @@ -0,0 +1,40 @@ +|EmailConfirmationTokenModel newModelQuery() + * @method static Builder|EmailConfirmationTokenModel newQuery() + * @method static Builder|EmailConfirmationTokenModel query() + * @method static Builder|EmailConfirmationTokenModel whereId($value) + * @method static Builder|EmailConfirmationTokenModel whereUserId($value) + * @method static Builder|EmailConfirmationTokenModel whereToken($value) + * @method static Builder|EmailConfirmationTokenModel whereAvailableTo($value) + * + * @mixin \Eloquent + */ +class EmailConfirmationTokenModel extends Model +{ + protected $table = 'email_confirmation_tokens'; + + public $timestamps = false; + + protected $fillable = [ + 'user_id', + 'token', + 'available_to', + ]; + + protected $casts = [ + 'available_to' => 'immutable_datetime', + ]; +} diff --git a/backend/app/Email/EmailConfirmationToken/EmailConfirmationTokenRepository.php b/backend/app/Email/EmailConfirmationToken/EmailConfirmationTokenRepository.php new file mode 100644 index 0000000..ed71f62 --- /dev/null +++ b/backend/app/Email/EmailConfirmationToken/EmailConfirmationTokenRepository.php @@ -0,0 +1,18 @@ +confirmationUrlPrefix}{$token}"; + } +} diff --git a/backend/app/Email/LaravelMailer.php b/backend/app/Email/LaravelMailer.php new file mode 100644 index 0000000..cf41752 --- /dev/null +++ b/backend/app/Email/LaravelMailer.php @@ -0,0 +1,25 @@ +mailer->raw( + $body, + function (Message $message) use ($from, $to) { + $message->from($from) + ->to($to) + ->subject('TIDE'); + } + ); + } +} diff --git a/backend/app/User/UseCases/ConfirmUserEmail/ConfirmUserEmail.php b/backend/app/User/UseCases/ConfirmUserEmail/ConfirmUserEmail.php new file mode 100644 index 0000000..1fdb563 --- /dev/null +++ b/backend/app/User/UseCases/ConfirmUserEmail/ConfirmUserEmail.php @@ -0,0 +1,65 @@ +token === null || $request->token === '') { + throw new BadRequestException('token is required'); + } + 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' + ); + } + + $token = $this->tokenRepo->findByToken($request->token); + if ($token === null) { + throw new DomainException('token not found'); + } + + $now = $this->clock->now(); + if ($token->getAvailableTo() < $now) { + throw new DomainException('token expired'); + } + + $user = $token->getUser(); + $confirmedUser = new User( + id: $user->getId(), + email: $user->getEmail(), + displayName: $user->getDisplayName(), + passwordHash: $this->hasher->hash($request->password), + isAdmin: $user->isAdmin(), + emailConfirmedAt: $now, + ); + $this->userRepo->update($confirmedUser); + $this->tokenRepo->delete($token->getId()); + } +} diff --git a/backend/app/User/UseCases/ConfirmUserEmail/ConfirmUserEmailRequest.php b/backend/app/User/UseCases/ConfirmUserEmail/ConfirmUserEmailRequest.php new file mode 100644 index 0000000..6945ebe --- /dev/null +++ b/backend/app/User/UseCases/ConfirmUserEmail/ConfirmUserEmailRequest.php @@ -0,0 +1,11 @@ +id(); + $table->foreignId('user_id') + ->constrained('users') + ->cascadeOnDelete(); + $table->string('token')->unique(); + $table->dateTime('available_to'); + }); + } + + public function down(): void + { + Schema::dropIfExists('email_confirmation_tokens'); + } +}; diff --git a/backend/tests/Fakes/FakeEmailConfirmationTokenRepository.php b/backend/tests/Fakes/FakeEmailConfirmationTokenRepository.php new file mode 100644 index 0000000..8ef77ad --- /dev/null +++ b/backend/tests/Fakes/FakeEmailConfirmationTokenRepository.php @@ -0,0 +1,82 @@ +nextId(); + $token = new EmailConfirmationToken( + id: $id, + user: $dto->user, + availableTo: $dto->availableTo, + token: bin2hex(random_bytes(32)), + ); + $this->existingTokens[$id] = $token; + + return $this->copy($token); + } + + public function findByToken(string $token): ?EmailConfirmationToken + { + foreach ($this->existingTokens as $existing) { + if ($existing->getToken() === $token) { + return $this->copy($existing); + } + } + + return null; + } + + public function findByUser(User $user): ?EmailConfirmationToken + { + foreach ($this->existingTokens as $existing) { + if ($existing->getUser()->getId() === $user->getId()) { + return $this->copy($existing); + } + } + + return null; + } + + public function delete(int $id): void + { + unset($this->existingTokens[$id]); + } + + private function copy( + EmailConfirmationToken $token, + ): EmailConfirmationToken { + $user = $this->userRepo->find($token->getUser()->getId()) + ?? $token->getUser(); + + return new EmailConfirmationToken( + id: $token->getId(), + user: $user, + availableTo: $token->getAvailableTo(), + token: $token->getToken(), + ); + } + + private function nextId(): int + { + return count($this->existingTokens) + 1; + } +} diff --git a/backend/tests/Fakes/FakeEmailFactory.php b/backend/tests/Fakes/FakeEmailFactory.php new file mode 100644 index 0000000..b6fb069 --- /dev/null +++ b/backend/tests/Fakes/FakeEmailFactory.php @@ -0,0 +1,13 @@ + + */ + private array $sentEmails = []; + + public function send(string $from, string $to, string $body): void + { + $this->sentEmails[] = [ + 'from' => $from, + 'to' => $to, + 'body' => $body, + ]; + } + + public function getNumberOfEmailsSent(): int + { + return count($this->sentEmails); + } + + /** + * @return array + */ + public function getSentEmails(): array + { + return $this->sentEmails; + } +} diff --git a/backend/tests/Unit/Email/EmailConfirmationToken/EmailConfirmationTokenTest.php b/backend/tests/Unit/Email/EmailConfirmationToken/EmailConfirmationTokenTest.php new file mode 100644 index 0000000..01aa9d0 --- /dev/null +++ b/backend/tests/Unit/Email/EmailConfirmationToken/EmailConfirmationTokenTest.php @@ -0,0 +1,40 @@ +assertSame(4, $token->getId()); + $this->assertSame($user, $token->getUser()); + $this->assertSame($availableTo, $token->getAvailableTo()); + $this->assertSame('abc123', $token->getToken()); + } +} diff --git a/backend/tests/Unit/User/UseCases/ConfirmUserEmailTest.php b/backend/tests/Unit/User/UseCases/ConfirmUserEmailTest.php new file mode 100644 index 0000000..2263902 --- /dev/null +++ b/backend/tests/Unit/User/UseCases/ConfirmUserEmailTest.php @@ -0,0 +1,182 @@ +now = new DateTimeImmutable( + '2026-05-06T12:00:00', + new DateTimeZone('UTC'), + ); + $this->userRepo = new FakeUserRepository; + $this->tokenRepo = new FakeEmailConfirmationTokenRepository( + $this->userRepo, + ); + $this->hasher = new FakePasswordHasher; + $this->clock = new FakeClock($this->now); + $this->useCase = new ConfirmUserEmail( + $this->userRepo, + $this->tokenRepo, + $this->hasher, + $this->clock, + ); + } + + private function seedUserAndToken(DateTimeImmutable $availableTo): User + { + $user = $this->userRepo->create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + displayName: 'user', + passwordHash: '', + isAdmin: false, + emailConfirmedAt: null, + )); + $this->tokenRepo->create(new CreateEmailConfirmationTokenDto( + user: $user, + availableTo: $availableTo, + )); + + return $user; + } + + private function tokenStringForUser(User $user): string + { + $token = $this->tokenRepo->findByUser($user); + $this->assertNotNull($token); + + return $token->getToken(); + } + + public function test_null_token_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new ConfirmUserEmailRequest( + token: null, + password: 'longenoughpassword', + )); + } + + public function test_null_password_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new ConfirmUserEmailRequest( + token: 'sometoken', + password: null, + )); + } + + public function test_short_password_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new ConfirmUserEmailRequest( + token: 'sometoken', + password: 'short', + )); + } + + public function test_unknown_token_throws_domain_exception(): void + { + $this->expectException(DomainException::class); + $this->useCase->execute(new ConfirmUserEmailRequest( + token: 'no-such-token', + password: 'longenoughpassword', + )); + } + + public function test_expired_token_throws_domain_exception(): void + { + $user = $this->seedUserAndToken( + $this->now->modify('-1 minute'), + ); + $tokenStr = $this->tokenStringForUser($user); + + $this->expectException(DomainException::class); + $this->useCase->execute(new ConfirmUserEmailRequest( + token: $tokenStr, + password: 'longenoughpassword', + )); + } + + public function test_valid_confirmation_sets_password_hash(): void + { + $user = $this->seedUserAndToken( + $this->now->modify('+1 day'), + ); + $tokenStr = $this->tokenStringForUser($user); + + $this->useCase->execute(new ConfirmUserEmailRequest( + token: $tokenStr, + password: 'longenoughpassword', + )); + + $reloaded = $this->userRepo->find($user->getId()); + $this->assertNotNull($reloaded); + $this->assertSame( + $this->hasher->hash('longenoughpassword'), + $reloaded->getPasswordHash(), + ); + } + + public function test_valid_confirmation_marks_email_confirmed(): void + { + $user = $this->seedUserAndToken( + $this->now->modify('+1 day'), + ); + $tokenStr = $this->tokenStringForUser($user); + + $this->useCase->execute(new ConfirmUserEmailRequest( + token: $tokenStr, + password: 'longenoughpassword', + )); + + $reloaded = $this->userRepo->find($user->getId()); + $this->assertNotNull($reloaded); + $this->assertTrue($reloaded->isEmailConfirmed()); + $this->assertEquals($this->now, $reloaded->getEmailConfirmedAt()); + } + + public function test_valid_confirmation_consumes_token(): void + { + $user = $this->seedUserAndToken( + $this->now->modify('+1 day'), + ); + $tokenStr = $this->tokenStringForUser($user); + + $this->useCase->execute(new ConfirmUserEmailRequest( + token: $tokenStr, + password: 'longenoughpassword', + )); + + $this->assertNull($this->tokenRepo->findByToken($tokenStr)); + } +}