test auth contracts

This commit is contained in:
Yisroel Baum 2026-08-02 20:53:15 +03:00
parent 3da9c586c3
commit b3266b38c8
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
15 changed files with 457 additions and 165 deletions

View file

@ -0,0 +1,18 @@
<?php
namespace Tests\Fakes;
use App\Auth\PasswordHasher;
class FakePasswordHasher implements PasswordHasher
{
public function hash(string $password): string
{
return 'hashed:'.$password;
}
public function verify(string $password, string $hash): bool
{
return $this->hash($password) === $hash;
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace Tests\Fakes;
use App\Auth\TokenGenerator;
use RuntimeException;
class FakeTokenGenerator implements TokenGenerator
{
private int $callCount = 0;
/**
* @param string[] $tokens
*/
public function __construct(private array $tokens) {}
public function generate(): string
{
if ($this->callCount >= count($this->tokens)) {
throw new RuntimeException('FakeTokenGenerator exhausted');
}
$token = $this->tokens[$this->callCount];
$this->callCount++;
return $token;
}
}

View file

@ -0,0 +1,56 @@
<?php
namespace Tests\Fakes;
use App\Shared\ValueObject\EmailAddress;
use App\User\CreateUserDto;
use App\User\User;
use App\User\UserRepository;
class FakeUserRepository implements UserRepository
{
/**
* @var array<int, User>
*/
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(),
);
}
}

View file

@ -101,7 +101,7 @@ class AuthMiddlewareTest extends TestCase
): User { ): User {
$user = app(UserRepository::class)->create(new CreateUserDto( $user = app(UserRepository::class)->create(new CreateUserDto(
email: new EmailAddress('user@example.com'), email: new EmailAddress('user@example.com'),
password: 'correct-password', passwordHash: 'hashed-password',
)); ));
app(SessionRepository::class)->create(new CreateSessionDto( app(SessionRepository::class)->create(new CreateSessionDto(
token: $token, token: $token,

View file

@ -20,7 +20,7 @@ class EloquentSessionRepositoryTest extends TestCase
{ {
$user = app(UserRepository::class)->create(new CreateUserDto( $user = app(UserRepository::class)->create(new CreateUserDto(
email: new EmailAddress('user@example.com'), email: new EmailAddress('user@example.com'),
password: 'correct-password', passwordHash: 'hashed-password',
)); ));
$createdAt = $this->utc('2026-07-31T12:00:00'); $createdAt = $this->utc('2026-07-31T12:00:00');
$expiresAt = $this->utc('2026-08-07T12: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( $user = app(UserRepository::class)->create(new CreateUserDto(
email: new EmailAddress('user@example.com'), email: new EmailAddress('user@example.com'),
password: 'correct-password', passwordHash: 'hashed-password',
)); ));
$repository = app(SessionRepository::class); $repository = app(SessionRepository::class);
$repository->create(new CreateSessionDto( $repository->create(new CreateSessionDto(

View file

@ -2,162 +2,46 @@
namespace Tests\Feature\Auth; namespace Tests\Feature\Auth;
use App\Auth\Clock; use App\Auth\PasswordHasher;
use App\Auth\SessionRepository; use App\Auth\SessionRepository;
use App\Http\Middleware\AuthMiddleware; use App\Http\Middleware\AuthMiddleware;
use App\Shared\ValueObject\EmailAddress; use App\Shared\ValueObject\EmailAddress;
use App\User\CreateUserDto; use App\User\CreateUserDto;
use App\User\User;
use App\User\UserRepository; use App\User\UserRepository;
use DateTimeImmutable;
use DateTimeZone;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Symfony\Component\HttpFoundation\Cookie;
use Tests\Fakes\FakeClock;
use Tests\TestCase; use Tests\TestCase;
class LoginEndpointTest extends TestCase class LoginEndpointTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
private DateTimeImmutable $currentTime; public function test_login_returns_user_and_sets_session_cookie(): void
protected function setUp(): void
{ {
parent::setUp(); $email = 'user@example.com';
$password = 'correct-password';
$this->currentTime = new DateTimeImmutable( $passwordHash = app(PasswordHasher::class)->hash($password);
'2026-07-31T12:00:00', app(UserRepository::class)->create(new CreateUserDto(
new DateTimeZone('UTC'), email: new EmailAddress($email),
); passwordHash: $passwordHash,
$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();
$response = $this->postJson('/api/login', [ $response = $this->postJson('/api/login', [
'email' => ' user@EXAMPLE.COM ', 'email' => $email,
'password' => 'correct-password', 'password' => $password,
]); ]);
$response->assertOk()->assertExactJson([ $response->assertOk();
'user' => [ $response->assertJsonPath('user.email', $email);
'id' => $user->getId(),
'email' => 'user@example.com',
],
]);
$cookie = $this->findAuthCookie( $cookie = $response->getCookie(
$response->headers->getCookies(), AuthMiddleware::COOKIE_NAME,
false,
); );
$token = $cookie->getValue(); $this->assertNotNull($cookie);
$this->assertNotNull(
$this->assertMatchesRegularExpression( app(SessionRepository::class)->findByToken(
'/^[a-f0-9]{64}$/', $cookie->getValue(),
$token, ),
); );
$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<int, Cookie> $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.');
} }
} }

View file

@ -25,7 +25,7 @@ class MeEndpointTest extends TestCase
); );
$user = app(UserRepository::class)->create(new CreateUserDto( $user = app(UserRepository::class)->create(new CreateUserDto(
email: new EmailAddress('user@example.com'), email: new EmailAddress('user@example.com'),
password: 'correct-password', passwordHash: 'hashed-password',
)); ));
app(SessionRepository::class)->create(new CreateSessionDto( app(SessionRepository::class)->create(new CreateSessionDto(
token: 'valid-token', token: 'valid-token',

View file

@ -2,6 +2,7 @@
namespace Tests\Feature\Database; namespace Tests\Feature\Database;
use App\Auth\PasswordHasher;
use App\Shared\ValueObject\EmailAddress; use App\Shared\ValueObject\EmailAddress;
use App\User\UserRepository; use App\User\UserRepository;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@ -21,15 +22,18 @@ class DatabaseSeederTest extends TestCase
]); ]);
$this->assertDatabaseMissing('users', [ $this->assertDatabaseMissing('users', [
'email' => 'user@example.com', 'email' => 'user@example.com',
'password' => 'password', 'passwordHash' => 'password',
]); ]);
$this->assertDatabaseCount('users', 1); $this->assertDatabaseCount('users', 1);
$user = app(UserRepository::class)->findByCredentials( $user = app(UserRepository::class)->findByEmail(
new EmailAddress('user@example.com'), new EmailAddress('user@example.com'),
'password',
); );
$this->assertNotNull($user); $this->assertNotNull($user);
$this->assertTrue(app(PasswordHasher::class)->verify(
'password',
$user->getPasswordHash(),
));
} }
} }

View file

@ -17,7 +17,7 @@ class EloquentUserRepositoryTest extends TestCase
$repository = app(UserRepository::class); $repository = app(UserRepository::class);
$user = $repository->create(new CreateUserDto( $user = $repository->create(new CreateUserDto(
email: new EmailAddress('Founder@EXAMPLE.COM'), email: new EmailAddress('Founder@EXAMPLE.COM'),
password: 'correct-password', passwordHash: 'hashed-password',
)); ));
$this->assertGreaterThan(0, $user->getId()); $this->assertGreaterThan(0, $user->getId());
@ -29,9 +29,9 @@ class EloquentUserRepositoryTest extends TestCase
'id' => $user->getId(), 'id' => $user->getId(),
'email' => 'Founder@example.com', 'email' => 'Founder@example.com',
]); ]);
$this->assertDatabaseMissing('users', [ $this->assertDatabaseHas('users', [
'id' => $user->getId(), 'id' => $user->getId(),
'password' => 'correct-password', 'passwordHash' => 'hashed-password',
]); ]);
$foundUser = $repository->find($user->getId()); $foundUser = $repository->find($user->getId());
@ -42,6 +42,10 @@ class EloquentUserRepositoryTest extends TestCase
$user->getEmail()->value(), $user->getEmail()->value(),
$foundUser->getEmail()->value(), $foundUser->getEmail()->value(),
); );
$this->assertSame(
$user->getPasswordHash(),
$foundUser->getPasswordHash(),
);
} }
public function test_it_returns_null_for_an_unknown_user(): void public function test_it_returns_null_for_an_unknown_user(): void
@ -51,38 +55,27 @@ class EloquentUserRepositoryTest extends TestCase
$this->assertNull($repository->find(999)); $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); $repository = app(UserRepository::class);
$createdUser = $repository->create(new CreateUserDto( $createdUser = $repository->create(new CreateUserDto(
email: new EmailAddress('user@example.com'), email: new EmailAddress('user@example.com'),
password: 'correct-password', passwordHash: 'hashed-password',
)); ));
$foundUser = $repository->findByCredentials( $foundUser = $repository->findByEmail(
new EmailAddress('user@EXAMPLE.COM'), new EmailAddress('user@EXAMPLE.COM'),
'correct-password',
); );
$this->assertNotNull($foundUser); $this->assertNotNull($foundUser);
$this->assertSame($createdUser->getId(), $foundUser->getId()); $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 = app(UserRepository::class);
$repository->create(new CreateUserDto( $this->assertNull($repository->findByEmail(
email: new EmailAddress('user@example.com'),
password: 'correct-password',
));
$this->assertNull($repository->findByCredentials(
new EmailAddress('user@example.com'),
'wrong-password',
));
$this->assertNull($repository->findByCredentials(
new EmailAddress('unknown@example.com'), new EmailAddress('unknown@example.com'),
'correct-password',
)); ));
} }
} }

View file

@ -154,6 +154,7 @@ class AuthMiddlewareTest extends TestCase
return new User( return new User(
id: 7, id: 7,
email: new EmailAddress('user@example.com'), email: new EmailAddress('user@example.com'),
passwordHash: 'hashed-password',
); );
} }
} }

View file

@ -16,6 +16,7 @@ class SessionTest extends TestCase
$user = new User( $user = new User(
id: 7, id: 7,
email: new EmailAddress('user@example.com'), email: new EmailAddress('user@example.com'),
passwordHash: 'hashed-password',
); );
$createdAt = $this->utc('2026-07-31T12:00:00'); $createdAt = $this->utc('2026-07-31T12:00:00');
$expiresAt = $this->utc('2026-08-07T12:00:00'); $expiresAt = $this->utc('2026-08-07T12:00:00');
@ -40,6 +41,7 @@ class SessionTest extends TestCase
user: new User( user: new User(
id: 7, id: 7,
email: new EmailAddress('user@example.com'), email: new EmailAddress('user@example.com'),
passwordHash: 'hashed-password',
), ),
createdAt: $this->utc('2026-07-31T12:00:00'), createdAt: $this->utc('2026-07-31T12:00:00'),
expiresAt: $expiresAt, expiresAt: $expiresAt,

View file

@ -0,0 +1,104 @@
<?php
namespace Tests\Unit\Auth\UseCases;
use App\Auth\UseCases\AuthenticateUser\AuthenticateUser;
use App\Auth\UseCases\AuthenticateUser\AuthenticateUserRequest;
use App\Exceptions\BadRequestException;
use App\Exceptions\UnauthorizedException;
use App\Shared\ValueObject\EmailAddress;
use App\User\CreateUserDto;
use App\User\User;
use PHPUnit\Framework\TestCase;
use Tests\Fakes\FakePasswordHasher;
use Tests\Fakes\FakeUserRepository;
class AuthenticateUserTest extends TestCase
{
private FakeUserRepository $userRepository;
private FakePasswordHasher $passwordHasher;
private AuthenticateUser $useCase;
protected function setUp(): void
{
$this->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),
));
}
}

View file

@ -0,0 +1,70 @@
<?php
namespace Tests\Unit\Auth\UseCases;
use App\Auth\UseCases\CreateSession\CreateSession;
use App\Shared\ValueObject\EmailAddress;
use App\User\User;
use DateTimeImmutable;
use DateTimeZone;
use PHPUnit\Framework\TestCase;
use Tests\Fakes\FakeClock;
use Tests\Fakes\FakeSessionRepository;
use Tests\Fakes\FakeTokenGenerator;
class CreateSessionTest extends TestCase
{
private DateTimeImmutable $now;
private FakeSessionRepository $sessionRepository;
private CreateSession $useCase;
protected function setUp(): void
{
$this->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',
);
}
}

View file

@ -0,0 +1,127 @@
<?php
namespace Tests\Unit\Http\Controllers;
use App\Auth\UseCases\AuthenticateUser\AuthenticateUser;
use App\Auth\UseCases\CreateSession\CreateSession;
use App\Http\Controllers\AuthController;
use App\Http\Middleware\AuthMiddleware;
use App\Shared\ValueObject\EmailAddress;
use App\User\CreateUserDto;
use DateTimeImmutable;
use DateTimeZone;
use Illuminate\Http\Request;
use PHPUnit\Framework\TestCase;
use Tests\Fakes\FakeClock;
use Tests\Fakes\FakePasswordHasher;
use Tests\Fakes\FakeSessionRepository;
use Tests\Fakes\FakeTokenGenerator;
use Tests\Fakes\FakeUserRepository;
class AuthControllerTest extends TestCase
{
private FakeUserRepository $userRepository;
private FakePasswordHasher $passwordHasher;
private FakeSessionRepository $sessionRepository;
private AuthController $controller;
protected function setUp(): void
{
$this->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),
));
}
}

View file

@ -8,12 +8,17 @@ use PHPUnit\Framework\TestCase;
class UserTest extends 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'); $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(42, $user->getId());
$this->assertSame($email, $user->getEmail()); $this->assertSame($email, $user->getEmail());
$this->assertSame('hashed-password', $user->getPasswordHash());
} }
} }