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..54926f3 --- /dev/null +++ b/backend/tests/Fakes/FakeTokenGenerator.php @@ -0,0 +1,28 @@ +callCount >= count($this->tokens)) { + throw new RuntimeException('FakeTokenGenerator exhausted'); + } + + $token = $this->tokens[$this->callCount]; + $this->callCount++; + + return $token; + } +} diff --git a/backend/tests/Fakes/FakeUserRepository.php b/backend/tests/Fakes/FakeUserRepository.php new file mode 100644 index 0000000..3672f87 --- /dev/null +++ b/backend/tests/Fakes/FakeUserRepository.php @@ -0,0 +1,56 @@ + + */ + private array $users = []; + + public function create(CreateUserDto $dto): User + { + $id = count($this->users) + 1; + $user = new User( + id: $id, + email: $dto->email, + passwordHash: $dto->passwordHash, + ); + $this->users[$id] = $user; + + return $this->copy($user); + } + + public function find(int $id): ?User + { + $user = $this->users[$id] ?? null; + + return $user === null ? null : $this->copy($user); + } + + public function findByEmail(EmailAddress $email): ?User + { + foreach ($this->users as $user) { + if ($user->getEmail()->value() === $email->value()) { + return $this->copy($user); + } + } + + return null; + } + + private function copy(User $user): User + { + return new User( + id: $user->getId(), + email: $user->getEmail(), + passwordHash: $user->getPasswordHash(), + ); + } +} diff --git a/backend/tests/Feature/Auth/AuthMiddlewareTest.php b/backend/tests/Feature/Auth/AuthMiddlewareTest.php index e9b6d25..91d7be3 100644 --- a/backend/tests/Feature/Auth/AuthMiddlewareTest.php +++ b/backend/tests/Feature/Auth/AuthMiddlewareTest.php @@ -101,7 +101,7 @@ class AuthMiddlewareTest extends TestCase ): User { $user = app(UserRepository::class)->create(new CreateUserDto( email: new EmailAddress('user@example.com'), - password: 'correct-password', + passwordHash: 'hashed-password', )); app(SessionRepository::class)->create(new CreateSessionDto( token: $token, diff --git a/backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php b/backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php index 0ce4b5f..25f9fe5 100644 --- a/backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php +++ b/backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php @@ -20,7 +20,7 @@ class EloquentSessionRepositoryTest extends TestCase { $user = app(UserRepository::class)->create(new CreateUserDto( email: new EmailAddress('user@example.com'), - password: 'correct-password', + passwordHash: 'hashed-password', )); $createdAt = $this->utc('2026-07-31T12:00:00'); $expiresAt = $this->utc('2026-08-07T12:00:00'); @@ -62,7 +62,7 @@ class EloquentSessionRepositoryTest extends TestCase { $user = app(UserRepository::class)->create(new CreateUserDto( email: new EmailAddress('user@example.com'), - password: 'correct-password', + passwordHash: 'hashed-password', )); $repository = app(SessionRepository::class); $repository->create(new CreateSessionDto( diff --git a/backend/tests/Feature/Auth/LoginEndpointTest.php b/backend/tests/Feature/Auth/LoginEndpointTest.php index ba01a77..bab28b8 100644 --- a/backend/tests/Feature/Auth/LoginEndpointTest.php +++ b/backend/tests/Feature/Auth/LoginEndpointTest.php @@ -2,162 +2,46 @@ namespace Tests\Feature\Auth; -use App\Auth\Clock; +use App\Auth\PasswordHasher; use App\Auth\SessionRepository; use App\Http\Middleware\AuthMiddleware; use App\Shared\ValueObject\EmailAddress; use App\User\CreateUserDto; -use App\User\User; use App\User\UserRepository; -use DateTimeImmutable; -use DateTimeZone; use Illuminate\Foundation\Testing\RefreshDatabase; -use Symfony\Component\HttpFoundation\Cookie; -use Tests\Fakes\FakeClock; use Tests\TestCase; class LoginEndpointTest extends TestCase { use RefreshDatabase; - private DateTimeImmutable $currentTime; - - protected function setUp(): void + public function test_login_returns_user_and_sets_session_cookie(): void { - parent::setUp(); - - $this->currentTime = new DateTimeImmutable( - '2026-07-31T12:00:00', - new DateTimeZone('UTC'), - ); - $this->app->instance( - Clock::class, - new FakeClock($this->currentTime), - ); - config()->set('session.lifetime', 120); - config()->set('session.path', '/'); - config()->set('session.secure', true); - config()->set('session.same_site', 'lax'); - } - - public function test_login_validates_its_request(): void - { - $this->postJson('/api/login') - ->assertUnprocessable() - ->assertJsonValidationErrors(['email', 'password']); - - $this->postJson('/api/login', [ - 'email' => 'invalid-email', - 'password' => 'password', - ])->assertUnprocessable() - ->assertJsonValidationErrors(['email']); - } - - public function test_login_rejects_invalid_credentials_generically(): void - { - $this->createUser(); - - $this->postJson('/api/login', [ - 'email' => 'user@example.com', - 'password' => 'wrong-password', - ])->assertUnauthorized() - ->assertExactJson(['error' => 'invalid_credentials']) - ->assertCookieMissing(AuthMiddleware::COOKIE_NAME); - - $this->postJson('/api/login', [ - 'email' => 'unknown@example.com', - 'password' => 'correct-password', - ])->assertUnauthorized() - ->assertExactJson(['error' => 'invalid_credentials']) - ->assertCookieMissing(AuthMiddleware::COOKIE_NAME); - - $this->assertDatabaseCount('sessions', 0); - } - - public function test_login_creates_a_session_and_returns_the_user(): void - { - $user = $this->createUser(); + $email = 'user@example.com'; + $password = 'correct-password'; + $passwordHash = app(PasswordHasher::class)->hash($password); + app(UserRepository::class)->create(new CreateUserDto( + email: new EmailAddress($email), + passwordHash: $passwordHash, + )); $response = $this->postJson('/api/login', [ - 'email' => ' user@EXAMPLE.COM ', - 'password' => 'correct-password', + 'email' => $email, + 'password' => $password, ]); - $response->assertOk()->assertExactJson([ - 'user' => [ - 'id' => $user->getId(), - 'email' => 'user@example.com', - ], - ]); + $response->assertOk(); + $response->assertJsonPath('user.email', $email); - $cookie = $this->findAuthCookie( - $response->headers->getCookies(), + $cookie = $response->getCookie( + AuthMiddleware::COOKIE_NAME, + false, ); - $token = $cookie->getValue(); - - $this->assertMatchesRegularExpression( - '/^[a-f0-9]{64}$/', - $token, + $this->assertNotNull($cookie); + $this->assertNotNull( + app(SessionRepository::class)->findByToken( + $cookie->getValue(), + ), ); - $this->assertTrue($cookie->isHttpOnly()); - $this->assertTrue($cookie->isSecure()); - $this->assertSame('/', $cookie->getPath()); - $this->assertSame('lax', $cookie->getSameSite()); - $this->assertSame( - $this->currentTime->modify('+120 minutes')->getTimestamp(), - $cookie->getExpiresTime(), - ); - - $session = app(SessionRepository::class)->findByToken($token); - - $this->assertNotNull($session); - $this->assertSame($user->getId(), $session->getUser()->getId()); - $this->assertEquals( - $this->currentTime, - $session->getCreatedAt(), - ); - $this->assertEquals( - $this->currentTime->modify('+120 minutes'), - $session->getExpiresAt(), - ); - } - - public function test_login_throttles_repeated_attempts(): void - { - $this->createUser(); - - for ($attempt = 1; $attempt <= 5; $attempt++) { - $this->postJson('/api/login', [ - 'email' => 'user@example.com', - 'password' => 'wrong-password', - ])->assertUnauthorized(); - } - - $this->postJson('/api/login', [ - 'email' => 'user@example.com', - 'password' => 'wrong-password', - ])->assertStatus(429); - } - - private function createUser(): User - { - return app(UserRepository::class)->create(new CreateUserDto( - email: new EmailAddress('user@example.com'), - password: 'correct-password', - )); - } - - /** - * @param array $cookies - */ - private function findAuthCookie(array $cookies): Cookie - { - foreach ($cookies as $cookie) { - if ($cookie->getName() === AuthMiddleware::COOKIE_NAME) { - return $cookie; - } - } - - $this->fail('The authentication cookie was not set.'); } } diff --git a/backend/tests/Feature/Auth/MeEndpointTest.php b/backend/tests/Feature/Auth/MeEndpointTest.php index 988fd85..3cabaf1 100644 --- a/backend/tests/Feature/Auth/MeEndpointTest.php +++ b/backend/tests/Feature/Auth/MeEndpointTest.php @@ -25,7 +25,7 @@ class MeEndpointTest extends TestCase ); $user = app(UserRepository::class)->create(new CreateUserDto( email: new EmailAddress('user@example.com'), - password: 'correct-password', + passwordHash: 'hashed-password', )); app(SessionRepository::class)->create(new CreateSessionDto( token: 'valid-token', diff --git a/backend/tests/Feature/Database/DatabaseSeederTest.php b/backend/tests/Feature/Database/DatabaseSeederTest.php index 361021d..ca1ec91 100644 --- a/backend/tests/Feature/Database/DatabaseSeederTest.php +++ b/backend/tests/Feature/Database/DatabaseSeederTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature\Database; +use App\Auth\PasswordHasher; use App\Shared\ValueObject\EmailAddress; use App\User\UserRepository; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -21,15 +22,18 @@ class DatabaseSeederTest extends TestCase ]); $this->assertDatabaseMissing('users', [ 'email' => 'user@example.com', - 'password' => 'password', + 'passwordHash' => 'password', ]); $this->assertDatabaseCount('users', 1); - $user = app(UserRepository::class)->findByCredentials( + $user = app(UserRepository::class)->findByEmail( new EmailAddress('user@example.com'), - 'password', ); $this->assertNotNull($user); + $this->assertTrue(app(PasswordHasher::class)->verify( + 'password', + $user->getPasswordHash(), + )); } } diff --git a/backend/tests/Feature/User/EloquentUserRepositoryTest.php b/backend/tests/Feature/User/EloquentUserRepositoryTest.php index 0d26dbb..5741cb5 100644 --- a/backend/tests/Feature/User/EloquentUserRepositoryTest.php +++ b/backend/tests/Feature/User/EloquentUserRepositoryTest.php @@ -17,7 +17,7 @@ class EloquentUserRepositoryTest extends TestCase $repository = app(UserRepository::class); $user = $repository->create(new CreateUserDto( email: new EmailAddress('Founder@EXAMPLE.COM'), - password: 'correct-password', + passwordHash: 'hashed-password', )); $this->assertGreaterThan(0, $user->getId()); @@ -29,9 +29,9 @@ class EloquentUserRepositoryTest extends TestCase 'id' => $user->getId(), 'email' => 'Founder@example.com', ]); - $this->assertDatabaseMissing('users', [ + $this->assertDatabaseHas('users', [ 'id' => $user->getId(), - 'password' => 'correct-password', + 'passwordHash' => 'hashed-password', ]); $foundUser = $repository->find($user->getId()); @@ -42,6 +42,10 @@ class EloquentUserRepositoryTest extends TestCase $user->getEmail()->value(), $foundUser->getEmail()->value(), ); + $this->assertSame( + $user->getPasswordHash(), + $foundUser->getPasswordHash(), + ); } public function test_it_returns_null_for_an_unknown_user(): void @@ -51,38 +55,27 @@ class EloquentUserRepositoryTest extends TestCase $this->assertNull($repository->find(999)); } - public function test_it_finds_a_user_with_matching_credentials(): void + public function test_it_finds_a_user_by_email(): void { $repository = app(UserRepository::class); $createdUser = $repository->create(new CreateUserDto( email: new EmailAddress('user@example.com'), - password: 'correct-password', + passwordHash: 'hashed-password', )); - $foundUser = $repository->findByCredentials( + $foundUser = $repository->findByEmail( new EmailAddress('user@EXAMPLE.COM'), - 'correct-password', ); $this->assertNotNull($foundUser); $this->assertSame($createdUser->getId(), $foundUser->getId()); } - public function test_it_rejects_non_matching_credentials(): void + public function test_it_returns_null_for_an_unknown_email(): void { $repository = app(UserRepository::class); - $repository->create(new CreateUserDto( - email: new EmailAddress('user@example.com'), - password: 'correct-password', - )); - - $this->assertNull($repository->findByCredentials( - new EmailAddress('user@example.com'), - 'wrong-password', - )); - $this->assertNull($repository->findByCredentials( + $this->assertNull($repository->findByEmail( new EmailAddress('unknown@example.com'), - 'correct-password', )); } } diff --git a/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php b/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php index 65908c7..d6a72a1 100644 --- a/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php +++ b/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php @@ -154,6 +154,7 @@ class AuthMiddlewareTest extends TestCase return new User( id: 7, email: new EmailAddress('user@example.com'), + passwordHash: 'hashed-password', ); } } diff --git a/backend/tests/Unit/Auth/SessionTest.php b/backend/tests/Unit/Auth/SessionTest.php index 7804333..264033c 100644 --- a/backend/tests/Unit/Auth/SessionTest.php +++ b/backend/tests/Unit/Auth/SessionTest.php @@ -16,6 +16,7 @@ class SessionTest extends TestCase $user = new User( id: 7, email: new EmailAddress('user@example.com'), + passwordHash: 'hashed-password', ); $createdAt = $this->utc('2026-07-31T12:00:00'); $expiresAt = $this->utc('2026-08-07T12:00:00'); @@ -40,6 +41,7 @@ class SessionTest extends TestCase user: new User( id: 7, email: new EmailAddress('user@example.com'), + passwordHash: 'hashed-password', ), createdAt: $this->utc('2026-07-31T12:00:00'), expiresAt: $expiresAt, diff --git a/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php b/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php new file mode 100644 index 0000000..585af22 --- /dev/null +++ b/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php @@ -0,0 +1,104 @@ +userRepository = new FakeUserRepository; + $this->passwordHasher = new FakePasswordHasher; + $this->useCase = new AuthenticateUser( + $this->userRepository, + $this->passwordHasher, + ); + } + + public function test_null_email_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('email is required'); + + $this->useCase->execute(new AuthenticateUserRequest( + email: null, + password: 'correct-password', + )); + } + + public function test_null_password_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('password is required'); + + $this->useCase->execute(new AuthenticateUserRequest( + email: 'user@example.com', + password: null, + )); + } + + public function test_unknown_email_throws_unauthorized(): void + { + $this->expectException(UnauthorizedException::class); + $this->expectExceptionMessage('invalid credentials'); + + $this->useCase->execute(new AuthenticateUserRequest( + email: 'unknown@example.com', + password: 'correct-password', + )); + } + + public function test_wrong_password_throws_unauthorized(): void + { + $this->createUser('correct-password'); + $this->expectException(UnauthorizedException::class); + $this->expectExceptionMessage('invalid credentials'); + + $this->useCase->execute(new AuthenticateUserRequest( + email: 'user@example.com', + password: 'wrong-password', + )); + } + + public function test_valid_credentials_return_user(): void + { + $user = $this->createUser('correct-password'); + + $authenticatedUser = $this->useCase->execute( + new AuthenticateUserRequest( + email: 'user@example.com', + password: 'correct-password', + ), + ); + + $this->assertSame($user->getId(), $authenticatedUser->getId()); + $this->assertSame( + $user->getEmail()->value(), + $authenticatedUser->getEmail()->value(), + ); + } + + private function createUser(string $password): User + { + return $this->userRepository->create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + passwordHash: $this->passwordHasher->hash($password), + )); + } +} diff --git a/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php b/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php new file mode 100644 index 0000000..4d85166 --- /dev/null +++ b/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php @@ -0,0 +1,70 @@ +now = new DateTimeImmutable( + '2026-07-31T12:00:00', + new DateTimeZone('UTC'), + ); + $this->sessionRepository = new FakeSessionRepository; + $this->useCase = new CreateSession( + $this->sessionRepository, + new FakeTokenGenerator(['session-token']), + new FakeClock($this->now), + ); + } + + public function test_creates_a_seven_day_session_with_generated_token(): void + { + $user = $this->user(); + + $session = $this->useCase->execute($user); + + $this->assertSame('session-token', $session->getToken()); + $this->assertSame($user, $session->getUser()); + $this->assertSame($this->now, $session->getCreatedAt()); + $this->assertEquals( + $this->now->modify('+7 days'), + $session->getExpiresAt(), + ); + } + + public function test_created_session_is_findable_by_token(): void + { + $this->useCase->execute($this->user()); + + $session = $this->sessionRepository->findByToken('session-token'); + + $this->assertNotNull($session); + $this->assertSame(7, $session->getUser()->getId()); + } + + private function user(): User + { + return new User( + id: 7, + email: new EmailAddress('user@example.com'), + passwordHash: 'hashed:correct-password', + ); + } +} diff --git a/backend/tests/Unit/Http/Controllers/AuthControllerTest.php b/backend/tests/Unit/Http/Controllers/AuthControllerTest.php new file mode 100644 index 0000000..cded75c --- /dev/null +++ b/backend/tests/Unit/Http/Controllers/AuthControllerTest.php @@ -0,0 +1,127 @@ +userRepository = new FakeUserRepository; + $this->passwordHasher = new FakePasswordHasher; + $this->sessionRepository = new FakeSessionRepository; + $authenticateUser = new AuthenticateUser( + $this->userRepository, + $this->passwordHasher, + ); + $createSession = new CreateSession( + $this->sessionRepository, + new FakeTokenGenerator(['session-token']), + new FakeClock(new DateTimeImmutable( + '2026-07-31T12:00:00', + new DateTimeZone('UTC'), + )), + ); + $this->controller = new AuthController( + $authenticateUser, + $createSession, + ); + } + + public function test_login_returns_user_and_cookie(): void + { + $this->createUser('correct-password'); + + $response = $this->controller->login(new Request([ + 'email' => 'user@example.com', + 'password' => 'correct-password', + ])); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame( + 'user@example.com', + json_decode($response->getContent(), true)['user']['email'], + ); + $cookie = $response->headers->getCookies()[0]; + $this->assertSame(AuthMiddleware::COOKIE_NAME, $cookie->getName()); + $this->assertSame('session-token', $cookie->getValue()); + $this->assertTrue($cookie->isHttpOnly()); + $this->assertSame('lax', $cookie->getSameSite()); + $this->assertNotNull( + $this->sessionRepository->findByToken('session-token'), + ); + } + + public function test_login_returns_bad_request_for_missing_email(): void + { + $response = $this->controller->login(new Request([ + 'password' => 'correct-password', + ])); + + $this->assertSame(400, $response->getStatusCode()); + $this->assertSame( + ['error' => 'email is required'], + json_decode($response->getContent(), true), + ); + } + + public function test_login_returns_bad_request_for_missing_password(): void + { + $response = $this->controller->login(new Request([ + 'email' => 'user@example.com', + ])); + + $this->assertSame(400, $response->getStatusCode()); + $this->assertSame( + ['error' => 'password is required'], + json_decode($response->getContent(), true), + ); + } + + public function test_login_returns_unauthorized_for_invalid_credentials(): void + { + $this->createUser('correct-password'); + + $response = $this->controller->login(new Request([ + 'email' => 'user@example.com', + 'password' => 'wrong-password', + ])); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertSame( + ['error' => 'invalid credentials'], + json_decode($response->getContent(), true), + ); + } + + private function createUser(string $password): void + { + $this->userRepository->create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + passwordHash: $this->passwordHasher->hash($password), + )); + } +} diff --git a/backend/tests/Unit/User/UserTest.php b/backend/tests/Unit/User/UserTest.php index 0ac8d53..b3c6873 100644 --- a/backend/tests/Unit/User/UserTest.php +++ b/backend/tests/Unit/User/UserTest.php @@ -8,12 +8,17 @@ use PHPUnit\Framework\TestCase; class UserTest extends TestCase { - public function test_it_exposes_its_identity_and_email(): void + public function test_it_exposes_its_identity_email_and_password_hash(): void { $email = new EmailAddress('user@example.com'); - $user = new User(id: 42, email: $email); + $user = new User( + id: 42, + email: $email, + passwordHash: 'hashed-password', + ); $this->assertSame(42, $user->getId()); $this->assertSame($email, $user->getEmail()); + $this->assertSame('hashed-password', $user->getPasswordHash()); } }