merge email-confirmation-domain
This commit is contained in:
commit
edb0bc0478
17 changed files with 690 additions and 0 deletions
|
|
@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
8
backend/app/Email/EmailFactory.php
Normal file
8
backend/app/Email/EmailFactory.php
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Email;
|
||||||
|
|
||||||
|
interface EmailFactory
|
||||||
|
{
|
||||||
|
public function makeConfirmationEmail(string $token): string;
|
||||||
|
}
|
||||||
8
backend/app/Email/Emailer.php
Normal file
8
backend/app/Email/Emailer.php
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Email;
|
||||||
|
|
||||||
|
interface Emailer
|
||||||
|
{
|
||||||
|
public function send(string $from, string $to, string $body): void;
|
||||||
|
}
|
||||||
15
backend/app/Email/LaravelEmailFactory.php
Normal file
15
backend/app/Email/LaravelEmailFactory.php
Normal 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
25
backend/app/Email/LaravelMailer.php
Normal file
25
backend/app/Email/LaravelMailer.php
Normal 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');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\User\UseCases\ConfirmUserEmail;
|
||||||
|
|
||||||
|
class ConfirmUserEmailRequest
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public ?string $token,
|
||||||
|
public ?string $password,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
82
backend/tests/Fakes/FakeEmailConfirmationTokenRepository.php
Normal file
82
backend/tests/Fakes/FakeEmailConfirmationTokenRepository.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
backend/tests/Fakes/FakeEmailFactory.php
Normal file
13
backend/tests/Fakes/FakeEmailFactory.php
Normal 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
35
backend/tests/Fakes/FakeEmailer.php
Normal file
35
backend/tests/Fakes/FakeEmailer.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
182
backend/tests/Unit/User/UseCases/ConfirmUserEmailTest.php
Normal file
182
backend/tests/Unit/User/UseCases/ConfirmUserEmailTest.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue