From 613180d4598488dcd0991d1d553692223eeb4f7a Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 18 May 2026 21:32:55 +0300 Subject: [PATCH 1/8] copy user entity and auth from ysv --- backend/app/Auth/BcryptPasswordHasher.php | 16 +++++ backend/app/Auth/Clock.php | 10 ++++ backend/app/Auth/CreateSessionDto.php | 16 +++++ .../app/Auth/EloquentSessionRepository.php | 60 +++++++++++++++++++ backend/app/Auth/PasswordHasher.php | 10 ++++ backend/app/Auth/RandomTokenGenerator.php | 11 ++++ backend/app/Auth/Session.php | 41 +++++++++++++ backend/app/Auth/SessionModel.php | 44 ++++++++++++++ backend/app/Auth/SessionRepository.php | 12 ++++ backend/app/Auth/SystemClock.php | 14 +++++ backend/app/Auth/TokenGenerator.php | 8 +++ .../AuthenticateUser/AuthenticateUser.php | 49 +++++++++++++++ .../AuthenticateUserRequest.php | 11 ++++ .../UseCases/CreateSession/CreateSession.php | 34 +++++++++++ backend/app/Auth/UseCases/Logout/Logout.php | 17 ++++++ .../app/Exceptions/BadRequestException.php | 7 +++ .../app/Exceptions/UnauthorizedException.php | 7 +++ .../app/Http/Middleware/AuthMiddleware.php | 51 ++++++++++++++++ .../app/Shared/ValueObject/EmailAddress.php | 54 +++++++++++++++++ backend/app/User/CreateUserDto.php | 13 ++++ backend/app/User/EloquentUserRepository.php | 52 ++++++++++++++++ backend/app/User/User.php | 29 +++++++++ backend/app/User/UserModel.php | 27 +++++++++ backend/app/User/UserRepository.php | 19 ++++++ 24 files changed, 612 insertions(+) create mode 100644 backend/app/Auth/BcryptPasswordHasher.php create mode 100644 backend/app/Auth/Clock.php create mode 100644 backend/app/Auth/CreateSessionDto.php create mode 100644 backend/app/Auth/EloquentSessionRepository.php create mode 100644 backend/app/Auth/PasswordHasher.php create mode 100644 backend/app/Auth/RandomTokenGenerator.php create mode 100644 backend/app/Auth/Session.php create mode 100644 backend/app/Auth/SessionModel.php create mode 100644 backend/app/Auth/SessionRepository.php create mode 100644 backend/app/Auth/SystemClock.php create mode 100644 backend/app/Auth/TokenGenerator.php create mode 100644 backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUser.php create mode 100644 backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUserRequest.php create mode 100644 backend/app/Auth/UseCases/CreateSession/CreateSession.php create mode 100644 backend/app/Auth/UseCases/Logout/Logout.php create mode 100644 backend/app/Exceptions/BadRequestException.php create mode 100644 backend/app/Exceptions/UnauthorizedException.php create mode 100644 backend/app/Http/Middleware/AuthMiddleware.php create mode 100644 backend/app/Shared/ValueObject/EmailAddress.php create mode 100644 backend/app/User/CreateUserDto.php create mode 100644 backend/app/User/EloquentUserRepository.php create mode 100644 backend/app/User/User.php create mode 100644 backend/app/User/UserModel.php create mode 100644 backend/app/User/UserRepository.php diff --git a/backend/app/Auth/BcryptPasswordHasher.php b/backend/app/Auth/BcryptPasswordHasher.php new file mode 100644 index 0000000..0bc4a46 --- /dev/null +++ b/backend/app/Auth/BcryptPasswordHasher.php @@ -0,0 +1,16 @@ + $dto->token, + 'user_id' => $dto->user->getId(), + 'created_at' => $dto->createdAt, + 'expires_at' => $dto->expiresAt, + ]); + + return new Session( + token: $dto->token, + user: $dto->user, + createdAt: $dto->createdAt, + expiresAt: $dto->expiresAt, + ); + } + + public function findByToken(string $token): ?Session + { + $model = SessionModel::find($token); + if ($model === null) { + return null; + } + $user = $this->userRepo->find($model->user_id); + if ($user === null) { + return null; + } + $utc = new DateTimeZone('UTC'); + + return new Session( + token: $model->token, + user: $user, + createdAt: new DateTimeImmutable( + $model->created_at->toDateTimeString(), + $utc + ), + expiresAt: new DateTimeImmutable( + $model->expires_at->toDateTimeString(), + $utc + ), + ); + } + + public function deleteByToken(string $token): void + { + SessionModel::where('token', $token)->delete(); + } +} diff --git a/backend/app/Auth/PasswordHasher.php b/backend/app/Auth/PasswordHasher.php new file mode 100644 index 0000000..ab57a41 --- /dev/null +++ b/backend/app/Auth/PasswordHasher.php @@ -0,0 +1,10 @@ +token; + } + + public function getUser(): User + { + return $this->user; + } + + public function getCreatedAt(): DateTimeImmutable + { + return $this->createdAt; + } + + public function getExpiresAt(): DateTimeImmutable + { + return $this->expiresAt; + } + + public function isExpired(DateTimeImmutable $now): bool + { + return $now >= $this->expiresAt; + } +} diff --git a/backend/app/Auth/SessionModel.php b/backend/app/Auth/SessionModel.php new file mode 100644 index 0000000..574a142 --- /dev/null +++ b/backend/app/Auth/SessionModel.php @@ -0,0 +1,44 @@ +|SessionModel newModelQuery() + * @method static Builder|SessionModel newQuery() + * @method static Builder|SessionModel query() + * + * @mixin \Eloquent + */ +class SessionModel extends Model +{ + protected $table = 'sessions'; + + protected $primaryKey = 'token'; + + public $incrementing = false; + + protected $keyType = 'string'; + + public $timestamps = false; + + protected $fillable = [ + 'token', + 'user_id', + 'created_at', + 'expires_at', + ]; + + protected $casts = [ + 'created_at' => 'datetime', + 'expires_at' => 'datetime', + ]; +} diff --git a/backend/app/Auth/SessionRepository.php b/backend/app/Auth/SessionRepository.php new file mode 100644 index 0000000..cabae60 --- /dev/null +++ b/backend/app/Auth/SessionRepository.php @@ -0,0 +1,12 @@ +email === null || $request->email === '') { + throw new BadRequestException('email is required'); + } + if ($request->password === null || $request->password === '') { + throw new BadRequestException('password is required'); + } + + $user = $this->userRepo->findByEmail( + new EmailAddress($request->email) + ); + if ($user === null) { + throw new UnauthorizedException('invalid credentials'); + } + + $passwordMatches = $this->hasher->verify( + $request->password, + $user->getPasswordHash(), + ); + if (! $passwordMatches) { + throw new UnauthorizedException('invalid credentials'); + } + + return $user; + } +} diff --git a/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUserRequest.php b/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUserRequest.php new file mode 100644 index 0000000..aa8b1df --- /dev/null +++ b/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUserRequest.php @@ -0,0 +1,11 @@ +clock->now(); + $expiresAt = $now->modify(self::SESSION_LIFETIME); + + return $this->sessionRepo->create(new CreateSessionDto( + token: $this->tokenGenerator->generate(), + user: $user, + createdAt: $now, + expiresAt: $expiresAt, + )); + } +} diff --git a/backend/app/Auth/UseCases/Logout/Logout.php b/backend/app/Auth/UseCases/Logout/Logout.php new file mode 100644 index 0000000..31b16de --- /dev/null +++ b/backend/app/Auth/UseCases/Logout/Logout.php @@ -0,0 +1,17 @@ +sessionRepo->deleteByToken($token); + } +} diff --git a/backend/app/Exceptions/BadRequestException.php b/backend/app/Exceptions/BadRequestException.php new file mode 100644 index 0000000..b900f47 --- /dev/null +++ b/backend/app/Exceptions/BadRequestException.php @@ -0,0 +1,7 @@ +cookie(self::COOKIE_NAME); + if (! is_string($token) || $token === '') { + return $this->unauthorized(); + } + + $session = $this->sessionRepo->findByToken($token); + if ($session === null) { + return $this->unauthorized(); + } + + if ($session->isExpired($this->clock->now())) { + $this->sessionRepo->deleteByToken($token); + + return $this->unauthorized(); + } + + $request->attributes->set('user', $session->getUser()); + + return $next($request); + } + + private function unauthorized(): JsonResponse + { + return new JsonResponse(['error' => 'unauthenticated'], 401); + } +} diff --git a/backend/app/Shared/ValueObject/EmailAddress.php b/backend/app/Shared/ValueObject/EmailAddress.php new file mode 100644 index 0000000..a744918 --- /dev/null +++ b/backend/app/Shared/ValueObject/EmailAddress.php @@ -0,0 +1,54 @@ +domain = mb_strtolower($domain); + $normalized = $local.'@'.$this->domain; + + if (filter_var($normalized, FILTER_VALIDATE_EMAIL) === false) { + throw new InvalidArgumentException(self::ERROR_MESSAGE." $email"); + } + + $this->normalized = $normalized; + } + + public function value(): string + { + return $this->normalized; + } + + public function equals(self $other): bool + { + return $this->normalized === $other->normalized; + } + + public function getDomain(): string + { + return $this->domain; + } + + public function __toString(): string + { + return $this->normalized; + } +} diff --git a/backend/app/User/CreateUserDto.php b/backend/app/User/CreateUserDto.php new file mode 100644 index 0000000..d10b373 --- /dev/null +++ b/backend/app/User/CreateUserDto.php @@ -0,0 +1,13 @@ + $dto->email->value(), + 'password_hash' => $dto->passwordHash, + ]); + + return $this->toDomain($model); + } + + public function findByEmail(EmailAddress $email): ?User + { + $model = UserModel::where('email', $email->value())->first(); + + return $model === null ? null : $this->toDomain($model); + } + + public function findByEmailDomain(string $domain): array + { + $models = UserModel::where('email', 'like', '%@'.$domain)->get(); + $users = []; + foreach ($models as $model) { + $users[] = $this->toDomain($model); + } + + return $users; + } + + public function find(int $id): ?User + { + $model = UserModel::find($id); + + return $model === null ? null : $this->toDomain($model); + } + + private function toDomain(UserModel $model): User + { + return new User( + id: $model->id, + email: new EmailAddress($model->email), + passwordHash: $model->password_hash, + ); + } +} diff --git a/backend/app/User/User.php b/backend/app/User/User.php new file mode 100644 index 0000000..3d8ed63 --- /dev/null +++ b/backend/app/User/User.php @@ -0,0 +1,29 @@ +id; + } + + public function getEmail(): EmailAddress + { + return $this->email; + } + + public function getPasswordHash(): string + { + return $this->passwordHash; + } +} diff --git a/backend/app/User/UserModel.php b/backend/app/User/UserModel.php new file mode 100644 index 0000000..7d8c09d --- /dev/null +++ b/backend/app/User/UserModel.php @@ -0,0 +1,27 @@ +|UserModel newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|UserModel newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|UserModel query() + * @method static \Illuminate\Database\Eloquent\Builder|UserModel whereEmail($value) + * @method static \Illuminate\Database\Eloquent\Builder|UserModel whereId($value) + * + * @mixin \Eloquent + */ +class UserModel extends Model +{ + protected $table = 'users'; + + public $timestamps = false; + + protected $fillable = ['email', 'password_hash']; +} diff --git a/backend/app/User/UserRepository.php b/backend/app/User/UserRepository.php new file mode 100644 index 0000000..3a1f083 --- /dev/null +++ b/backend/app/User/UserRepository.php @@ -0,0 +1,19 @@ + Date: Mon, 18 May 2026 21:36:10 +0300 Subject: [PATCH 2/8] add unit tests for user and auth --- backend/tests/Fakes/FakeSessionRepository.php | 47 ++++++++++ backend/tests/Fakes/FakeUserRepository.php | 70 +++++++++++++++ .../Auth/UseCases/AuthenticateUserTest.php | 89 +++++++++++++++++++ .../Unit/Auth/UseCases/CreateSessionTest.php | 51 +++++++++++ .../tests/Unit/Auth/UseCases/LogoutTest.php | 38 ++++++++ backend/tests/Unit/User/UserTest.php | 20 +++++ 6 files changed, 315 insertions(+) create mode 100644 backend/tests/Fakes/FakeSessionRepository.php create mode 100644 backend/tests/Fakes/FakeUserRepository.php create mode 100644 backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php create mode 100644 backend/tests/Unit/Auth/UseCases/CreateSessionTest.php create mode 100644 backend/tests/Unit/Auth/UseCases/LogoutTest.php create mode 100644 backend/tests/Unit/User/UserTest.php diff --git a/backend/tests/Fakes/FakeSessionRepository.php b/backend/tests/Fakes/FakeSessionRepository.php new file mode 100644 index 0000000..120ccd8 --- /dev/null +++ b/backend/tests/Fakes/FakeSessionRepository.php @@ -0,0 +1,47 @@ +token, + $dto->user, + $dto->createdAt, + $dto->expiresAt + ); + $this->sessionsByToken[$dto->token] = $session; + + return $session; + } + + public function findByToken(string $token): ?Session + { + if (! isset($this->sessionsByToken[$token])) { + return null; + } + + $stored = $this->sessionsByToken[$token]; + + return new Session( + $stored->getToken(), + $stored->getUser(), + $stored->getCreatedAt(), + $stored->getExpiresAt() + ); + } + + public function deleteByToken(string $token): void + { + unset($this->sessionsByToken[$token]); + } +} diff --git a/backend/tests/Fakes/FakeUserRepository.php b/backend/tests/Fakes/FakeUserRepository.php new file mode 100644 index 0000000..dc47606 --- /dev/null +++ b/backend/tests/Fakes/FakeUserRepository.php @@ -0,0 +1,70 @@ +usersById) + 1; + $user = new User($id, $dto->email, $dto->passwordHash); + $this->usersById[$id] = $user; + $this->usersByEmail[$dto->email->value()] = $user; + + return $user; + } + + public function findByEmail(EmailAddress $email): ?User + { + if (! isset($this->usersByEmail[$email->value()])) { + return null; + } + + $stored = $this->usersByEmail[$email->value()]; + + return new User( + $stored->getId(), + $stored->getEmail(), + $stored->getPasswordHash() + ); + } + + public function findByEmailDomain(string $domain): array + { + $result = []; + foreach ($this->usersByEmail as $email => $stored) { + if (str_ends_with($email, '@' . $domain)) { + $result[] = new User( + $stored->getId(), + $stored->getEmail(), + $stored->getPasswordHash() + ); + } + } + + return $result; + } + + public function find(int $id): ?User + { + if (! isset($this->usersById[$id])) { + return null; + } + + $stored = $this->usersById[$id]; + + return new User( + $stored->getId(), + $stored->getEmail(), + $stored->getPasswordHash() + ); + } +} diff --git a/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php b/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php new file mode 100644 index 0000000..d9056e0 --- /dev/null +++ b/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php @@ -0,0 +1,89 @@ +userRepo = new FakeUserRepository(); + $this->hasher = new class implements PasswordHasher { + public function hash(string $password): string + { + return 'hashed-' . $password; + } + + public function verify(string $password, string $hash): bool + { + return $hash === 'hashed-' . $password; + } + }; + + $this->authenticateUser = new AuthenticateUser($this->userRepo, $this->hasher); + } + + public function testAuthenticatesValidUser(): void + { + $email = new EmailAddress('user@example.com'); + $this->userRepo->create(new \App\User\CreateUserDto($email, 'hashed-secret')); + + $request = new AuthenticateUserRequest('user@example.com', 'secret'); + $user = $this->authenticateUser->execute($request); + + $this->assertInstanceOf(User::class, $user); + $this->assertSame('user@example.com', $user->getEmail()->value()); + } + + public function testThrowsWhenEmailMissing(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('email is required'); + + $request = new AuthenticateUserRequest(null, 'secret'); + $this->authenticateUser->execute($request); + } + + public function testThrowsWhenPasswordMissing(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('password is required'); + + $request = new AuthenticateUserRequest('user@example.com', null); + $this->authenticateUser->execute($request); + } + + public function testThrowsWhenUserNotFound(): void + { + $this->expectException(UnauthorizedException::class); + $this->expectExceptionMessage('invalid credentials'); + + $request = new AuthenticateUserRequest('missing@example.com', 'secret'); + $this->authenticateUser->execute($request); + } + + public function testThrowsWhenPasswordIncorrect(): void + { + $email = new EmailAddress('user@example.com'); + $this->userRepo->create(new \App\User\CreateUserDto($email, 'hashed-secret')); + + $this->expectException(UnauthorizedException::class); + $this->expectExceptionMessage('invalid credentials'); + + $request = new AuthenticateUserRequest('user@example.com', 'wrong'); + $this->authenticateUser->execute($request); + } +} diff --git a/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php b/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php new file mode 100644 index 0000000..93c9f25 --- /dev/null +++ b/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php @@ -0,0 +1,51 @@ +sessionRepo = new FakeSessionRepository(); + $this->tokenGenerator = new class implements TokenGenerator { + public function generate(): string + { + return 'fake-token-123'; + } + }; + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-05-18 12:00:00', new \DateTimeZone('UTC')); + } + }; + + $this->createSession = new CreateSession($this->sessionRepo, $this->tokenGenerator, $this->clock); + } + + public function testCreatesSessionForUser(): void + { + $email = new EmailAddress('user@example.com'); + $user = new User(1, $email, 'hashed-password'); + + $session = $this->createSession->execute($user); + + $this->assertSame('fake-token-123', $session->getToken()); + $this->assertSame($user, $session->getUser()); + $this->assertFalse($session->isExpired($this->clock->now())); + } +} diff --git a/backend/tests/Unit/Auth/UseCases/LogoutTest.php b/backend/tests/Unit/Auth/UseCases/LogoutTest.php new file mode 100644 index 0000000..36c5f16 --- /dev/null +++ b/backend/tests/Unit/Auth/UseCases/LogoutTest.php @@ -0,0 +1,38 @@ +sessionRepo = new FakeSessionRepository(); + $this->logout = new Logout($this->sessionRepo); + } + + public function testDeletesSessionByToken(): void + { + $email = new EmailAddress('user@example.com'); + $user = new User(1, $email, 'hashed-password'); + + $session = $this->sessionRepo->create(new \App\Auth\CreateSessionDto( + 'session-token', + $user, + new \DateTimeImmutable(), + new \DateTimeImmutable('+1 hour') + )); + + $this->logout->execute('session-token'); + + $this->assertNull($this->sessionRepo->findByToken('session-token')); + } +} diff --git a/backend/tests/Unit/User/UserTest.php b/backend/tests/Unit/User/UserTest.php new file mode 100644 index 0000000..89734e9 --- /dev/null +++ b/backend/tests/Unit/User/UserTest.php @@ -0,0 +1,20 @@ +assertSame(1, $user->getId()); + $this->assertSame($email, $user->getEmail()); + $this->assertSame('hashed-password', $user->getPasswordHash()); + } +} From ae07a6ff7cd5ce599605cb513d5d20e5023449b9 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 18 May 2026 21:46:50 +0300 Subject: [PATCH 3/8] remove web from routing --- backend/bootstrap/app.php | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/bootstrap/app.php b/backend/bootstrap/app.php index c183276..cb7c01a 100644 --- a/backend/bootstrap/app.php +++ b/backend/bootstrap/app.php @@ -6,7 +6,6 @@ use Illuminate\Foundation\Configuration\Middleware; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( - web: __DIR__.'/../routes/web.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) From 64acbfad60f63c1aa4f8ca7ac483a4c0618e4cbc Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 18 May 2026 22:01:45 +0300 Subject: [PATCH 4/8] extract auth test fakes --- backend/tests/Fakes/FakeClock.php | 15 ++++++++++++++ backend/tests/Fakes/FakeHasher.php | 18 +++++++++++++++++ backend/tests/Fakes/FakeTokenGenerator.php | 13 ++++++++++++ .../Auth/UseCases/AuthenticateUserTest.php | 18 +++++------------ .../Unit/Auth/UseCases/CreateSessionTest.php | 20 +++++-------------- .../tests/Unit/Auth/UseCases/LogoutTest.php | 3 ++- 6 files changed, 58 insertions(+), 29 deletions(-) create mode 100644 backend/tests/Fakes/FakeClock.php create mode 100644 backend/tests/Fakes/FakeHasher.php create mode 100644 backend/tests/Fakes/FakeTokenGenerator.php diff --git a/backend/tests/Fakes/FakeClock.php b/backend/tests/Fakes/FakeClock.php new file mode 100644 index 0000000..f32d5a4 --- /dev/null +++ b/backend/tests/Fakes/FakeClock.php @@ -0,0 +1,15 @@ +userRepo = new FakeUserRepository(); - $this->hasher = new class implements PasswordHasher { - public function hash(string $password): string - { - return 'hashed-' . $password; - } - - public function verify(string $password, string $hash): bool - { - return $hash === 'hashed-' . $password; - } - }; + $this->hasher = new FakeHasher(); $this->authenticateUser = new AuthenticateUser($this->userRepo, $this->hasher); } @@ -39,7 +31,7 @@ class AuthenticateUserTest extends TestCase public function testAuthenticatesValidUser(): void { $email = new EmailAddress('user@example.com'); - $this->userRepo->create(new \App\User\CreateUserDto($email, 'hashed-secret')); + $this->userRepo->create(new CreateUserDto($email, 'hashed-secret')); $request = new AuthenticateUserRequest('user@example.com', 'secret'); $user = $this->authenticateUser->execute($request); @@ -78,7 +70,7 @@ class AuthenticateUserTest extends TestCase public function testThrowsWhenPasswordIncorrect(): void { $email = new EmailAddress('user@example.com'); - $this->userRepo->create(new \App\User\CreateUserDto($email, 'hashed-secret')); + $this->userRepo->create(new CreateUserDto($email, 'hashed-secret')); $this->expectException(UnauthorizedException::class); $this->expectExceptionMessage('invalid credentials'); diff --git a/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php b/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php index 93c9f25..183ec36 100644 --- a/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php +++ b/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php @@ -3,36 +3,26 @@ namespace Tests\Unit\Auth\UseCases; use App\Auth\Clock; -use App\Auth\TokenGenerator; use App\Auth\UseCases\CreateSession\CreateSession; use App\Shared\ValueObject\EmailAddress; use App\User\User; -use DateTimeImmutable; use PHPUnit\Framework\TestCase; +use Tests\Fakes\FakeClock; use Tests\Fakes\FakeSessionRepository; +use Tests\Fakes\FakeTokenGenerator; class CreateSessionTest extends TestCase { private FakeSessionRepository $sessionRepo; - private TokenGenerator $tokenGenerator; + private FakeTokenGenerator $tokenGenerator; private Clock $clock; private CreateSession $createSession; protected function setUp(): void { $this->sessionRepo = new FakeSessionRepository(); - $this->tokenGenerator = new class implements TokenGenerator { - public function generate(): string - { - return 'fake-token-123'; - } - }; - $this->clock = new class implements Clock { - public function now(): DateTimeImmutable - { - return new DateTimeImmutable('2026-05-18 12:00:00', new \DateTimeZone('UTC')); - } - }; + $this->tokenGenerator = new FakeTokenGenerator(); + $this->clock = new FakeClock(); $this->createSession = new CreateSession($this->sessionRepo, $this->tokenGenerator, $this->clock); } diff --git a/backend/tests/Unit/Auth/UseCases/LogoutTest.php b/backend/tests/Unit/Auth/UseCases/LogoutTest.php index 36c5f16..6697e81 100644 --- a/backend/tests/Unit/Auth/UseCases/LogoutTest.php +++ b/backend/tests/Unit/Auth/UseCases/LogoutTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\Auth\UseCases; +use App\Auth\CreateSessionDto; use App\Auth\UseCases\Logout\Logout; use App\Shared\ValueObject\EmailAddress; use App\User\User; @@ -24,7 +25,7 @@ class LogoutTest extends TestCase $email = new EmailAddress('user@example.com'); $user = new User(1, $email, 'hashed-password'); - $session = $this->sessionRepo->create(new \App\Auth\CreateSessionDto( + $session = $this->sessionRepo->create(new CreateSessionDto( 'session-token', $user, new \DateTimeImmutable(), From 1c5d09f09c532fb51a80e7fa71e9449078dcb80d Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 18 May 2026 22:06:26 +0300 Subject: [PATCH 5/8] add empty string tests for authenticate --- .../Auth/UseCases/AuthenticateUserTest.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php b/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php index 394190d..8a96f41 100644 --- a/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php +++ b/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php @@ -58,6 +58,24 @@ class AuthenticateUserTest extends TestCase $this->authenticateUser->execute($request); } + public function testThrowsWhenEmailEmpty(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('email is required'); + + $request = new AuthenticateUserRequest('', 'secret'); + $this->authenticateUser->execute($request); + } + + public function testThrowsWhenPasswordEmpty(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('password is required'); + + $request = new AuthenticateUserRequest('user@example.com', ''); + $this->authenticateUser->execute($request); + } + public function testThrowsWhenUserNotFound(): void { $this->expectException(UnauthorizedException::class); From 7c996ade74b9d7883c4d0725d1ac5883a55eb6ca Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 18 May 2026 22:06:38 +0300 Subject: [PATCH 6/8] add timestamp and persistence assertions for create session --- backend/tests/Unit/Auth/UseCases/CreateSessionTest.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php b/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php index 183ec36..d6368f2 100644 --- a/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php +++ b/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php @@ -37,5 +37,11 @@ class CreateSessionTest extends TestCase $this->assertSame('fake-token-123', $session->getToken()); $this->assertSame($user, $session->getUser()); $this->assertFalse($session->isExpired($this->clock->now())); + $this->assertSame('2026-05-18 12:00:00', $session->getCreatedAt()->format('Y-m-d H:i:s')); + $this->assertSame('2026-05-25 12:00:00', $session->getExpiresAt()->format('Y-m-d H:i:s')); + + $stored = $this->sessionRepo->findByToken($session->getToken()); + $this->assertNotNull($stored); + $this->assertSame('fake-token-123', $stored->getToken()); } } From 383a8a0fbdec3a8240edeab12f041f38f5b5e1d7 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 18 May 2026 22:06:46 +0300 Subject: [PATCH 7/8] add idempotent logout for missing token test --- backend/tests/Unit/Auth/UseCases/LogoutTest.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/backend/tests/Unit/Auth/UseCases/LogoutTest.php b/backend/tests/Unit/Auth/UseCases/LogoutTest.php index 6697e81..2b28630 100644 --- a/backend/tests/Unit/Auth/UseCases/LogoutTest.php +++ b/backend/tests/Unit/Auth/UseCases/LogoutTest.php @@ -36,4 +36,11 @@ class LogoutTest extends TestCase $this->assertNull($this->sessionRepo->findByToken('session-token')); } + + public function testDeletesMissingTokenIsIdempotent(): void + { + $this->logout->execute('nonexistent-token'); + + $this->assertNull($this->sessionRepo->findByToken('nonexistent-token')); + } } From 883f83814ccffb39c998c815ce38f78a55a0b5ee Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 18 May 2026 22:13:29 +0300 Subject: [PATCH 8/8] fix backend process compose --- process-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/process-compose.yml b/process-compose.yml index 7f71fe0..4fcfb4e 100644 --- a/process-compose.yml +++ b/process-compose.yml @@ -12,8 +12,8 @@ processes: period_seconds: 2 backend: - command: bash backend/bin/serve - working_dir: . + command: php artisan serve --host=127.0.0.1 --port=8000 + working_dir: backend depends_on: postgres: condition: process_healthy