From 533320fcacb6872b23dafc155fa8eae5101b11fc Mon Sep 17 00:00:00 2001 From: yisroel Date: Wed, 6 May 2026 15:00:49 +0300 Subject: [PATCH 01/15] add User entity, dto, repository interface User holds email (EmailAddress vo), passwordHash, isAdmin - tide keeps password and admin flag on the user row directly (no separate profile entity like youngstartup). UserRepository exposes find, findByEmail, create. CreateUserDto is readonly with explicit isAdmin (per shared.md no-default-args rule). --- backend/app/User/CreateUserDto.php | 14 ++++++++++++ backend/app/User/User.php | 35 +++++++++++++++++++++++++++++ backend/app/User/UserRepository.php | 14 ++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 backend/app/User/CreateUserDto.php create mode 100644 backend/app/User/User.php create mode 100644 backend/app/User/UserRepository.php diff --git a/backend/app/User/CreateUserDto.php b/backend/app/User/CreateUserDto.php new file mode 100644 index 0000000..7c353e5 --- /dev/null +++ b/backend/app/User/CreateUserDto.php @@ -0,0 +1,14 @@ +id; + } + + public function getEmail(): EmailAddress + { + return $this->email; + } + + public function getPasswordHash(): string + { + return $this->passwordHash; + } + + public function isAdmin(): bool + { + return $this->isAdmin; + } +} diff --git a/backend/app/User/UserRepository.php b/backend/app/User/UserRepository.php new file mode 100644 index 0000000..4805f3f --- /dev/null +++ b/backend/app/User/UserRepository.php @@ -0,0 +1,14 @@ + Date: Wed, 6 May 2026 15:10:21 +0300 Subject: [PATCH 02/15] add User persistence: model, migration, eloquent + fake repo UserModel maps users table (id, email unique, password_hash, is_admin bool default false). EloquentUserRepository implements UserRepository: create from CreateUserDto, find by id, findByEmail. toDomain() materializes a User entity wrapping email in EmailAddress vo. FakeUserRepository: in-memory map keyed by auto-incrementing id, returns defensive copies on read (per youngstartup pattern). composer stan script now passes --no-progress for cleaner ci output. --- backend/app/User/EloquentUserRepository.php | 43 ++++++++++++ backend/app/User/UserModel.php | 38 +++++++++++ backend/composer.json | 2 +- .../2026_05_06_000000_create_users_table.php | 23 +++++++ backend/tests/Fakes/FakeUserRepository.php | 66 +++++++++++++++++++ 5 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 backend/app/User/EloquentUserRepository.php create mode 100644 backend/app/User/UserModel.php create mode 100644 backend/database/migrations/2026_05_06_000000_create_users_table.php create mode 100644 backend/tests/Fakes/FakeUserRepository.php diff --git a/backend/app/User/EloquentUserRepository.php b/backend/app/User/EloquentUserRepository.php new file mode 100644 index 0000000..f5887ea --- /dev/null +++ b/backend/app/User/EloquentUserRepository.php @@ -0,0 +1,43 @@ + $dto->email->value(), + 'password_hash' => $dto->passwordHash, + 'is_admin' => $dto->isAdmin, + ]); + + return $this->toDomain($model); + } + + public function find(int $id): ?User + { + $model = UserModel::find($id); + + return $model === null ? null : $this->toDomain($model); + } + + public function findByEmail(EmailAddress $email): ?User + { + $model = UserModel::where('email', $email->value())->first(); + + 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, + isAdmin: $model->is_admin, + ); + } +} diff --git a/backend/app/User/UserModel.php b/backend/app/User/UserModel.php new file mode 100644 index 0000000..767ac29 --- /dev/null +++ b/backend/app/User/UserModel.php @@ -0,0 +1,38 @@ +|UserModel newModelQuery() + * @method static Builder|UserModel newQuery() + * @method static Builder|UserModel query() + * @method static Builder|UserModel whereId($value) + * @method static Builder|UserModel whereEmail($value) + * @method static Builder|UserModel whereIsAdmin($value) + * + * @mixin \Eloquent + */ +class UserModel extends Model +{ + protected $table = 'users'; + + public $timestamps = false; + + protected $fillable = [ + 'email', + 'password_hash', + 'is_admin', + ]; + + protected $casts = [ + 'is_admin' => 'boolean', + ]; +} diff --git a/backend/composer.json b/backend/composer.json index 93db624..abe76eb 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -49,7 +49,7 @@ "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" --names=server,queue,logs --kill-others" ], - "stan": "phpstan analyse", + "stan": "phpstan analyse --no-progress", "cs:fix": "php-cs-fixer fix", "cs:check": "php-cs-fixer check --diff -vvv", diff --git a/backend/database/migrations/2026_05_06_000000_create_users_table.php b/backend/database/migrations/2026_05_06_000000_create_users_table.php new file mode 100644 index 0000000..45e4086 --- /dev/null +++ b/backend/database/migrations/2026_05_06_000000_create_users_table.php @@ -0,0 +1,23 @@ +id(); + $table->string('email')->unique(); + $table->string('password_hash'); + $table->boolean('is_admin')->default(false); + }); + } + + public function down(): void + { + Schema::dropIfExists('users'); + } +}; diff --git a/backend/tests/Fakes/FakeUserRepository.php b/backend/tests/Fakes/FakeUserRepository.php new file mode 100644 index 0000000..3ad23bd --- /dev/null +++ b/backend/tests/Fakes/FakeUserRepository.php @@ -0,0 +1,66 @@ +getNextId(); + $user = new User( + id: $id, + email: $dto->email, + passwordHash: $dto->passwordHash, + isAdmin: $dto->isAdmin, + ); + $this->existingUsers[$id] = $user; + + return $user; + } + + public function find(int $id): ?User + { + $user = $this->existingUsers[$id] ?? null; + if ($user === null) { + return null; + } + + return new User( + id: $user->getId(), + email: $user->getEmail(), + passwordHash: $user->getPasswordHash(), + isAdmin: $user->isAdmin(), + ); + } + + public function findByEmail(EmailAddress $email): ?User + { + foreach ($this->existingUsers as $user) { + if ($user->getEmail()->equals($email)) { + return new User( + id: $user->getId(), + email: $user->getEmail(), + passwordHash: $user->getPasswordHash(), + isAdmin: $user->isAdmin(), + ); + } + } + + return null; + } + + private function getNextId(): int + { + return count($this->existingUsers) + 1; + } +} From bb38e544ee2de299a31a104a5d0dcb347c8d2668 Mon Sep 17 00:00:00 2001 From: yisroel Date: Wed, 6 May 2026 15:11:19 +0300 Subject: [PATCH 03/15] add auth utility interfaces and impls Clock + SystemClock (DateTimeImmutable in UTC), TokenGenerator + RandomTokenGenerator (bin2hex(random_bytes(32)) -> 64-char hex), PasswordHasher + BcryptPasswordHasher (password_hash with PASSWORD_DEFAULT, password_verify). matching fakes: FakeClock with mutable setTime, FakeTokenGenerator with a pre-seeded queue (throws once exhausted), FakePasswordHasher returns 'hashed:' for deterministic test assertions. composer stan now passes --memory-limit=512M (default 128M overflows once larastan loads more rules). --- backend/app/Auth/BcryptPasswordHasher.php | 16 +++++++++++++ backend/app/Auth/Clock.php | 10 ++++++++ backend/app/Auth/PasswordHasher.php | 10 ++++++++ backend/app/Auth/RandomTokenGenerator.php | 11 +++++++++ backend/app/Auth/SystemClock.php | 14 +++++++++++ backend/app/Auth/TokenGenerator.php | 8 +++++++ backend/composer.json | 2 +- backend/tests/Fakes/FakeClock.php | 21 +++++++++++++++++ backend/tests/Fakes/FakePasswordHasher.php | 18 +++++++++++++++ backend/tests/Fakes/FakeTokenGenerator.php | 27 ++++++++++++++++++++++ 10 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 backend/app/Auth/BcryptPasswordHasher.php create mode 100644 backend/app/Auth/Clock.php create mode 100644 backend/app/Auth/PasswordHasher.php create mode 100644 backend/app/Auth/RandomTokenGenerator.php create mode 100644 backend/app/Auth/SystemClock.php create mode 100644 backend/app/Auth/TokenGenerator.php create mode 100644 backend/tests/Fakes/FakeClock.php create mode 100644 backend/tests/Fakes/FakePasswordHasher.php create mode 100644 backend/tests/Fakes/FakeTokenGenerator.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 @@ +currentTime; + } + + public function setTime(DateTimeImmutable $newTime): void + { + $this->currentTime = $newTime; + } +} diff --git a/backend/tests/Fakes/FakePasswordHasher.php b/backend/tests/Fakes/FakePasswordHasher.php new file mode 100644 index 0000000..9e93325 --- /dev/null +++ b/backend/tests/Fakes/FakePasswordHasher.php @@ -0,0 +1,18 @@ +hash($password) === $hash; + } +} diff --git a/backend/tests/Fakes/FakeTokenGenerator.php b/backend/tests/Fakes/FakeTokenGenerator.php new file mode 100644 index 0000000..601eb2f --- /dev/null +++ b/backend/tests/Fakes/FakeTokenGenerator.php @@ -0,0 +1,27 @@ +callCount >= count($this->tokens)) { + throw new RuntimeException('FakeTokenGenerator exhausted'); + } + $token = $this->tokens[$this->callCount]; + $this->callCount++; + + return $token; + } +} From 05f935f27598590d6408fc72a80013661d4d91c2 Mon Sep 17 00:00:00 2001 From: yisroel Date: Wed, 6 May 2026 15:12:07 +0300 Subject: [PATCH 04/15] add Session entity, persistence, fake Session: immutable holder of token, owning User, createdAt, expiresAt. isExpired(now) compares >= expiresAt. SessionModel keys on token (string primary, non-incrementing). migration adds sessions table with foreign user_id (cascade on user delete) and indexed expires_at for cleanup queries. EloquentSessionRepository takes UserRepository to rehydrate the owning User on findByToken; sessions for deleted users return null. FakeSessionRepository mirrors with an in-memory map keyed by token, defensive copies on read. --- backend/app/Auth/CreateSessionDto.php | 16 +++++ .../app/Auth/EloquentSessionRepository.php | 60 +++++++++++++++++++ backend/app/Auth/Session.php | 41 +++++++++++++ backend/app/Auth/SessionModel.php | 44 ++++++++++++++ backend/app/Auth/SessionRepository.php | 12 ++++ ...026_05_06_000001_create_sessions_table.php | 25 ++++++++ backend/tests/Fakes/FakeSessionRepository.php | 48 +++++++++++++++ 7 files changed, 246 insertions(+) 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/database/migrations/2026_05_06_000001_create_sessions_table.php create mode 100644 backend/tests/Fakes/FakeSessionRepository.php diff --git a/backend/app/Auth/CreateSessionDto.php b/backend/app/Auth/CreateSessionDto.php new file mode 100644 index 0000000..2e5e2f7 --- /dev/null +++ b/backend/app/Auth/CreateSessionDto.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/Session.php b/backend/app/Auth/Session.php new file mode 100644 index 0000000..b433114 --- /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..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 @@ +string('token')->primary(); + $table->foreignId('user_id') + ->constrained('users') + ->cascadeOnDelete(); + $table->dateTime('created_at'); + $table->dateTime('expires_at')->index(); + }); + } + + public function down(): void + { + Schema::dropIfExists('sessions'); + } +}; diff --git a/backend/tests/Fakes/FakeSessionRepository.php b/backend/tests/Fakes/FakeSessionRepository.php new file mode 100644 index 0000000..b110a1d --- /dev/null +++ b/backend/tests/Fakes/FakeSessionRepository.php @@ -0,0 +1,48 @@ +token, + user: $dto->user, + createdAt: $dto->createdAt, + expiresAt: $dto->expiresAt, + ); + $this->sessions[$dto->token] = $session; + + return $session; + } + + public function findByToken(string $token): ?Session + { + $session = $this->sessions[$token] ?? null; + if ($session === null) { + return null; + } + + return new Session( + token: $session->getToken(), + user: $session->getUser(), + createdAt: $session->getCreatedAt(), + expiresAt: $session->getExpiresAt(), + ); + } + + public function deleteByToken(string $token): void + { + unset($this->sessions[$token]); + } +} From fefc992431fe88dfbf2667895185a2ced7d41842 Mon Sep 17 00:00:00 2001 From: yisroel Date: Wed, 6 May 2026 15:12:52 +0300 Subject: [PATCH 05/15] test SignupUser use case 9 cases: null/empty/malformed email -> BadRequest; null or sub-8-char password -> BadRequest; duplicate email -> DomainException; valid signup returns User with hashed password and isAdmin=false; user is findable by email afterwards; EmailAddress vo lowercases the domain. fails red - SignupUser class not yet defined. --- .../Unit/User/UseCases/SignupUserTest.php | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 backend/tests/Unit/User/UseCases/SignupUserTest.php diff --git a/backend/tests/Unit/User/UseCases/SignupUserTest.php b/backend/tests/Unit/User/UseCases/SignupUserTest.php new file mode 100644 index 0000000..1ae841e --- /dev/null +++ b/backend/tests/Unit/User/UseCases/SignupUserTest.php @@ -0,0 +1,133 @@ +userRepo = new FakeUserRepository; + $this->hasher = new FakePasswordHasher; + $this->useCase = new SignupUser( + $this->userRepo, + $this->hasher, + ); + } + + public function test_null_email_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new SignupUserRequest( + email: null, + password: 'longenoughpassword', + )); + } + + public function test_empty_email_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new SignupUserRequest( + email: '', + password: 'longenoughpassword', + )); + } + + public function test_invalid_email_format_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new SignupUserRequest( + email: 'not-an-email', + password: 'longenoughpassword', + )); + } + + public function test_null_password_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new SignupUserRequest( + email: 'user@example.com', + password: null, + )); + } + + public function test_short_password_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new SignupUserRequest( + email: 'user@example.com', + password: 'short', + )); + } + + public function test_duplicate_email_throws_domain_exception(): void + { + $this->userRepo->create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + passwordHash: $this->hasher->hash('original-password'), + isAdmin: false, + )); + + $this->expectException(DomainException::class); + $this->useCase->execute(new SignupUserRequest( + email: 'user@example.com', + password: 'second-attempt-password', + )); + } + + public function test_valid_signup_returns_user_with_hashed_password(): void + { + $created = $this->useCase->execute(new SignupUserRequest( + email: 'new@example.com', + password: 'longenoughpassword', + )); + + $this->assertInstanceOf(User::class, $created); + $this->assertSame('new@example.com', $created->getEmail()->value()); + $this->assertSame( + $this->hasher->hash('longenoughpassword'), + $created->getPasswordHash(), + ); + $this->assertFalse($created->isAdmin()); + } + + public function test_created_user_is_findable_by_email(): void + { + $created = $this->useCase->execute(new SignupUserRequest( + email: 'lookup@example.com', + password: 'longenoughpassword', + )); + + $found = $this->userRepo->findByEmail( + new EmailAddress('lookup@example.com') + ); + $this->assertNotNull($found); + $this->assertSame($created->getId(), $found->getId()); + } + + public function test_signup_normalizes_email_domain(): void + { + $created = $this->useCase->execute(new SignupUserRequest( + email: 'Mixed@CASE.com', + password: 'longenoughpassword', + )); + + $this->assertSame('Mixed@case.com', $created->getEmail()->value()); + } +} From a108b29d199d7f5025bb3bb475f2270bb3380fd8 Mon Sep 17 00:00:00 2001 From: yisroel Date: Wed, 6 May 2026 15:13:26 +0300 Subject: [PATCH 06/15] implement SignupUser use case validates email present + format (wraps EmailAddress vo's InvalidArgumentException as BadRequest), password present + >= 8 chars, then ensures email not already registered. hashes password through injected PasswordHasher and persists via UserRepository->create with isAdmin=false (admins are seeder- only per plan). throws DomainException on duplicate email so the controller layer can map it to 409. all 18 tests pass. --- .../User/UseCases/SignupUser/SignupUser.php | 57 +++++++++++++++++++ .../UseCases/SignupUser/SignupUserRequest.php | 11 ++++ 2 files changed, 68 insertions(+) create mode 100644 backend/app/User/UseCases/SignupUser/SignupUser.php create mode 100644 backend/app/User/UseCases/SignupUser/SignupUserRequest.php diff --git a/backend/app/User/UseCases/SignupUser/SignupUser.php b/backend/app/User/UseCases/SignupUser/SignupUser.php new file mode 100644 index 0000000..7f2c78e --- /dev/null +++ b/backend/app/User/UseCases/SignupUser/SignupUser.php @@ -0,0 +1,57 @@ +email === null || $request->email === '') { + throw new BadRequestException('email 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' + ); + } + + try { + $email = new EmailAddress($request->email); + } catch (InvalidArgumentException $exception) { + throw new BadRequestException($exception->getMessage()); + } + + if ($this->userRepo->findByEmail($email) !== null) { + throw new DomainException('email already registered'); + } + + return $this->userRepo->create(new CreateUserDto( + email: $email, + passwordHash: $this->hasher->hash($request->password), + isAdmin: false, + )); + } +} diff --git a/backend/app/User/UseCases/SignupUser/SignupUserRequest.php b/backend/app/User/UseCases/SignupUser/SignupUserRequest.php new file mode 100644 index 0000000..b6f809f --- /dev/null +++ b/backend/app/User/UseCases/SignupUser/SignupUserRequest.php @@ -0,0 +1,11 @@ + Date: Wed, 6 May 2026 15:14:03 +0300 Subject: [PATCH 07/15] test AuthenticateUser use case 9 cases: null/empty/malformed email -> BadRequest; null/empty password -> BadRequest; unknown email -> Unauthorized; wrong password -> Unauthorized; valid creds return the User entity; isAdmin flag survives the auth round-trip. fails red - the AuthenticateUser class does not exist yet. --- .../Auth/UseCases/AuthenticateUserTest.php | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php diff --git a/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php b/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php new file mode 100644 index 0000000..6d00862 --- /dev/null +++ b/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php @@ -0,0 +1,156 @@ +userRepo = new FakeUserRepository; + $this->hasher = new FakePasswordHasher; + $this->useCase = new AuthenticateUser( + $this->userRepo, + $this->hasher, + ); + } + + private function seedUser( + string $email, + string $password, + bool $isAdmin, + ): User { + return $this->userRepo->create(new CreateUserDto( + email: new EmailAddress($email), + passwordHash: $this->hasher->hash($password), + isAdmin: $isAdmin, + )); + } + + public function test_null_email_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new AuthenticateUserRequest( + email: null, + password: 'correctpassword', + )); + } + + public function test_empty_email_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new AuthenticateUserRequest( + email: '', + password: 'correctpassword', + )); + } + + public function test_null_password_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new AuthenticateUserRequest( + email: 'user@example.com', + password: null, + )); + } + + public function test_empty_password_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new AuthenticateUserRequest( + email: 'user@example.com', + password: '', + )); + } + + public function test_malformed_email_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new AuthenticateUserRequest( + email: 'not-an-email', + password: 'correctpassword', + )); + } + + public function test_unknown_email_throws_unauthorized(): void + { + $this->expectException(UnauthorizedException::class); + $this->useCase->execute(new AuthenticateUserRequest( + email: 'nobody@example.com', + password: 'correctpassword', + )); + } + + public function test_wrong_password_throws_unauthorized(): void + { + $this->seedUser( + email: 'user@example.com', + password: 'correctpassword', + isAdmin: false, + ); + + $this->expectException(UnauthorizedException::class); + $this->useCase->execute(new AuthenticateUserRequest( + email: 'user@example.com', + password: 'wrongpassword', + )); + } + + public function test_valid_credentials_return_user(): void + { + $seeded = $this->seedUser( + email: 'user@example.com', + password: 'correctpassword', + isAdmin: false, + ); + + $authenticated = $this->useCase->execute( + new AuthenticateUserRequest( + email: 'user@example.com', + password: 'correctpassword', + ) + ); + + $this->assertInstanceOf(User::class, $authenticated); + $this->assertSame($seeded->getId(), $authenticated->getId()); + $this->assertSame( + 'user@example.com', + $authenticated->getEmail()->value(), + ); + $this->assertFalse($authenticated->isAdmin()); + } + + public function test_admin_flag_is_preserved_on_authentication(): void + { + $this->seedUser( + email: 'admin@example.com', + password: 'adminpassword', + isAdmin: true, + ); + + $authenticated = $this->useCase->execute( + new AuthenticateUserRequest( + email: 'admin@example.com', + password: 'adminpassword', + ) + ); + + $this->assertTrue($authenticated->isAdmin()); + } +} From 5b74e9d76ab58e3f9d7799c349bfd0e3d3f69bb8 Mon Sep 17 00:00:00 2001 From: yisroel Date: Wed, 6 May 2026 15:14:34 +0300 Subject: [PATCH 08/15] implement AuthenticateUser use case input validation: email + password required. constructs EmailAddress vo (BadRequest on bad format). looks up user; absent or password-mismatch -> UnauthorizedException with constant 'invalid credentials' message (no enumeration leak). password verified through PasswordHasher->verify against stored hash on the User entity (no separate profile lookup -> tide keeps password on the user row). returns the User entity for the caller (typically CreateSession + AuthController). 27 tests pass. --- .../AuthenticateUser/AuthenticateUser.php | 54 +++++++++++++++++++ .../AuthenticateUserRequest.php | 11 ++++ 2 files changed, 65 insertions(+) create mode 100644 backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUser.php create mode 100644 backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUserRequest.php diff --git a/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUser.php b/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUser.php new file mode 100644 index 0000000..a9a16ca --- /dev/null +++ b/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUser.php @@ -0,0 +1,54 @@ +email === null || $request->email === '') { + throw new BadRequestException('email is required'); + } + if ($request->password === null || $request->password === '') { + throw new BadRequestException('password is required'); + } + + try { + $email = new EmailAddress($request->email); + } catch (InvalidArgumentException $exception) { + throw new BadRequestException($exception->getMessage()); + } + + $user = $this->userRepo->findByEmail($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 @@ + Date: Wed, 6 May 2026 15:15:04 +0300 Subject: [PATCH 09/15] test CreateSession use case 4 cases: returns Session with the generated token + supplied user; createdAt matches injected Clock now; expiresAt is now+7d; session is findable via SessionRepository->findByToken. fails red - CreateSession class missing. --- .../Unit/Auth/UseCases/CreateSessionTest.php | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 backend/tests/Unit/Auth/UseCases/CreateSessionTest.php diff --git a/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php b/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php new file mode 100644 index 0000000..d5460b5 --- /dev/null +++ b/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php @@ -0,0 +1,86 @@ +now = new DateTimeImmutable( + '2026-05-06T12:00:00', + new DateTimeZone('UTC') + ); + $this->sessionRepo = new FakeSessionRepository; + $this->tokenGenerator = new FakeTokenGenerator(['token-abc']); + $this->clock = new FakeClock($this->now); + $this->useCase = new CreateSession( + $this->sessionRepo, + $this->tokenGenerator, + $this->clock, + ); + } + + private function makeUser(): User + { + return new User( + id: 7, + email: new EmailAddress('user@example.com'), + passwordHash: 'hashed:irrelevant', + isAdmin: false, + ); + } + + public function test_creates_session_with_generated_token(): void + { + $session = $this->useCase->execute($this->makeUser()); + + $this->assertInstanceOf(Session::class, $session); + $this->assertSame('token-abc', $session->getToken()); + $this->assertSame(7, $session->getUser()->getId()); + } + + public function test_session_created_at_is_clock_now(): void + { + $session = $this->useCase->execute($this->makeUser()); + + $this->assertEquals($this->now, $session->getCreatedAt()); + } + + public function test_session_expires_seven_days_from_now(): void + { + $session = $this->useCase->execute($this->makeUser()); + + $expected = $this->now->modify('+7 days'); + $this->assertEquals($expected, $session->getExpiresAt()); + } + + public function test_session_is_findable_by_token(): void + { + $this->useCase->execute($this->makeUser()); + + $found = $this->sessionRepo->findByToken('token-abc'); + $this->assertNotNull($found); + $this->assertSame(7, $found->getUser()->getId()); + } +} From 0697e4af69619eff345598c9a23933ed8b38a2fd Mon Sep 17 00:00:00 2001 From: yisroel Date: Wed, 6 May 2026 15:15:25 +0300 Subject: [PATCH 10/15] implement CreateSession use case generates token via injected TokenGenerator, asks Clock for now, sets expiry to now+7d, persists through SessionRepository->create and returns the resulting Session. all 31 tests pass. --- .../UseCases/CreateSession/CreateSession.php | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 backend/app/Auth/UseCases/CreateSession/CreateSession.php diff --git a/backend/app/Auth/UseCases/CreateSession/CreateSession.php b/backend/app/Auth/UseCases/CreateSession/CreateSession.php new file mode 100644 index 0000000..db6403f --- /dev/null +++ b/backend/app/Auth/UseCases/CreateSession/CreateSession.php @@ -0,0 +1,34 @@ +clock->now(); + $expiresAt = $now->modify(self::SESSION_LIFETIME); + + return $this->sessionRepo->create(new CreateSessionDto( + token: $this->tokenGenerator->generate(), + user: $user, + createdAt: $now, + expiresAt: $expiresAt, + )); + } +} From 30e97864c8cc0e4b2d1d3bcc9b8a4aecb2847b84 Mon Sep 17 00:00:00 2001 From: yisroel Date: Wed, 6 May 2026 15:15:46 +0300 Subject: [PATCH 11/15] test Logout use case 2 cases: existing token's session gets removed; unknown token is a no-op (deleteByToken stays idempotent). --- .../tests/Unit/Auth/UseCases/LogoutTest.php | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 backend/tests/Unit/Auth/UseCases/LogoutTest.php diff --git a/backend/tests/Unit/Auth/UseCases/LogoutTest.php b/backend/tests/Unit/Auth/UseCases/LogoutTest.php new file mode 100644 index 0000000..c98c582 --- /dev/null +++ b/backend/tests/Unit/Auth/UseCases/LogoutTest.php @@ -0,0 +1,58 @@ +now = new DateTimeImmutable( + '2026-05-06T12:00:00', + new DateTimeZone('UTC') + ); + $this->sessionRepo = new FakeSessionRepository; + $this->useCase = new Logout($this->sessionRepo); + } + + public function test_existing_token_session_is_removed(): void + { + $user = new User( + id: 7, + email: new EmailAddress('user@example.com'), + passwordHash: 'hashed:irrelevant', + isAdmin: false, + ); + $this->sessionRepo->create(new CreateSessionDto( + token: 'token-abc', + user: $user, + createdAt: $this->now, + expiresAt: $this->now->modify('+7 days'), + )); + + $this->useCase->execute('token-abc'); + + $this->assertNull($this->sessionRepo->findByToken('token-abc')); + } + + public function test_unknown_token_does_not_throw(): void + { + $this->useCase->execute('unknown-token'); + + $this->assertNull($this->sessionRepo->findByToken('unknown-token')); + } +} From 526a1b8f6170d063c4387a6f7c06b90dfb613209 Mon Sep 17 00:00:00 2001 From: yisroel Date: Wed, 6 May 2026 15:16:03 +0300 Subject: [PATCH 12/15] implement Logout use case thin pass-through to SessionRepository->deleteByToken. test green; 33 tests pass. --- backend/app/Auth/UseCases/Logout/Logout.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 backend/app/Auth/UseCases/Logout/Logout.php 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); + } +} From d87215ff9b9cd1a7cef9cf498dee5e857cb01fec Mon Sep 17 00:00:00 2001 From: yisroel Date: Wed, 6 May 2026 15:16:35 +0300 Subject: [PATCH 13/15] test AuthMiddleware 4 cases: missing auth_token cookie -> 401 json {error: unauthenticated}; unknown token -> 401; expired token -> 401 + repo cleanup; valid token -> 200 with the User attached to request->attributes['user']. fails red - middleware class absent. --- .../Auth/Middleware/AuthMiddlewareTest.php | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php diff --git a/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php b/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php new file mode 100644 index 0000000..7f844c9 --- /dev/null +++ b/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php @@ -0,0 +1,142 @@ +now = new DateTimeImmutable( + '2026-05-06T12:00:00', + new DateTimeZone('UTC') + ); + $this->sessionRepo = new FakeSessionRepository; + $this->clock = new FakeClock($this->now); + $this->middleware = new AuthMiddleware( + $this->sessionRepo, + $this->clock, + ); + } + + private function makeRequest(?string $token): Request + { + $request = Request::create('/api/anything', 'POST'); + if ($token !== null) { + $request->cookies->set('auth_token', $token); + } + + return $request; + } + + private function nextThatRecords(?Request &$captured): Closure + { + return function (Request $request) use (&$captured) { + $captured = $request; + + return new JsonResponse(['ok' => true], 200); + }; + } + + private function makeUser(int $id = 7): User + { + return new User( + id: $id, + email: new EmailAddress('user@example.com'), + passwordHash: 'hashed:irrelevant', + isAdmin: false, + ); + } + + public function test_missing_cookie_returns_unauthorized_json(): void + { + $captured = null; + $response = $this->middleware->handle( + $this->makeRequest(null), + $this->nextThatRecords($captured), + ); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertSame( + ['error' => 'unauthenticated'], + json_decode($response->getContent(), true), + ); + $this->assertNull($captured); + } + + public function test_unknown_token_returns_unauthorized(): void + { + $captured = null; + $response = $this->middleware->handle( + $this->makeRequest('does-not-exist'), + $this->nextThatRecords($captured), + ); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertNull($captured); + } + + public function test_expired_session_returns_unauthorized_and_is_deleted(): void + { + $this->sessionRepo->create(new CreateSessionDto( + token: 'expired-token', + user: $this->makeUser(), + createdAt: $this->now->modify('-8 days'), + expiresAt: $this->now->modify('-1 day'), + )); + + $captured = null; + $response = $this->middleware->handle( + $this->makeRequest('expired-token'), + $this->nextThatRecords($captured), + ); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertNull($captured); + $this->assertNull( + $this->sessionRepo->findByToken('expired-token') + ); + } + + public function test_valid_session_attaches_user_and_calls_next(): void + { + $this->sessionRepo->create(new CreateSessionDto( + token: 'valid-token', + user: $this->makeUser(id: 7), + createdAt: $this->now, + expiresAt: $this->now->modify('+7 days'), + )); + + $captured = null; + $response = $this->middleware->handle( + $this->makeRequest('valid-token'), + $this->nextThatRecords($captured), + ); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertNotNull($captured); + $attachedUser = $captured->attributes->get('user'); + $this->assertInstanceOf(User::class, $attachedUser); + $this->assertSame(7, $attachedUser->getId()); + } +} From ca8a2066de7da78353d7bc779ef576538d2e8814 Mon Sep 17 00:00:00 2001 From: yisroel Date: Wed, 6 May 2026 15:16:59 +0300 Subject: [PATCH 14/15] implement AuthMiddleware reads auth_token cookie (constant COOKIE_NAME for cross-layer sharing with the AuthController). missing/empty cookie or unknown token -> 401 json {error: unauthenticated}. expired session is deleted then 401 returned. valid session attaches the User entity to request attributes under 'user' so downstream controllers can read it via request attributes. 37 tests pass. --- .../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..8e21ece --- /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->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); + } +} From 2e3265e5680bd645f5ba222f83244b2aec0af823 Mon Sep 17 00:00:00 2001 From: yisroel Date: Wed, 6 May 2026 15:18:06 +0300 Subject: [PATCH 15/15] wire repository and utility bindings AppServiceProvider binds Clock -> SystemClock, TokenGenerator -> RandomTokenGenerator, PasswordHasher -> BcryptPasswordHasher. new RepositoryServiceProvider binds UserRepository -> EloquentUserRepository, SessionRepository -> EloquentSessionRepository. bootstrap/providers.php registers both. verified container resolves: app(Clock::class) returns SystemClock. migrations create users + sessions tables with proper unique/foreign-key/index constraints (sqlite roundtrip confirmed). --- backend/app/Providers/AppServiceProvider.php | 16 +++++++------ .../Providers/RepositoryServiceProvider.php | 24 +++++++++++++++++++ backend/bootstrap/providers.php | 2 ++ 3 files changed, 35 insertions(+), 7 deletions(-) create mode 100644 backend/app/Providers/RepositoryServiceProvider.php diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index 452e6b6..5b73916 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -2,21 +2,23 @@ namespace App\Providers; +use App\Auth\BcryptPasswordHasher; +use App\Auth\Clock; +use App\Auth\PasswordHasher; +use App\Auth\RandomTokenGenerator; +use App\Auth\SystemClock; +use App\Auth\TokenGenerator; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider { - /** - * Register any application services. - */ public function register(): void { - // + $this->app->bind(Clock::class, SystemClock::class); + $this->app->bind(TokenGenerator::class, RandomTokenGenerator::class); + $this->app->bind(PasswordHasher::class, BcryptPasswordHasher::class); } - /** - * Bootstrap any application services. - */ public function boot(): void { // diff --git a/backend/app/Providers/RepositoryServiceProvider.php b/backend/app/Providers/RepositoryServiceProvider.php new file mode 100644 index 0000000..0671b59 --- /dev/null +++ b/backend/app/Providers/RepositoryServiceProvider.php @@ -0,0 +1,24 @@ +app->bind( + UserRepository::class, + EloquentUserRepository::class, + ); + $this->app->bind( + SessionRepository::class, + EloquentSessionRepository::class, + ); + } +} diff --git a/backend/bootstrap/providers.php b/backend/bootstrap/providers.php index fc94ae6..37b94e7 100644 --- a/backend/bootstrap/providers.php +++ b/backend/bootstrap/providers.php @@ -1,7 +1,9 @@