merge email-confirmation-domain

This commit is contained in:
Yisroel Baum 2026-05-06 22:07:38 +03:00
commit edb0bc0478
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
17 changed files with 690 additions and 0 deletions

View file

@ -0,0 +1,14 @@
<?php
namespace App\Email\EmailConfirmationToken;
use App\User\User;
use DateTimeImmutable;
readonly class CreateEmailConfirmationTokenDto
{
public function __construct(
public User $user,
public DateTimeImmutable $availableTo,
) {}
}

View file

@ -0,0 +1,73 @@
<?php
namespace App\Email\EmailConfirmationToken;
use App\User\User;
use App\User\UserRepository;
use DateTimeImmutable;
use DateTimeZone;
use DomainException;
class EloquentEmailConfirmationTokenRepository implements EmailConfirmationTokenRepository
{
public function __construct(
private UserRepository $userRepo,
) {}
public function create(
CreateEmailConfirmationTokenDto $dto,
): EmailConfirmationToken {
$model = EmailConfirmationTokenModel::create([
'user_id' => $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,
);
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Email\EmailConfirmationToken;
use App\User\User;
use DateTimeImmutable;
class EmailConfirmationToken
{
public function __construct(
private int $id,
private User $user,
private DateTimeImmutable $availableTo,
private string $token,
) {}
public function getId(): int
{
return $this->id;
}
public function getUser(): User
{
return $this->user;
}
public function getAvailableTo(): DateTimeImmutable
{
return $this->availableTo;
}
public function getToken(): string
{
return $this->token;
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace App\Email\EmailConfirmationToken;
use DateTimeImmutable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $id
* @property int $user_id
* @property string $token
* @property DateTimeImmutable $available_to
*
* @method static Builder<static>|EmailConfirmationTokenModel newModelQuery()
* @method static Builder<static>|EmailConfirmationTokenModel newQuery()
* @method static Builder<static>|EmailConfirmationTokenModel query()
* @method static Builder<static>|EmailConfirmationTokenModel whereId($value)
* @method static Builder<static>|EmailConfirmationTokenModel whereUserId($value)
* @method static Builder<static>|EmailConfirmationTokenModel whereToken($value)
* @method static Builder<static>|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',
];
}

View file

@ -0,0 +1,18 @@
<?php
namespace App\Email\EmailConfirmationToken;
use App\User\User;
interface EmailConfirmationTokenRepository
{
public function create(
CreateEmailConfirmationTokenDto $dto,
): EmailConfirmationToken;
public function findByToken(string $token): ?EmailConfirmationToken;
public function findByUser(User $user): ?EmailConfirmationToken;
public function delete(int $id): void;
}

View file

@ -0,0 +1,8 @@
<?php
namespace App\Email;
interface EmailFactory
{
public function makeConfirmationEmail(string $token): string;
}

View file

@ -0,0 +1,8 @@
<?php
namespace App\Email;
interface Emailer
{
public function send(string $from, string $to, string $body): void;
}

View file

@ -0,0 +1,15 @@
<?php
namespace App\Email;
class LaravelEmailFactory implements EmailFactory
{
public function __construct(
private string $confirmationUrlPrefix,
) {}
public function makeConfirmationEmail(string $token): string
{
return "Confirm your email: {$this->confirmationUrlPrefix}{$token}";
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Email;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Mail\Message;
class LaravelMailer implements Emailer
{
public function __construct(
private Mailer $mailer,
) {}
public function send(string $from, string $to, string $body): void
{
$this->mailer->raw(
$body,
function (Message $message) use ($from, $to) {
$message->from($from)
->to($to)
->subject('TIDE');
}
);
}
}

View file

@ -0,0 +1,65 @@
<?php
namespace App\User\UseCases\ConfirmUserEmail;
use App\Auth\Clock;
use App\Auth\PasswordHasher;
use App\Email\EmailConfirmationToken\EmailConfirmationTokenRepository;
use App\Exceptions\BadRequestException;
use App\User\User;
use App\User\UserRepository;
use DomainException;
class ConfirmUserEmail
{
private const MIN_PASSWORD_LENGTH = 8;
public function __construct(
private UserRepository $userRepo,
private EmailConfirmationTokenRepository $tokenRepo,
private PasswordHasher $hasher,
private Clock $clock,
) {}
/**
* @throws BadRequestException
* @throws DomainException
*/
public function execute(ConfirmUserEmailRequest $request): void
{
if ($request->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());
}
}

View file

@ -0,0 +1,11 @@
<?php
namespace App\User\UseCases\ConfirmUserEmail;
class ConfirmUserEmailRequest
{
public function __construct(
public ?string $token,
public ?string $password,
) {}
}

View file

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('email_confirmation_tokens', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,82 @@
<?php
namespace Tests\Fakes;
use App\Email\EmailConfirmationToken\CreateEmailConfirmationTokenDto;
use App\Email\EmailConfirmationToken\EmailConfirmationToken;
use App\Email\EmailConfirmationToken\EmailConfirmationTokenRepository;
use App\User\User;
use App\User\UserRepository;
class FakeEmailConfirmationTokenRepository implements EmailConfirmationTokenRepository
{
/**
* @var EmailConfirmationToken[]
*/
private array $existingTokens = [];
public function __construct(
private UserRepository $userRepo,
) {}
public function create(
CreateEmailConfirmationTokenDto $dto,
): EmailConfirmationToken {
$id = $this->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;
}
}

View file

@ -0,0 +1,13 @@
<?php
namespace Tests\Fakes;
use App\Email\EmailFactory;
class FakeEmailFactory implements EmailFactory
{
public function makeConfirmationEmail(string $token): string
{
return "confirm:{$token}";
}
}

View file

@ -0,0 +1,35 @@
<?php
namespace Tests\Fakes;
use App\Email\Emailer;
class FakeEmailer implements Emailer
{
/**
* @var array<int, array{from: string, to: string, body: string}>
*/
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<int, array{from: string, to: string, body: string}>
*/
public function getSentEmails(): array
{
return $this->sentEmails;
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace Tests\Unit\Email\EmailConfirmationToken;
use App\Email\EmailConfirmationToken\EmailConfirmationToken;
use App\Shared\ValueObject\EmailAddress;
use App\User\User;
use DateTimeImmutable;
use DateTimeZone;
use Tests\TestCase;
class EmailConfirmationTokenTest extends TestCase
{
public function test_token_exposes_all_properties(): void
{
$availableTo = new DateTimeImmutable(
'2026-05-08T12:00:00',
new DateTimeZone('UTC'),
);
$user = new User(
id: 7,
email: new EmailAddress('alice@example.com'),
displayName: 'alice',
passwordHash: '',
isAdmin: false,
emailConfirmedAt: null,
);
$token = new EmailConfirmationToken(
id: 4,
user: $user,
availableTo: $availableTo,
token: 'abc123',
);
$this->assertSame(4, $token->getId());
$this->assertSame($user, $token->getUser());
$this->assertSame($availableTo, $token->getAvailableTo());
$this->assertSame('abc123', $token->getToken());
}
}

View file

@ -0,0 +1,182 @@
<?php
namespace Tests\Unit\User\UseCases;
use App\Email\EmailConfirmationToken\CreateEmailConfirmationTokenDto;
use App\Exceptions\BadRequestException;
use App\Shared\ValueObject\EmailAddress;
use App\User\CreateUserDto;
use App\User\UseCases\ConfirmUserEmail\ConfirmUserEmail;
use App\User\UseCases\ConfirmUserEmail\ConfirmUserEmailRequest;
use App\User\User;
use DateTimeImmutable;
use DateTimeZone;
use DomainException;
use Tests\Fakes\FakeClock;
use Tests\Fakes\FakeEmailConfirmationTokenRepository;
use Tests\Fakes\FakePasswordHasher;
use Tests\Fakes\FakeUserRepository;
use Tests\TestCase;
class ConfirmUserEmailTest extends TestCase
{
private FakeUserRepository $userRepo;
private FakeEmailConfirmationTokenRepository $tokenRepo;
private FakePasswordHasher $hasher;
private FakeClock $clock;
private ConfirmUserEmail $useCase;
private DateTimeImmutable $now;
protected function setUp(): void
{
$this->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));
}
}