From b5697e1e1f82618aabbaa64e409312a9012bc941 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 31 Jul 2026 09:48:01 +0300 Subject: [PATCH 01/11] test user persistence --- .../User/EloquentUserRepositoryTest.php | 48 +++++++++++++++++++ .../Shared/ValueObject/EmailAddressTest.php | 27 +++++++++++ backend/tests/Unit/User/UserTest.php | 19 ++++++++ 3 files changed, 94 insertions(+) create mode 100644 backend/tests/Feature/User/EloquentUserRepositoryTest.php create mode 100644 backend/tests/Unit/Shared/ValueObject/EmailAddressTest.php create mode 100644 backend/tests/Unit/User/UserTest.php diff --git a/backend/tests/Feature/User/EloquentUserRepositoryTest.php b/backend/tests/Feature/User/EloquentUserRepositoryTest.php new file mode 100644 index 0000000..207523f --- /dev/null +++ b/backend/tests/Feature/User/EloquentUserRepositoryTest.php @@ -0,0 +1,48 @@ +create(new CreateUserDto( + email: new EmailAddress('Founder@EXAMPLE.COM'), + )); + + $this->assertGreaterThan(0, $user->getId()); + $this->assertSame( + 'Founder@example.com', + $user->getEmail()->value(), + ); + $this->assertDatabaseHas('users', [ + 'id' => $user->getId(), + 'email' => 'Founder@example.com', + ]); + + $foundUser = $repository->find($user->getId()); + + $this->assertNotNull($foundUser); + $this->assertSame($user->getId(), $foundUser->getId()); + $this->assertSame( + $user->getEmail()->value(), + $foundUser->getEmail()->value(), + ); + } + + public function test_it_returns_null_for_an_unknown_user(): void + { + $repository = app(UserRepository::class); + + $this->assertNull($repository->find(999)); + } +} diff --git a/backend/tests/Unit/Shared/ValueObject/EmailAddressTest.php b/backend/tests/Unit/Shared/ValueObject/EmailAddressTest.php new file mode 100644 index 0000000..c47b3e4 --- /dev/null +++ b/backend/tests/Unit/Shared/ValueObject/EmailAddressTest.php @@ -0,0 +1,27 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Invalid email address: invalid-email', + ); + + new EmailAddress('invalid-email'); + } + + public function test_it_trims_and_normalizes_the_domain(): void + { + $email = new EmailAddress(' Founder@EXAMPLE.COM '); + + $this->assertSame('Founder@example.com', $email->value()); + } +} diff --git a/backend/tests/Unit/User/UserTest.php b/backend/tests/Unit/User/UserTest.php new file mode 100644 index 0000000..0ac8d53 --- /dev/null +++ b/backend/tests/Unit/User/UserTest.php @@ -0,0 +1,19 @@ +assertSame(42, $user->getId()); + $this->assertSame($email, $user->getEmail()); + } +} From 0777f0da2ff39c6e6a630e0a4210c40702e38042 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 31 Jul 2026 09:49:18 +0300 Subject: [PATCH 02/11] add user entity --- backend/app/Models/User.php | 40 ------ backend/app/Providers/AppServiceProvider.php | 7 +- .../app/Shared/ValueObject/EmailAddress.php | 40 ++++++ backend/app/User/CreateUserDto.php | 12 ++ backend/app/User/EloquentUserRepository.php | 35 ++++++ backend/app/User/User.php | 23 ++++ backend/app/User/UserModel.php | 25 ++++ backend/app/User/UserRepository.php | 10 ++ backend/config/auth.php | 117 ------------------ .../0001_01_01_000000_create_users_table.php | 21 ++++ 10 files changed, 172 insertions(+), 158 deletions(-) delete mode 100644 backend/app/Models/User.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 delete mode 100644 backend/config/auth.php create mode 100644 backend/database/migrations/0001_01_01_000000_create_users_table.php diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php deleted file mode 100644 index 4c7c078..0000000 --- a/backend/app/Models/User.php +++ /dev/null @@ -1,40 +0,0 @@ - - */ - protected function casts(): array - { - return [ - 'email_verified_at' => 'datetime', - 'password' => 'hashed', - ]; - } -} diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index f1525e9..fdc3673 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -2,6 +2,8 @@ namespace App\Providers; +use App\User\EloquentUserRepository; +use App\User\UserRepository; use Carbon\CarbonImmutable; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; @@ -15,7 +17,10 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->bind( + UserRepository::class, + EloquentUserRepository::class, + ); } /** diff --git a/backend/app/Shared/ValueObject/EmailAddress.php b/backend/app/Shared/ValueObject/EmailAddress.php new file mode 100644 index 0000000..de15bd9 --- /dev/null +++ b/backend/app/Shared/ValueObject/EmailAddress.php @@ -0,0 +1,40 @@ +normalized = $normalizedEmail; + } + + public function value(): string + { + return $this->normalized; + } +} diff --git a/backend/app/User/CreateUserDto.php b/backend/app/User/CreateUserDto.php new file mode 100644 index 0000000..735320b --- /dev/null +++ b/backend/app/User/CreateUserDto.php @@ -0,0 +1,12 @@ + $dto->email->value(), + ]); + + return $this->toDomain($model); + } + + public function find(int $id): ?User + { + $model = UserModel::find($id); + if ($model === null) { + return null; + } + + return $this->toDomain($model); + } + + private function toDomain(UserModel $model): User + { + return new User( + id: $model->id, + email: new EmailAddress($model->email), + ); + } +} diff --git a/backend/app/User/User.php b/backend/app/User/User.php new file mode 100644 index 0000000..8c9fa9c --- /dev/null +++ b/backend/app/User/User.php @@ -0,0 +1,23 @@ +id; + } + + public function getEmail(): EmailAddress + { + return $this->email; + } +} diff --git a/backend/app/User/UserModel.php b/backend/app/User/UserModel.php new file mode 100644 index 0000000..74f3211 --- /dev/null +++ b/backend/app/User/UserModel.php @@ -0,0 +1,25 @@ +|UserModel newModelQuery() + * @method static Builder|UserModel newQuery() + * @method static Builder|UserModel query() + * + * @mixin \Eloquent + */ +#[Fillable(['email'])] +class UserModel extends Model +{ + protected $table = 'users'; + + public $timestamps = false; +} diff --git a/backend/app/User/UserRepository.php b/backend/app/User/UserRepository.php new file mode 100644 index 0000000..5fcd6eb --- /dev/null +++ b/backend/app/User/UserRepository.php @@ -0,0 +1,10 @@ + [ - 'guard' => env('AUTH_GUARD', 'web'), - 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), - ], - - /* - |-------------------------------------------------------------------------- - | Authentication Guards - |-------------------------------------------------------------------------- - | - | Next, you may define every authentication guard for your application. - | Of course, a great default configuration has been defined for you - | which utilizes session storage plus the Eloquent user provider. - | - | All authentication guards have a user provider, which defines how the - | users are actually retrieved out of your database or other storage - | system used by the application. Typically, Eloquent is utilized. - | - | Supported: "session" - | - */ - - 'guards' => [ - 'web' => [ - 'driver' => 'session', - 'provider' => 'users', - ], - ], - - /* - |-------------------------------------------------------------------------- - | User Providers - |-------------------------------------------------------------------------- - | - | All authentication guards have a user provider, which defines how the - | users are actually retrieved out of your database or other storage - | system used by the application. Typically, Eloquent is utilized. - | - | If you have multiple user tables or models you may configure multiple - | providers to represent the model / table. These providers may then - | be assigned to any extra authentication guards you have defined. - | - | Supported: "database", "eloquent" - | - */ - - 'providers' => [ - 'users' => [ - 'driver' => 'eloquent', - 'model' => env('AUTH_MODEL', User::class), - ], - - // 'users' => [ - // 'driver' => 'database', - // 'table' => 'users', - // ], - ], - - /* - |-------------------------------------------------------------------------- - | Resetting Passwords - |-------------------------------------------------------------------------- - | - | These configuration options specify the behavior of Laravel's password - | reset functionality, including the table utilized for token storage - | and the user provider that is invoked to actually retrieve users. - | - | The expiry time is the number of minutes that each reset token will be - | considered valid. This security feature keeps tokens short-lived so - | they have less time to be guessed. You may change this as needed. - | - | The throttle setting is the number of seconds a user must wait before - | generating more password reset tokens. This prevents the user from - | quickly generating a very large amount of password reset tokens. - | - */ - - 'passwords' => [ - 'users' => [ - 'provider' => 'users', - 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), - 'expire' => 60, - 'throttle' => 60, - ], - ], - - /* - |-------------------------------------------------------------------------- - | Password Confirmation Timeout - |-------------------------------------------------------------------------- - | - | Here you may define the number of seconds before a password confirmation - | window expires and users are asked to re-enter their password via the - | confirmation screen. By default, the timeout lasts for three hours. - | - */ - - 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), - -]; diff --git a/backend/database/migrations/0001_01_01_000000_create_users_table.php b/backend/database/migrations/0001_01_01_000000_create_users_table.php new file mode 100644 index 0000000..dafb6c2 --- /dev/null +++ b/backend/database/migrations/0001_01_01_000000_create_users_table.php @@ -0,0 +1,21 @@ +id(); + $table->string('email')->unique(); + }); + } + + public function down(): void + { + Schema::dropIfExists('users'); + } +}; From a37c8da5551b16ceae2d4d4064c5dd7bbfb00516 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 31 Jul 2026 09:50:08 +0300 Subject: [PATCH 03/11] test auth sessions --- .../Auth/EloquentSessionRepositoryTest.php | 85 +++++++++++++++++++ backend/tests/Unit/Auth/SessionTest.php | 61 +++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php create mode 100644 backend/tests/Unit/Auth/SessionTest.php diff --git a/backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php b/backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php new file mode 100644 index 0000000..c9f76e2 --- /dev/null +++ b/backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php @@ -0,0 +1,85 @@ +create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + )); + $createdAt = $this->utc('2026-07-31T12:00:00'); + $expiresAt = $this->utc('2026-08-07T12:00:00'); + $repository = app(SessionRepository::class); + + $session = $repository->create(new CreateSessionDto( + token: 'session-token', + user: $user, + createdAt: $createdAt, + expiresAt: $expiresAt, + )); + + $this->assertSame('session-token', $session->getToken()); + $this->assertSame($user, $session->getUser()); + $this->assertDatabaseHas('auth_sessions', [ + 'token' => 'session-token', + 'user_id' => $user->getId(), + ]); + + $foundSession = $repository->findByToken('session-token'); + + $this->assertNotNull($foundSession); + $this->assertSame( + $user->getId(), + $foundSession->getUser()->getId(), + ); + $this->assertEquals($createdAt, $foundSession->getCreatedAt()); + $this->assertEquals($expiresAt, $foundSession->getExpiresAt()); + } + + public function test_it_returns_null_for_an_unknown_token(): void + { + $repository = app(SessionRepository::class); + + $this->assertNull($repository->findByToken('unknown-token')); + } + + public function test_it_deletes_a_session_by_token(): void + { + $user = app(UserRepository::class)->create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + )); + $repository = app(SessionRepository::class); + $repository->create(new CreateSessionDto( + token: 'session-token', + user: $user, + createdAt: $this->utc('2026-07-31T12:00:00'), + expiresAt: $this->utc('2026-08-07T12:00:00'), + )); + + $repository->deleteByToken('session-token'); + + $this->assertNull($repository->findByToken('session-token')); + $this->assertDatabaseMissing('auth_sessions', [ + 'token' => 'session-token', + ]); + } + + private function utc(string $time): DateTimeImmutable + { + return new DateTimeImmutable($time, new DateTimeZone('UTC')); + } +} diff --git a/backend/tests/Unit/Auth/SessionTest.php b/backend/tests/Unit/Auth/SessionTest.php new file mode 100644 index 0000000..7804333 --- /dev/null +++ b/backend/tests/Unit/Auth/SessionTest.php @@ -0,0 +1,61 @@ +utc('2026-07-31T12:00:00'); + $expiresAt = $this->utc('2026-08-07T12:00:00'); + $session = new Session( + token: 'session-token', + user: $user, + createdAt: $createdAt, + expiresAt: $expiresAt, + ); + + $this->assertSame('session-token', $session->getToken()); + $this->assertSame($user, $session->getUser()); + $this->assertSame($createdAt, $session->getCreatedAt()); + $this->assertSame($expiresAt, $session->getExpiresAt()); + } + + public function test_it_expires_at_the_expiry_time(): void + { + $expiresAt = $this->utc('2026-08-07T12:00:00'); + $session = new Session( + token: 'session-token', + user: new User( + id: 7, + email: new EmailAddress('user@example.com'), + ), + createdAt: $this->utc('2026-07-31T12:00:00'), + expiresAt: $expiresAt, + ); + + $this->assertFalse( + $session->isExpired($expiresAt->modify('-1 second')), + ); + $this->assertTrue($session->isExpired($expiresAt)); + $this->assertTrue( + $session->isExpired($expiresAt->modify('+1 second')), + ); + } + + private function utc(string $time): DateTimeImmutable + { + return new DateTimeImmutable($time, new DateTimeZone('UTC')); + } +} From 34e26f81f535ae58293a74f90a1e75640c365e63 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 31 Jul 2026 09:51:39 +0300 Subject: [PATCH 04/11] add auth sessions --- backend/app/Auth/Clock.php | 10 +++ backend/app/Auth/CreateSessionDto.php | 16 +++++ .../app/Auth/EloquentSessionRepository.php | 63 +++++++++++++++++++ backend/app/Auth/Session.php | 41 ++++++++++++ backend/app/Auth/SessionModel.php | 50 +++++++++++++++ backend/app/Auth/SessionRepository.php | 12 ++++ backend/app/Auth/SystemClock.php | 17 +++++ backend/app/Providers/AppServiceProvider.php | 9 +++ ...7_31_000000_create_auth_sessions_table.php | 25 ++++++++ 9 files changed, 243 insertions(+) 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/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/database/migrations/2026_07_31_000000_create_auth_sessions_table.php diff --git a/backend/app/Auth/Clock.php b/backend/app/Auth/Clock.php new file mode 100644 index 0000000..b96ad32 --- /dev/null +++ b/backend/app/Auth/Clock.php @@ -0,0 +1,10 @@ + $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->userRepository->find($model->user_id); + if ($user === null) { + return null; + } + + return new Session( + token: $model->token, + user: $user, + createdAt: $this->toUtc($model->created_at), + expiresAt: $this->toUtc($model->expires_at), + ); + } + + public function deleteByToken(string $token): void + { + SessionModel::where('token', $token)->delete(); + } + + private function toUtc(CarbonInterface $dateTime): DateTimeImmutable + { + return DateTimeImmutable::createFromInterface($dateTime) + ->setTimezone(new DateTimeZone('UTC')); + } +} diff --git a/backend/app/Auth/Session.php b/backend/app/Auth/Session.php new file mode 100644 index 0000000..5fd9cb1 --- /dev/null +++ b/backend/app/Auth/Session.php @@ -0,0 +1,41 @@ +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..75e87e3 --- /dev/null +++ b/backend/app/Auth/SessionModel.php @@ -0,0 +1,50 @@ +|SessionModel newModelQuery() + * @method static Builder|SessionModel newQuery() + * @method static Builder|SessionModel query() + * + * @mixin \Eloquent + */ +#[Fillable([ + 'token', + 'user_id', + 'created_at', + 'expires_at', +])] +class SessionModel extends Model +{ + protected $table = 'auth_sessions'; + + protected $primaryKey = 'token'; + + public $incrementing = false; + + protected $keyType = 'string'; + + public $timestamps = false; + + /** + * @return array + */ + protected function casts(): array + { + return [ + '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 @@ +app->bind( + SessionRepository::class, + EloquentSessionRepository::class, + ); + $this->app->bind(Clock::class, SystemClock::class); } /** diff --git a/backend/database/migrations/2026_07_31_000000_create_auth_sessions_table.php b/backend/database/migrations/2026_07_31_000000_create_auth_sessions_table.php new file mode 100644 index 0000000..995c147 --- /dev/null +++ b/backend/database/migrations/2026_07_31_000000_create_auth_sessions_table.php @@ -0,0 +1,25 @@ +string('token', 64)->primary(); + $table->foreignId('user_id') + ->constrained('users') + ->cascadeOnDelete(); + $table->timestamp('created_at'); + $table->timestamp('expires_at')->index(); + }); + } + + public function down(): void + { + Schema::dropIfExists('auth_sessions'); + } +}; From 19d930b7e85ceb8522bf18c604c5f58bbaf963e8 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 31 Jul 2026 09:53:12 +0300 Subject: [PATCH 05/11] test auth middleware --- backend/tests/Fakes/FakeClock.php | 18 ++ backend/tests/Fakes/FakeSessionRepository.php | 38 +++++ .../tests/Feature/Auth/AuthMiddlewareTest.php | 110 ++++++++++++ .../Auth/Middleware/AuthMiddlewareTest.php | 159 ++++++++++++++++++ 4 files changed, 325 insertions(+) create mode 100644 backend/tests/Fakes/FakeClock.php create mode 100644 backend/tests/Fakes/FakeSessionRepository.php create mode 100644 backend/tests/Feature/Auth/AuthMiddlewareTest.php create mode 100644 backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php diff --git a/backend/tests/Fakes/FakeClock.php b/backend/tests/Fakes/FakeClock.php new file mode 100644 index 0000000..30374d3 --- /dev/null +++ b/backend/tests/Fakes/FakeClock.php @@ -0,0 +1,18 @@ +currentTime; + } +} diff --git a/backend/tests/Fakes/FakeSessionRepository.php b/backend/tests/Fakes/FakeSessionRepository.php new file mode 100644 index 0000000..d64efd5 --- /dev/null +++ b/backend/tests/Fakes/FakeSessionRepository.php @@ -0,0 +1,38 @@ + + */ + private array $sessions = []; + + public function create(CreateSessionDto $dto): Session + { + $session = new Session( + token: $dto->token, + user: $dto->user, + createdAt: $dto->createdAt, + expiresAt: $dto->expiresAt, + ); + $this->sessions[$dto->token] = $session; + + return $session; + } + + public function findByToken(string $token): ?Session + { + return $this->sessions[$token] ?? null; + } + + public function deleteByToken(string $token): void + { + unset($this->sessions[$token]); + } +} diff --git a/backend/tests/Feature/Auth/AuthMiddlewareTest.php b/backend/tests/Feature/Auth/AuthMiddlewareTest.php new file mode 100644 index 0000000..5b9c034 --- /dev/null +++ b/backend/tests/Feature/Auth/AuthMiddlewareTest.php @@ -0,0 +1,110 @@ +get( + '/test/authenticated-user', + function (Request $request): JsonResponse { + $user = $request->attributes->get('user'); + if (! $user instanceof User) { + return new JsonResponse(['error' => 'missing user'], 500); + } + + return new JsonResponse([ + 'id' => $user->getId(), + 'email' => $user->getEmail()->value(), + ]); + }, + ); + } + + public function test_valid_cookie_reaches_the_protected_route(): void + { + $user = $this->createUserAndSession( + token: 'valid-token', + expiresAt: $this->utc('2030-08-07T12:00:00'), + ); + + $response = $this->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'valid-token', + )->getJson('/test/authenticated-user'); + + $response->assertOk()->assertExactJson([ + 'id' => $user->getId(), + 'email' => 'user@example.com', + ]); + } + + public function test_missing_cookie_is_rejected(): void + { + $response = $this->getJson('/test/authenticated-user'); + + $response + ->assertStatus(401) + ->assertExactJson(['error' => 'unauthenticated']); + } + + public function test_expired_cookie_is_rejected_and_deleted(): void + { + $this->createUserAndSession( + token: 'expired-token', + expiresAt: $this->utc('2020-08-07T12:00:00'), + ); + + $response = $this->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'expired-token', + )->getJson('/test/authenticated-user'); + + $response->assertStatus(401); + $this->assertNull( + app(SessionRepository::class)->findByToken('expired-token'), + ); + } + + private function createUserAndSession( + string $token, + DateTimeImmutable $expiresAt, + ): User { + $user = app(UserRepository::class)->create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + )); + app(SessionRepository::class)->create(new CreateSessionDto( + token: $token, + user: $user, + createdAt: $this->utc('2026-07-31T12:00:00'), + expiresAt: $expiresAt, + )); + + return $user; + } + + private function utc(string $time): DateTimeImmutable + { + return new DateTimeImmutable($time, new DateTimeZone('UTC')); + } +} diff --git a/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php b/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php new file mode 100644 index 0000000..65908c7 --- /dev/null +++ b/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php @@ -0,0 +1,159 @@ +now = new DateTimeImmutable( + '2026-07-31T12:00:00', + new DateTimeZone('UTC'), + ); + $this->sessionRepository = new FakeSessionRepository; + $this->middleware = new AuthMiddleware( + sessionRepository: $this->sessionRepository, + clock: new FakeClock($this->now), + ); + } + + public function test_missing_cookie_returns_unauthenticated(): void + { + $capturedRequest = null; + + $response = $this->middleware->handle( + $this->requestWithToken(null), + $this->captureNextRequest($capturedRequest), + ); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertSame( + ['error' => 'unauthenticated'], + json_decode($response->getContent(), true), + ); + $this->assertNull($capturedRequest); + } + + public function test_empty_cookie_returns_unauthenticated(): void + { + $capturedRequest = null; + + $response = $this->middleware->handle( + $this->requestWithToken(''), + $this->captureNextRequest($capturedRequest), + ); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertNull($capturedRequest); + } + + public function test_unknown_token_returns_unauthenticated(): void + { + $capturedRequest = null; + + $response = $this->middleware->handle( + $this->requestWithToken('unknown-token'), + $this->captureNextRequest($capturedRequest), + ); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertNull($capturedRequest); + } + + public function test_expired_session_is_deleted(): void + { + $this->sessionRepository->create(new CreateSessionDto( + token: 'expired-token', + user: $this->user(), + createdAt: $this->now->modify('-8 days'), + expiresAt: $this->now->modify('-1 day'), + )); + $capturedRequest = null; + + $response = $this->middleware->handle( + $this->requestWithToken('expired-token'), + $this->captureNextRequest($capturedRequest), + ); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertNull($capturedRequest); + $this->assertNull( + $this->sessionRepository->findByToken('expired-token'), + ); + } + + public function test_valid_session_attaches_user_and_calls_next(): void + { + $user = $this->user(); + $this->sessionRepository->create(new CreateSessionDto( + token: 'valid-token', + user: $user, + createdAt: $this->now, + expiresAt: $this->now->modify('+7 days'), + )); + $capturedRequest = null; + + $response = $this->middleware->handle( + $this->requestWithToken('valid-token'), + $this->captureNextRequest($capturedRequest), + ); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertNotNull($capturedRequest); + $this->assertSame( + $user, + $capturedRequest->attributes->get('user'), + ); + } + + private function requestWithToken(?string $token): Request + { + $request = Request::create('/anything', 'GET'); + if ($token !== null) { + $request->cookies->set(AuthMiddleware::COOKIE_NAME, $token); + } + + return $request; + } + + /** + * @param Request|null $capturedRequest + * @return Closure(Request): JsonResponse + */ + private function captureNextRequest( + ?Request &$capturedRequest, + ): Closure { + return function (Request $request) use (&$capturedRequest) { + $capturedRequest = $request; + + return new JsonResponse(['ok' => true]); + }; + } + + private function user(): User + { + return new User( + id: 7, + email: new EmailAddress('user@example.com'), + ); + } +} From 529a3529069afb49cc1986130b8126c8c37ebe7a Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 31 Jul 2026 09:53:38 +0300 Subject: [PATCH 06/11] test sessions table name --- backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php b/backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php index c9f76e2..32d170d 100644 --- a/backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php +++ b/backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php @@ -34,7 +34,7 @@ class EloquentSessionRepositoryTest extends TestCase $this->assertSame('session-token', $session->getToken()); $this->assertSame($user, $session->getUser()); - $this->assertDatabaseHas('auth_sessions', [ + $this->assertDatabaseHas('sessions', [ 'token' => 'session-token', 'user_id' => $user->getId(), ]); @@ -73,7 +73,7 @@ class EloquentSessionRepositoryTest extends TestCase $repository->deleteByToken('session-token'); $this->assertNull($repository->findByToken('session-token')); - $this->assertDatabaseMissing('auth_sessions', [ + $this->assertDatabaseMissing('sessions', [ 'token' => 'session-token', ]); } From 8bc9ef728b0109871fe09da586177a63ab957a24 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 31 Jul 2026 09:54:05 +0300 Subject: [PATCH 07/11] use sessions table --- backend/app/Auth/SessionModel.php | 2 +- ..._table.php => 2026_07_31_000000_create_sessions_table.php} | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename backend/database/migrations/{2026_07_31_000000_create_auth_sessions_table.php => 2026_07_31_000000_create_sessions_table.php} (81%) diff --git a/backend/app/Auth/SessionModel.php b/backend/app/Auth/SessionModel.php index 75e87e3..0b5092c 100644 --- a/backend/app/Auth/SessionModel.php +++ b/backend/app/Auth/SessionModel.php @@ -27,7 +27,7 @@ use Illuminate\Database\Eloquent\Model; ])] class SessionModel extends Model { - protected $table = 'auth_sessions'; + protected $table = 'sessions'; protected $primaryKey = 'token'; diff --git a/backend/database/migrations/2026_07_31_000000_create_auth_sessions_table.php b/backend/database/migrations/2026_07_31_000000_create_sessions_table.php similarity index 81% rename from backend/database/migrations/2026_07_31_000000_create_auth_sessions_table.php rename to backend/database/migrations/2026_07_31_000000_create_sessions_table.php index 995c147..687d9fb 100644 --- a/backend/database/migrations/2026_07_31_000000_create_auth_sessions_table.php +++ b/backend/database/migrations/2026_07_31_000000_create_sessions_table.php @@ -8,7 +8,7 @@ return new class extends Migration { public function up(): void { - Schema::create('auth_sessions', function (Blueprint $table): void { + Schema::create('sessions', function (Blueprint $table): void { $table->string('token', 64)->primary(); $table->foreignId('user_id') ->constrained('users') @@ -20,6 +20,6 @@ return new class extends Migration public function down(): void { - Schema::dropIfExists('auth_sessions'); + Schema::dropIfExists('sessions'); } }; From 167096b4fedaf5fbf0911465c29d36574f52679a Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 31 Jul 2026 09:55:13 +0300 Subject: [PATCH 08/11] fix auth cookie tests --- .../tests/Feature/Auth/AuthMiddlewareTest.php | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/backend/tests/Feature/Auth/AuthMiddlewareTest.php b/backend/tests/Feature/Auth/AuthMiddlewareTest.php index 5b9c034..bbc07bd 100644 --- a/backend/tests/Feature/Auth/AuthMiddlewareTest.php +++ b/backend/tests/Feature/Auth/AuthMiddlewareTest.php @@ -48,10 +48,11 @@ class AuthMiddlewareTest extends TestCase expiresAt: $this->utc('2030-08-07T12:00:00'), ); - $response = $this->withUnencryptedCookie( - AuthMiddleware::COOKIE_NAME, - 'valid-token', - )->getJson('/test/authenticated-user'); + $response = $this->withCredentials() + ->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'valid-token', + )->getJson('/test/authenticated-user'); $response->assertOk()->assertExactJson([ 'id' => $user->getId(), @@ -75,10 +76,11 @@ class AuthMiddlewareTest extends TestCase expiresAt: $this->utc('2020-08-07T12:00:00'), ); - $response = $this->withUnencryptedCookie( - AuthMiddleware::COOKIE_NAME, - 'expired-token', - )->getJson('/test/authenticated-user'); + $response = $this->withCredentials() + ->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'expired-token', + )->getJson('/test/authenticated-user'); $response->assertStatus(401); $this->assertNull( From 88a3997bada4ee477040906ce73638fa28058f79 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 31 Jul 2026 09:55:44 +0300 Subject: [PATCH 09/11] add auth middleware --- .../app/Http/Middleware/AuthMiddleware.php | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 backend/app/Http/Middleware/AuthMiddleware.php diff --git a/backend/app/Http/Middleware/AuthMiddleware.php b/backend/app/Http/Middleware/AuthMiddleware.php new file mode 100644 index 0000000..a600e36 --- /dev/null +++ b/backend/app/Http/Middleware/AuthMiddleware.php @@ -0,0 +1,51 @@ +cookie(self::COOKIE_NAME); + if (! is_string($token) || $token === '') { + return $this->unauthorized(); + } + + $session = $this->sessionRepository->findByToken($token); + if ($session === null) { + return $this->unauthorized(); + } + + if ($session->isExpired($this->clock->now())) { + $this->sessionRepository->deleteByToken($token); + + return $this->unauthorized(); + } + + $request->attributes->set('user', $session->getUser()); + + return $next($request); + } + + private function unauthorized(): JsonResponse + { + return new JsonResponse(['error' => 'unauthenticated'], 401); + } +} From ad27a0f03c2eff8e99467ccbcd419c3da2dc097e Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 31 Jul 2026 10:15:35 +0300 Subject: [PATCH 10/11] use native session dates --- backend/app/Auth/EloquentSessionRepository.php | 3 +-- backend/app/Auth/SessionModel.php | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/backend/app/Auth/EloquentSessionRepository.php b/backend/app/Auth/EloquentSessionRepository.php index 5c038d4..fd2ad3a 100644 --- a/backend/app/Auth/EloquentSessionRepository.php +++ b/backend/app/Auth/EloquentSessionRepository.php @@ -3,7 +3,6 @@ namespace App\Auth; use App\User\UserRepository; -use Carbon\CarbonInterface; use DateTimeImmutable; use DateTimeZone; @@ -55,7 +54,7 @@ class EloquentSessionRepository implements SessionRepository SessionModel::where('token', $token)->delete(); } - private function toUtc(CarbonInterface $dateTime): DateTimeImmutable + private function toUtc(DateTimeImmutable $dateTime): DateTimeImmutable { return DateTimeImmutable::createFromInterface($dateTime) ->setTimezone(new DateTimeZone('UTC')); diff --git a/backend/app/Auth/SessionModel.php b/backend/app/Auth/SessionModel.php index 0b5092c..37b30cb 100644 --- a/backend/app/Auth/SessionModel.php +++ b/backend/app/Auth/SessionModel.php @@ -2,7 +2,7 @@ namespace App\Auth; -use Carbon\CarbonInterface; +use DateTimeImmutable; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; @@ -10,8 +10,8 @@ use Illuminate\Database\Eloquent\Model; /** * @property string $token * @property int $user_id - * @property CarbonInterface $created_at - * @property CarbonInterface $expires_at + * @property DateTimeImmutable $created_at + * @property DateTimeImmutable $expires_at * * @method static Builder|SessionModel newModelQuery() * @method static Builder|SessionModel newQuery() From b3c9b0630fc3e558649394f7d8b0d35ffbe0e429 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 31 Jul 2026 10:20:37 +0300 Subject: [PATCH 11/11] freeze auth feature time --- backend/tests/Feature/Auth/AuthMiddlewareTest.php | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/backend/tests/Feature/Auth/AuthMiddlewareTest.php b/backend/tests/Feature/Auth/AuthMiddlewareTest.php index bbc07bd..8d84a1d 100644 --- a/backend/tests/Feature/Auth/AuthMiddlewareTest.php +++ b/backend/tests/Feature/Auth/AuthMiddlewareTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature\Auth; +use App\Auth\Clock; use App\Auth\CreateSessionDto; use App\Auth\SessionRepository; use App\Http\Middleware\AuthMiddleware; @@ -15,16 +16,22 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; +use Tests\Fakes\FakeClock; use Tests\TestCase; class AuthMiddlewareTest extends TestCase { use RefreshDatabase; + private DateTimeImmutable $now; + protected function setUp(): void { parent::setUp(); + $this->now = $this->utc('2026-07-31T12:00:00'); + $this->app->instance(Clock::class, new FakeClock($this->now)); + Route::middleware(AuthMiddleware::class)->get( '/test/authenticated-user', function (Request $request): JsonResponse { @@ -45,7 +52,7 @@ class AuthMiddlewareTest extends TestCase { $user = $this->createUserAndSession( token: 'valid-token', - expiresAt: $this->utc('2030-08-07T12:00:00'), + expiresAt: $this->now->modify('+7 days'), ); $response = $this->withCredentials() @@ -73,7 +80,7 @@ class AuthMiddlewareTest extends TestCase { $this->createUserAndSession( token: 'expired-token', - expiresAt: $this->utc('2020-08-07T12:00:00'), + expiresAt: $this->now->modify('-1 day'), ); $response = $this->withCredentials() @@ -98,7 +105,7 @@ class AuthMiddlewareTest extends TestCase app(SessionRepository::class)->create(new CreateSessionDto( token: $token, user: $user, - createdAt: $this->utc('2026-07-31T12:00:00'), + createdAt: $this->now, expiresAt: $expiresAt, ));