Compare commits

..

10 commits

43 changed files with 1159 additions and 30 deletions

View file

@ -87,3 +87,10 @@ Future versions of Attainly may include:
Attainly is built around a simple idea: Attainly is built around a simple idea:
Large goals become attainable when they are broken into clear, scheduled steps and completed consistently over time. Large goals become attainable when they are broken into clear, scheduled steps and completed consistently over time.
## Development Login
The seeded local development account uses these credentials:
- Email: `user@example.com`
- Password: `password`

View file

@ -0,0 +1,16 @@
<?php
namespace App\Auth;
class BcryptPasswordHasher implements PasswordHasher
{
public function hash(string $password): string
{
return password_hash($password, PASSWORD_DEFAULT);
}
public function verify(string $password, string $hash): bool
{
return password_verify($password, $hash);
}
}

View file

@ -0,0 +1,10 @@
<?php
namespace App\Auth;
interface PasswordHasher
{
public function hash(string $password): string;
public function verify(string $password, string $hash): bool;
}

View file

@ -0,0 +1,11 @@
<?php
namespace App\Auth;
class RandomTokenGenerator implements TokenGenerator
{
public function generate(): string
{
return bin2hex(random_bytes(32));
}
}

View file

@ -0,0 +1,8 @@
<?php
namespace App\Auth;
interface TokenGenerator
{
public function generate(): string;
}

View file

@ -0,0 +1,49 @@
<?php
namespace App\Auth\UseCases\AuthenticateUser;
use App\Auth\PasswordHasher;
use App\Exceptions\BadRequestException;
use App\Exceptions\UnauthorizedException;
use App\Shared\ValueObject\EmailAddress;
use App\User\User;
use App\User\UserRepository;
class AuthenticateUser
{
public function __construct(
private UserRepository $userRepo,
private PasswordHasher $hasher,
) {}
/**
* @throws BadRequestException
* @throws UnauthorizedException
*/
public function execute(AuthenticateUserRequest $request): User
{
if ($request->email === null || $request->email === '') {
throw new BadRequestException('email is required');
}
if ($request->password === null || $request->password === '') {
throw new BadRequestException('password is required');
}
$user = $this->userRepo->findByEmail(
new EmailAddress($request->email)
);
if ($user === null) {
throw new UnauthorizedException('invalid credentials');
}
$passwordMatches = $this->hasher->verify(
$request->password,
$user->getPasswordHash(),
);
if (! $passwordMatches) {
throw new UnauthorizedException('invalid credentials');
}
return $user;
}
}

View file

@ -0,0 +1,11 @@
<?php
namespace App\Auth\UseCases\AuthenticateUser;
class AuthenticateUserRequest
{
public function __construct(
public ?string $email,
public ?string $password,
) {}
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Auth\UseCases\CreateSession;
use App\Auth\Clock;
use App\Auth\CreateSessionDto;
use App\Auth\Session;
use App\Auth\SessionRepository;
use App\Auth\TokenGenerator;
use App\User\User;
class CreateSession
{
private const SESSION_LIFETIME = '+7 days';
public function __construct(
private SessionRepository $sessionRepo,
private TokenGenerator $tokenGenerator,
private Clock $clock,
) {}
public function execute(User $user): Session
{
$now = $this->clock->now();
$expiresAt = $now->modify(self::SESSION_LIFETIME);
return $this->sessionRepo->create(new CreateSessionDto(
token: $this->tokenGenerator->generate(),
user: $user,
createdAt: $now,
expiresAt: $expiresAt,
));
}
}

View file

@ -0,0 +1,7 @@
<?php
namespace App\Exceptions;
use DomainException;
class BadRequestException extends DomainException {}

View file

@ -0,0 +1,7 @@
<?php
namespace App\Exceptions;
use DomainException;
class UnauthorizedException extends DomainException {}

View file

@ -2,22 +2,83 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Auth\UseCases\AuthenticateUser\AuthenticateUser;
use App\Auth\UseCases\AuthenticateUser\AuthenticateUserRequest;
use App\Auth\UseCases\CreateSession\CreateSession;
use App\Exceptions\BadRequestException;
use App\Exceptions\UnauthorizedException;
use App\Http\Middleware\AuthMiddleware;
use App\Shared\Http\RequestInput;
use App\User\User; use App\User\User;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Cookie;
class AuthController extends Controller class AuthController extends Controller
{ {
public function __construct(
private AuthenticateUser $authenticateUser,
private CreateSession $createSession,
) {}
public function login(Request $request): JsonResponse
{
$input = new RequestInput($request);
try {
$user = $this->authenticateUser->execute(
new AuthenticateUserRequest(
email: $input->string('email'),
password: $input->string('password'),
)
);
} catch (BadRequestException $exception) {
return new JsonResponse(
['error' => $exception->getMessage()], 400
);
} catch (UnauthorizedException $exception) {
return new JsonResponse(
['error' => $exception->getMessage()], 401
);
}
$session = $this->createSession->execute($user);
$response = new JsonResponse([
'user' => $this->userPayload($user),
], 200);
return $response->withCookie(Cookie::create(
name: AuthMiddleware::COOKIE_NAME,
value: $session->getToken(),
expire: $session->getExpiresAt()->getTimestamp(),
path: '/',
domain: null,
secure: false,
httpOnly: true,
raw: false,
sameSite: Cookie::SAMESITE_LAX,
));
}
public function me(Request $request): JsonResponse public function me(Request $request): JsonResponse
{ {
/** @var User $user */ /** @var User $user */
$user = $request->attributes->get('user'); $user = $request->attributes->get('user');
return new JsonResponse([ return new JsonResponse([
'user' => [ 'user' => $this->userPayload($user),
'id' => $user->getId(),
'email' => $user->getEmail()->value(),
],
]); ]);
} }
/**
* @return array{id: int, email: string}
*/
private function userPayload(User $user): array
{
return [
'id' => $user->getId(),
'email' => $user->getEmail()->value(),
];
}
} }

View file

@ -2,10 +2,14 @@
namespace App\Providers; namespace App\Providers;
use App\Auth\BcryptPasswordHasher;
use App\Auth\Clock; use App\Auth\Clock;
use App\Auth\EloquentSessionRepository; use App\Auth\EloquentSessionRepository;
use App\Auth\PasswordHasher;
use App\Auth\RandomTokenGenerator;
use App\Auth\SessionRepository; use App\Auth\SessionRepository;
use App\Auth\SystemClock; use App\Auth\SystemClock;
use App\Auth\TokenGenerator;
use App\User\EloquentUserRepository; use App\User\EloquentUserRepository;
use App\User\UserRepository; use App\User\UserRepository;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
@ -29,6 +33,8 @@ class AppServiceProvider extends ServiceProvider
SessionRepository::class, SessionRepository::class,
EloquentSessionRepository::class, EloquentSessionRepository::class,
); );
$this->app->bind(PasswordHasher::class, BcryptPasswordHasher::class);
$this->app->bind(TokenGenerator::class, RandomTokenGenerator::class);
$this->app->bind(Clock::class, SystemClock::class); $this->app->bind(Clock::class, SystemClock::class);
} }

View file

@ -0,0 +1,23 @@
<?php
namespace App\Shared\Http;
use Illuminate\Http\Request;
class RequestInput
{
public function __construct(private Request $request) {}
public function string(string $key): ?string
{
$value = $this->request->input($key);
if (is_string($value)) {
return $value;
}
if (is_int($value) || is_float($value) || is_bool($value)) {
return (string) $value;
}
return null;
}
}

View file

@ -8,5 +8,6 @@ final readonly class CreateUserDto
{ {
public function __construct( public function __construct(
public EmailAddress $email, public EmailAddress $email,
public string $passwordHash,
) {} ) {}
} }

View file

@ -10,6 +10,7 @@ class EloquentUserRepository implements UserRepository
{ {
$model = UserModel::create([ $model = UserModel::create([
'email' => $dto->email->value(), 'email' => $dto->email->value(),
'passwordHash' => $dto->passwordHash,
]); ]);
return $this->toDomain($model); return $this->toDomain($model);
@ -25,11 +26,24 @@ class EloquentUserRepository implements UserRepository
return $this->toDomain($model); return $this->toDomain($model);
} }
public function findByEmail(EmailAddress $email): ?User
{
$model = UserModel::query()
->where('email', $email->value())
->first();
if ($model === null) {
return null;
}
return $this->toDomain($model);
}
private function toDomain(UserModel $model): User private function toDomain(UserModel $model): User
{ {
return new User( return new User(
id: $model->id, id: $model->id,
email: new EmailAddress($model->email), email: new EmailAddress($model->email),
passwordHash: $model->passwordHash,
); );
} }
} }

View file

@ -9,6 +9,7 @@ final readonly class User
public function __construct( public function __construct(
private int $id, private int $id,
private EmailAddress $email, private EmailAddress $email,
private string $passwordHash,
) {} ) {}
public function getId(): int public function getId(): int
@ -20,4 +21,9 @@ final readonly class User
{ {
return $this->email; return $this->email;
} }
public function getPasswordHash(): string
{
return $this->passwordHash;
}
} }

View file

@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Model;
/** /**
* @property int $id * @property int $id
* @property string $email * @property string $email
* @property string $passwordHash
* *
* @method static Builder<static>|UserModel newModelQuery() * @method static Builder<static>|UserModel newModelQuery()
* @method static Builder<static>|UserModel newQuery() * @method static Builder<static>|UserModel newQuery()
@ -16,7 +17,7 @@ use Illuminate\Database\Eloquent\Model;
* *
* @mixin \Eloquent * @mixin \Eloquent
*/ */
#[Fillable(['email'])] #[Fillable(['email', 'passwordHash'])]
class UserModel extends Model class UserModel extends Model
{ {
protected $table = 'users'; protected $table = 'users';

View file

@ -2,9 +2,13 @@
namespace App\User; namespace App\User;
use App\Shared\ValueObject\EmailAddress;
interface UserRepository interface UserRepository
{ {
public function create(CreateUserDto $dto): User; public function create(CreateUserDto $dto): User;
public function find(int $id): ?User; public function find(int $id): ?User;
public function findByEmail(EmailAddress $email): ?User;
} }

View file

@ -11,6 +11,7 @@ return new class extends Migration
Schema::create('users', function (Blueprint $table): void { Schema::create('users', function (Blueprint $table): void {
$table->id(); $table->id();
$table->string('email')->unique(); $table->string('email')->unique();
$table->string('passwordHash');
}); });
} }

View file

@ -2,15 +2,29 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\User\UserModel; use App\Auth\PasswordHasher;
use App\Shared\ValueObject\EmailAddress;
use App\User\CreateUserDto;
use App\User\UserRepository;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
class UserSeeder extends Seeder class UserSeeder extends Seeder
{ {
public const string EMAIL = 'user@example.com';
public const string PASSWORD = 'password';
public function run(): void public function run(): void
{ {
UserModel::firstOrCreate([ $userRepository = app(UserRepository::class);
'email' => 'user@example.com', $email = new EmailAddress(self::EMAIL);
]); if ($userRepository->findByEmail($email) !== null) {
return;
}
$userRepository->create(new CreateUserDto(
email: $email,
passwordHash: app(PasswordHasher::class)->hash(self::PASSWORD),
));
} }
} }

View file

@ -4,6 +4,8 @@ use App\Http\Controllers\AuthController;
use App\Http\Middleware\AuthMiddleware; use App\Http\Middleware\AuthMiddleware;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::post('/login', [AuthController::class, 'login']);
Route::middleware(AuthMiddleware::class)->group(function (): void { Route::middleware(AuthMiddleware::class)->group(function (): void {
Route::get('/me', [AuthController::class, 'me']); Route::get('/me', [AuthController::class, 'me']);
}); });

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,6 +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'),
passwordHash: 'hashed-password',
)); ));
app(SessionRepository::class)->create(new CreateSessionDto( app(SessionRepository::class)->create(new CreateSessionDto(
token: $token, token: $token,

View file

@ -20,6 +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'),
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');
@ -61,6 +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'),
passwordHash: 'hashed-password',
)); ));
$repository = app(SessionRepository::class); $repository = app(SessionRepository::class);
$repository->create(new CreateSessionDto( $repository->create(new CreateSessionDto(

View file

@ -0,0 +1,47 @@
<?php
namespace Tests\Feature\Auth;
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\UserRepository;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class LoginEndpointTest extends TestCase
{
use RefreshDatabase;
public function test_login_returns_user_and_sets_session_cookie(): void
{
$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' => $email,
'password' => $password,
]);
$response->assertOk();
$response->assertJsonPath('user.email', $email);
$cookie = $response->getCookie(
AuthMiddleware::COOKIE_NAME,
false,
);
$this->assertNotNull($cookie);
$this->assertNotNull(
app(SessionRepository::class)->findByToken(
$cookie->getValue(),
),
);
}
}

View file

@ -25,6 +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'),
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,9 @@
namespace Tests\Feature\Database; namespace Tests\Feature\Database;
use App\Auth\PasswordHasher;
use App\Shared\ValueObject\EmailAddress;
use App\User\UserRepository;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase; use Tests\TestCase;
@ -17,6 +20,20 @@ class DatabaseSeederTest extends TestCase
$this->assertDatabaseHas('users', [ $this->assertDatabaseHas('users', [
'email' => 'user@example.com', 'email' => 'user@example.com',
]); ]);
$this->assertDatabaseMissing('users', [
'email' => 'user@example.com',
'passwordHash' => 'password',
]);
$this->assertDatabaseCount('users', 1); $this->assertDatabaseCount('users', 1);
$user = app(UserRepository::class)->findByEmail(
new EmailAddress('user@example.com'),
);
$this->assertNotNull($user);
$this->assertTrue(app(PasswordHasher::class)->verify(
'password',
$user->getPasswordHash(),
));
} }
} }

View file

@ -17,6 +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'),
passwordHash: 'hashed-password',
)); ));
$this->assertGreaterThan(0, $user->getId()); $this->assertGreaterThan(0, $user->getId());
@ -28,6 +29,10 @@ class EloquentUserRepositoryTest extends TestCase
'id' => $user->getId(), 'id' => $user->getId(),
'email' => 'Founder@example.com', 'email' => 'Founder@example.com',
]); ]);
$this->assertDatabaseHas('users', [
'id' => $user->getId(),
'passwordHash' => 'hashed-password',
]);
$foundUser = $repository->find($user->getId()); $foundUser = $repository->find($user->getId());
@ -37,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
@ -45,4 +54,28 @@ class EloquentUserRepositoryTest extends TestCase
$this->assertNull($repository->find(999)); $this->assertNull($repository->find(999));
} }
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'),
passwordHash: 'hashed-password',
));
$foundUser = $repository->findByEmail(
new EmailAddress('user@EXAMPLE.COM'),
);
$this->assertNotNull($foundUser);
$this->assertSame($createdUser->getId(), $foundUser->getId());
}
public function test_it_returns_null_for_an_unknown_email(): void
{
$repository = app(UserRepository::class);
$this->assertNull($repository->findByEmail(
new EmailAddress('unknown@example.com'),
));
}
} }

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());
} }
} }

View file

@ -0,0 +1,151 @@
const authenticatedUser = {
id: 7,
email: 'user@example.com',
}
function fillLoginForm(): void {
cy.get('#login-email').type('user@example.com')
cy.get('#login-password').type('correct-password')
}
describe('password login', () => {
beforeEach(() => {
cy.intercept('GET', '**/api/me', {
statusCode: 401,
body: { error: 'unauthenticated' },
}).as('me')
})
it('logs in and redirects to the dashboard', () => {
cy.intercept('POST', '**/api/login', (request) => {
expect(request.body).to.deep.equal({
email: 'user@example.com',
password: 'correct-password',
})
request.reply({
statusCode: 200,
body: { user: authenticatedUser },
})
}).as('login')
cy.visit('/login')
fillLoginForm()
cy.get('form').submit()
cy.wait('@login')
cy.location('pathname').should('equal', '/dashboard')
})
it('returns to a safe internal redirect after login', () => {
cy.intercept('POST', '**/api/login', {
statusCode: 200,
body: { user: authenticatedUser },
}).as('login')
cy.visit('/login?redirect=%2Fdashboard%3Ffocus%3Dtoday')
fillLoginForm()
cy.get('form').submit()
cy.wait('@login')
cy.location('pathname').should('equal', '/dashboard')
cy.location('search').should('equal', '?focus=today')
})
it('ignores an unsafe redirect after login', () => {
cy.intercept('POST', '**/api/login', {
statusCode: 200,
body: { user: authenticatedUser },
}).as('login')
cy.visit('/login?redirect=https%3A%2F%2Fexample.com')
fillLoginForm()
cy.get('form').submit()
cy.wait('@login')
cy.location('pathname').should('equal', '/dashboard')
})
it('validates required login fields before submitting', () => {
cy.visit('/login')
cy.get('form').submit()
cy.get('#login-email-error')
.should('have.text', 'Enter a valid email address.')
.and('be.visible')
cy.get('#login-email').should('have.attr', 'aria-invalid', 'true')
cy.get('#login-password-error')
.should('have.text', 'Enter your password.')
.and('be.visible')
cy.get('#login-password').should('have.attr', 'aria-invalid', 'true')
cy.location('pathname').should('equal', '/login')
})
it('shows the backend invalid-credentials error', () => {
cy.intercept('POST', '**/api/login', {
statusCode: 401,
body: { error: 'invalid credentials' },
}).as('login')
cy.visit('/login')
fillLoginForm()
cy.get('form').submit()
cy.wait('@login')
cy.get('[role="alert"]')
.should('have.text', 'invalid credentials')
.and('be.visible')
cy.location('pathname').should('equal', '/login')
})
it('shows backend request errors', () => {
cy.intercept('POST', '**/api/login', {
statusCode: 400,
body: { error: 'email is required' },
}).as('login')
cy.visit('/login')
fillLoginForm()
cy.get('form').submit()
cy.wait('@login')
cy.get('[role="alert"]')
.should('have.text', 'email is required')
.and('be.visible')
cy.get('#login-email-error').should('not.exist')
})
it('shows a malformed-response error', () => {
cy.intercept('POST', '**/api/login', {
statusCode: 200,
body: { user: { id: 7 } },
}).as('malformedLogin')
cy.visit('/login')
fillLoginForm()
cy.get('form').submit()
cy.wait('@malformedLogin')
cy.get('[role="alert"]').should(
'have.text',
'Unable to log in. Please try again.',
)
})
it('disables the form while login is pending', () => {
cy.intercept('POST', '**/api/login', {
delay: 500,
statusCode: 200,
body: { user: authenticatedUser },
}).as('login')
cy.visit('/login')
fillLoginForm()
cy.get('form').submit()
cy.get('button[type="submit"]')
.should('be.disabled')
.and('have.text', 'Logging in...')
cy.get('#login-email').should('be.disabled')
cy.get('#login-password').should('be.disabled')
cy.wait('@login')
})
})

View file

@ -1,16 +1,34 @@
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ defineProps<{
submitLabel: string submitLabel: string
submittingLabel?: string
submitting?: boolean
error?: string
}>()
defineEmits<{
submit: []
}>() }>()
</script> </script>
<template> <template>
<form class="auth-form" @submit.prevent> <form
class="auth-form"
:aria-busy="submitting === true ? 'true' : undefined"
novalidate
@submit.prevent="$emit('submit')"
>
<div class="auth-form__fields"> <div class="auth-form__fields">
<slot></slot> <slot></slot>
</div> </div>
<button type="submit">{{ submitLabel }}</button> <p v-if="error !== undefined" class="auth-form__error" role="alert">
{{ error }}
</p>
<button type="submit" :disabled="submitting">
{{ submitting && submittingLabel ? submittingLabel : submitLabel }}
</button>
</form> </form>
</template> </template>
@ -60,6 +78,22 @@ button:focus-visible {
outline-offset: 0.2rem; outline-offset: 0.2rem;
} }
button:disabled {
border-color: #708079;
background: #708079;
box-shadow: none;
cursor: wait;
transform: none;
}
.auth-form__error {
margin: -0.65rem 0;
color: #a33f37;
font-size: 0.8rem;
font-weight: 650;
line-height: 1.5;
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
button { button {
transition: none; transition: none;

View file

@ -5,7 +5,20 @@ defineProps<{
type: 'email' | 'password' | 'text' type: 'email' | 'password' | 'text'
autocomplete: string autocomplete: string
placeholder: string placeholder: string
modelValue?: string
error?: string
disabled?: boolean
}>() }>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
function updateValue(event: Event): void {
if (event.target instanceof HTMLInputElement) {
emit('update:modelValue', event.target.value)
}
}
</script> </script>
<template> <template>
@ -17,7 +30,15 @@ defineProps<{
:name="id" :name="id"
:autocomplete="autocomplete" :autocomplete="autocomplete"
:placeholder="placeholder" :placeholder="placeholder"
:value="modelValue"
:disabled="disabled"
:aria-invalid="error === undefined ? undefined : 'true'"
:aria-describedby="error === undefined ? undefined : `${id}-error`"
@input="updateValue"
/> />
<p v-if="error !== undefined" :id="`${id}-error`" class="text-field__error">
{{ error }}
</p>
</div> </div>
</template> </template>
@ -62,6 +83,23 @@ input:focus {
box-shadow: 0 0 0 3px rgb(77 125 109 / 16%); box-shadow: 0 0 0 3px rgb(77 125 109 / 16%);
} }
input[aria-invalid='true'] {
border-color: #a54e46;
}
input:disabled {
color: #67736e;
background: #f5f5f2;
cursor: not-allowed;
}
.text-field__error {
margin: 0;
color: #a33f37;
font-size: 0.76rem;
line-height: 1.4;
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
input { input {
transition: none; transition: none;

View file

@ -13,7 +13,12 @@ const meResponseSchema = z.object({
user: authUserSchema, user: authUserSchema,
}) })
const loginErrorResponseSchema = z.object({
error: z.string(),
})
export type AuthUser = z.infer<typeof authUserSchema> export type AuthUser = z.infer<typeof authUserSchema>
export type LoginFieldErrors = Partial<Record<'email' | 'password', string>>
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const user = ref<AuthUser | null>(null) const user = ref<AuthUser | null>(null)
@ -57,11 +62,51 @@ export const useAuthStore = defineStore('auth', () => {
} }
} }
async function login(email: string, password: string): Promise<boolean> {
loading.value = true
error.value = null
try {
const response = await fetch(`${API_BASE}/api/login`, {
method: 'POST',
credentials: 'include',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
})
const responseBody: unknown = await response.json()
if (response.status === 200) {
user.value = meResponseSchema.parse(responseBody).user
return true
}
user.value = null
const errorResponse = loginErrorResponseSchema.safeParse(responseBody)
error.value = errorResponse.success
? errorResponse.data.error
: 'Unable to log in. Please try again.'
return false
} catch {
user.value = null
error.value = 'Unable to log in. Please try again.'
return false
} finally {
loading.value = false
}
}
return { return {
user, user,
loading, loading,
error, error,
isAuthenticated, isAuthenticated,
fetchMe, fetchMe,
login,
} }
}) })

View file

@ -1,7 +1,58 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router'
import { z } from 'zod'
import AuthForm from '@/components/AuthForm.vue' import AuthForm from '@/components/AuthForm.vue'
import AuthLayout from '@/components/AuthLayout.vue' import AuthLayout from '@/components/AuthLayout.vue'
import AuthTextField from '@/components/AuthTextField.vue' import AuthTextField from '@/components/AuthTextField.vue'
import { type LoginFieldErrors, useAuthStore } from '@/stores/auth'
const emailSchema = z.string().email()
const email = ref('')
const password = ref('')
const fieldErrors = ref<LoginFieldErrors>({})
const authStore = useAuthStore()
const { loading, error } = storeToRefs(authStore)
const route = useRoute()
const router = useRouter()
async function submitLogin(): Promise<void> {
const normalizedEmail = email.value.trim()
fieldErrors.value = validateLogin(normalizedEmail, password.value)
if (Object.keys(fieldErrors.value).length > 0) {
return
}
const loggedIn = await authStore.login(normalizedEmail, password.value)
if (!loggedIn) {
return
}
await router.push(safeLoginRedirect(route.query.redirect))
}
function validateLogin(submittedEmail: string, submittedPassword: string): LoginFieldErrors {
const errors: LoginFieldErrors = {}
if (!emailSchema.safeParse(submittedEmail).success) {
errors.email = 'Enter a valid email address.'
}
if (submittedPassword === '') {
errors.password = 'Enter your password.'
}
return errors
}
function safeLoginRedirect(redirect: unknown): string {
if (typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//')) {
return redirect
}
return '/dashboard'
}
</script> </script>
<template> <template>
@ -10,13 +61,22 @@ import AuthTextField from '@/components/AuthTextField.vue'
title="Welcome back" title="Welcome back"
description="Pick up where you left off and keep your momentum going." description="Pick up where you left off and keep your momentum going."
> >
<AuthForm submit-label="Log in"> <AuthForm
submit-label="Log in"
submitting-label="Logging in..."
:submitting="loading"
:error="error ?? undefined"
@submit="submitLogin"
>
<AuthTextField <AuthTextField
id="login-email" id="login-email"
label="Email address" label="Email address"
type="email" type="email"
autocomplete="email" autocomplete="email"
placeholder="you@example.com" placeholder="you@example.com"
v-model="email"
:error="fieldErrors.email"
:disabled="loading"
/> />
<AuthTextField <AuthTextField
id="login-password" id="login-password"
@ -24,6 +84,9 @@ import AuthTextField from '@/components/AuthTextField.vue'
type="password" type="password"
autocomplete="current-password" autocomplete="current-password"
placeholder="Enter your password" placeholder="Enter your password"
v-model="password"
:error="fieldErrors.password"
:disabled="loading"
/> />
</AuthForm> </AuthForm>

7
justfile Normal file
View file

@ -0,0 +1,7 @@
set shell := ["bash", "-c"]
default:
@just --list
fresh:
cd backend && php artisan migrate:fresh --seed

View file

@ -11,20 +11,6 @@ processes:
initial_delay_seconds: 1 initial_delay_seconds: 1
period_seconds: 2 period_seconds: 2
migrate:
command: php artisan migrate --force
working_dir: ./backend
depends_on:
postgres:
condition: process_healthy
seed:
command: php artisan db:seed --force
working_dir: ./backend
depends_on:
migrate:
condition: process_completed_successfully
mailpit: mailpit:
command: mailpit --smtp 127.0.0.1:${MAILPIT_SMTP_PORT:-2525} --listen 127.0.0.1:${MAILPIT_UI_PORT:-8025} command: mailpit --smtp 127.0.0.1:${MAILPIT_SMTP_PORT:-2525} --listen 127.0.0.1:${MAILPIT_UI_PORT:-8025}
readiness_probe: readiness_probe:
@ -39,8 +25,8 @@ processes:
command: php artisan serve --host=127.0.0.1 --port=${BACKEND_PORT:-8001} command: php artisan serve --host=127.0.0.1 --port=${BACKEND_PORT:-8001}
working_dir: ./backend working_dir: ./backend
depends_on: depends_on:
seed: postgres:
condition: process_completed_successfully condition: process_healthy
mailpit: mailpit:
condition: process_healthy condition: process_healthy
readiness_probe: readiness_probe: