Merge branch 'auth-and-admin'

This commit is contained in:
Yisroel Baum 2026-04-26 20:05:31 +03:00
commit 041590da15
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
65 changed files with 2160 additions and 125 deletions

View file

@ -37,6 +37,12 @@
<mxCell id="UlVOh7WOaItsqOB8hf6W-9" parent="1" style="whiteSpace=wrap;html=1;aspect=fixed;" value="Node" vertex="1"> <mxCell id="UlVOh7WOaItsqOB8hf6W-9" parent="1" style="whiteSpace=wrap;html=1;aspect=fixed;" value="Node" vertex="1">
<mxGeometry height="80" width="80" x="400" y="60" as="geometry" /> <mxGeometry height="80" width="80" x="400" y="60" as="geometry" />
</mxCell> </mxCell>
<mxCell id="UlVOh7WOaItsqOB8hf6W-19" edge="1" parent="1" source="UlVOh7WOaItsqOB8hf6W-17" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" target="UlVOh7WOaItsqOB8hf6W-1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="UlVOh7WOaItsqOB8hf6W-17" parent="1" style="whiteSpace=wrap;html=1;aspect=fixed;" value="Session" vertex="1">
<mxGeometry height="80" width="80" x="130" y="290" as="geometry" />
</mxCell>
</root> </root>
</mxGraphModel> </mxGraphModel>
</diagram> </diagram>

View file

@ -20,29 +20,41 @@ Code patterns to follow:
- Entities: constructor with properties, getters - Entities: constructor with properties, getters
- DTOs: simple data containers for creation - DTOs: simple data containers for creation
- Repositories: interfaces that define data access - Repositories: interfaces that define data access
- Do not write unit tests for concrete repository implementations
(e.g., Doctrine/persistence-backed). They are exercised by e2e
tests. Use cases are tested with fake repositories.
- Use cases: business logic with Request objects - Use cases: business logic with Request objects
- When throwing exceptions, add @throws docblock - When throwing exceptions, add @throws docblock
- Fakes: in-memory implementations for testing - Fakes: in-memory implementations for testing
- Look at tests/Fakes/ for examples - Look at tests/Fakes/ for examples
- Find/lookup methods must return a new instance of the entity, not the stored reference - Find/lookup methods must return a new instance of the entity, not the stored reference
- Tests: follow existing patterns in tests/Unit/[Entity]/UseCases/ - Tests: follow existing patterns in tests/Unit/[Entity]/UseCases/
- In setUp, only use fake repositories for entities under test construct dependency objects directly with `new` (e.g., `new Text(....)`) instead of creating them through their fake repositories - In setUp, only use fake repositories for entities under test - construct dependency objects directly with `new` (e.g., `new Text(....)`) instead of creating them through their fake repositories
- Lines should not exceed 80 columns, but should use up to 80 columns when possible do not split lines unnecessarily - Lines should not exceed 80 columns, but should use up to 80 columns when possible - do not split lines unnecessarily
- Imports: always put use statements at the top of the file, never use inline imports (e.g., \App\Foo\Bar::class) - Imports: always put use statements at the top of the file, never use inline imports (e.g., \App\Foo\Bar::class)
- Variable names: use explicit, descriptive names — never single-letter or abbreviated variables (e.g., use $sponsorship not $s, $event not $e) - Variable names: use explicit, descriptive names - never single-letter or abbreviated variables (e.g., use $sponsorship not $s, $event not $e)
- Never use em-dashes (—) in code, comments, commit messages, or any
written output. Use a regular hyphen (-), a colon, or rephrase
with parentheses instead.
Git commit style: Git commit style:
- Present tense, imperative mood (add, create, test, fix) - Subject: present tense, imperative mood (add, create, test, fix)
- Lowercase - Subject: lowercase, short (3-6 words)
- Short (3-6 words) - Match subject patterns found in git history
- Match patterns found in git history - Add a body when the change needs explanation beyond the subject -
e.g., why the change was made, non-obvious tradeoffs, or notable
implementation details. Skip the body for trivial/self-evident commits.
- Separate subject and body with a blank line; wrap body at ~72 columns
Git commits: Git commits:
- Tests should be committed first, before implementation - Tests should be committed first, before implementation
- One commit per file - each new file gets its own commit - Group related changes together in a single commit (e.g., a new class
- Make commits SMALL and FREQUENT - every meaningful change should be a commit plus its registration, or a getter plus the property it exposes).
Avoid mixing unrelated concerns in one commit.
- Keep commits small and focused - prefer many small commits over few
large ones, but don't artificially split a single logical change
across multiple commits
- Commits are for reviewing and documenting the development of code - Commits are for reviewing and documenting the development of code
- A commit can be as simple as adding one import, one getter, one property, etc.
- Don't wait to commit - commit as you go - Don't wait to commit - commit as you go
- Run `php-cs-fixer fix` on worked on directories before committing - Run `php-cs-fixer fix` on worked on directories before committing

View file

@ -17,22 +17,31 @@ Code patterns to follow:
- First, explore the codebase to understand existing entity patterns - First, explore the codebase to understand existing entity patterns
- Look at similar pages for reference - Look at similar pages for reference
- Tests: follow existing patterns in cypress/e2e/ - Tests: follow existing patterns in cypress/e2e/
- Lines should not exceed 80 columns, but should use up to 80 columns when possible do not split lines unnecessarily - Lines should not exceed 80 columns, but should use up to 80 columns when possible - do not split lines unnecessarily
- Imports: always put imports at the top of the file - Imports: always put imports at the top of the file
- Variable names: use explicit, descriptive names — never single-letter or abbreviated variables (e.g., use sponsorship not s, event not e) - Variable names: use explicit, descriptive names - never single-letter or abbreviated variables (e.g., use sponsorship not s, event not e)
- Never use em-dashes (—) in code, comments, commit messages, or any
written output. Use a regular hyphen (-), a colon, or rephrase
with parentheses instead.
Git commit style: Git commit style:
- Present tense, imperative mood (add, create, test, fix) - Subject: present tense, imperative mood (add, create, test, fix)
- Lowercase - Subject: lowercase, short (3-6 words)
- Short (3-6 words) - Match subject patterns found in git history
- Match patterns found in git history - Add a body when the change needs explanation beyond the subject -
e.g., why the change was made, non-obvious tradeoffs, or notable
implementation details. Skip the body for trivial/self-evident commits.
- Separate subject and body with a blank line; wrap body at ~72 columns
Git commits: Git commits:
- Tests should be committed first, before implementation - Tests should be committed first, before implementation
- One commit per file - each new file gets its own commit - Group related changes together in a single commit (e.g., a new class
- Make commits SMALL and FREQUENT - every meaningful change should be a commit plus its registration, or a getter plus the property it exposes).
Avoid mixing unrelated concerns in one commit.
- Keep commits small and focused - prefer many small commits over few
large ones, but don't artificially split a single logical change
across multiple commits
- Commits are for reviewing and documenting the development of code - Commits are for reviewing and documenting the development of code
- A commit can be as simple as adding one import, one getter, one property, etc.
- Don't wait to commit - commit as you go - Don't wait to commit - commit as you go
Branch naming: Branch naming:

View file

@ -0,0 +1,64 @@
<?php
namespace App\Auth;
use App\User\User;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Slim\Psr7\Response;
class AdminMiddleware implements MiddlewareInterface
{
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler,
): ResponseInterface {
$user = $request->getAttribute('user');
if (!$user instanceof User || !$user->isAdmin()) {
return $this->forbidden($request);
}
return $handler->handle($request);
}
private function forbidden(
ServerRequestInterface $request
): ResponseInterface {
$response = new Response(403);
if ($this->wantsJson($request)) {
$response->getBody()->write(
json_encode(['error' => 'forbidden'])
);
return $response->withHeader(
'Content-Type',
'application/json'
);
}
$html = file_get_contents(
__DIR__ . '/../../views/templates/forbidden.php'
);
$response->getBody()->write($html);
return $response->withHeader('Content-Type', 'text/html');
}
private function wantsJson(ServerRequestInterface $request): bool
{
$path = $request->getUri()->getPath();
if (str_starts_with($path, '/api/')) {
return true;
}
$accept = $request->getHeaderLine('Accept');
if (str_contains($accept, 'application/json')) {
return true;
}
return false;
}
}

166
app/Auth/AuthController.php Normal file
View file

@ -0,0 +1,166 @@
<?php
namespace App\Auth;
use App\Auth\UseCases\CreateSession;
use App\Exceptions\BadRequestException;
use App\Exceptions\UnauthorizedException;
use App\User\UseCases\AuthenticateUser;
use App\User\UseCases\AuthenticateUserRequest;
use App\User\UseCases\CreateUser;
use App\User\UseCases\CreateUserRequest;
use App\User\User;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
class AuthController
{
private const COOKIE_MAX_AGE = 7 * 24 * 60 * 60;
public function login(
Request $request,
Response $response,
AuthenticateUser $authenticateUser,
CreateSession $createSession,
): Response {
$data = $this->parseBody($request);
try {
$user = $authenticateUser->execute(
new AuthenticateUserRequest(
email: $data['email'] ?? null,
password: $data['password'] ?? null,
)
);
} catch (BadRequestException $exception) {
return $this->errorResponse(
$response,
400,
$exception->getMessage()
);
} catch (UnauthorizedException $exception) {
return $this->errorResponse(
$response,
401,
$exception->getMessage()
);
}
$session = $createSession->execute($user);
return $this->userResponse($response, $user)
->withHeader(
'Set-Cookie',
$this->buildSetCookie($session->getToken())
);
}
public function register(
Request $request,
Response $response,
CreateUser $createUser,
CreateSession $createSession,
): Response {
$data = $this->parseBody($request);
try {
$user = $createUser->execute(new CreateUserRequest(
email: $data['email'] ?? null,
password: $data['password'] ?? null,
isAdmin: false,
));
} catch (BadRequestException $exception) {
return $this->errorResponse(
$response,
400,
$exception->getMessage()
);
}
$session = $createSession->execute($user);
return $this->userResponse($response, $user)
->withHeader(
'Set-Cookie',
$this->buildSetCookie($session->getToken())
);
}
public function logout(
Request $request,
Response $response,
SessionRepository $sessionRepo,
): Response {
$cookies = $request->getCookieParams();
$token = $cookies[AuthMiddleware::COOKIE_NAME] ?? null;
if ($token !== null) {
$sessionRepo->deleteByToken($token);
}
return $response->withStatus(204)
->withHeader('Set-Cookie', $this->buildClearCookie());
}
public function me(Request $request, Response $response): Response
{
$user = $request->getAttribute('user');
if (!$user instanceof User) {
return $this->errorResponse(
$response,
401,
'unauthenticated'
);
}
return $this->userResponse($response, $user);
}
private function parseBody(Request $request): array
{
return json_decode((string) $request->getBody(), true) ?? [];
}
private function userResponse(Response $response, User $user): Response
{
$response->getBody()->write(json_encode([
'user' => [
'id' => $user->getId(),
'email' => $user->getEmail()->value(),
'isAdmin' => $user->isAdmin(),
],
]));
return $response->withHeader(
'Content-Type',
'application/json'
);
}
private function errorResponse(
Response $response,
int $status,
string $message,
): Response {
$response->getBody()->write(
json_encode(['error' => $message])
);
return $response->withStatus($status)
->withHeader('Content-Type', 'application/json');
}
private function buildSetCookie(string $token): string
{
$maxAge = self::COOKIE_MAX_AGE;
return AuthMiddleware::COOKIE_NAME . '=' . $token
. '; Path=/; HttpOnly; SameSite=Lax; Max-Age=' . $maxAge;
}
private function buildClearCookie(): string
{
return AuthMiddleware::COOKIE_NAME . '=;'
. ' Path=/; HttpOnly; SameSite=Lax; Max-Age=0';
}
}

View file

@ -0,0 +1,84 @@
<?php
namespace App\Auth;
use App\User\UserRepository;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Slim\Psr7\Response;
class AuthMiddleware implements MiddlewareInterface
{
public const COOKIE_NAME = 'auth_token';
public function __construct(
private SessionRepository $sessionRepo,
private UserRepository $userRepo,
private Clock $clock,
) {}
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler,
): ResponseInterface {
$cookies = $request->getCookieParams();
$token = $cookies[self::COOKIE_NAME] ?? null;
if ($token === null) {
return $this->unauthorized($request);
}
$session = $this->sessionRepo->findByToken($token);
if ($session === null) {
return $this->unauthorized($request);
}
if ($session->isExpired($this->clock->now())) {
$this->sessionRepo->deleteByToken($token);
return $this->unauthorized($request);
}
$user = $this->userRepo->find($session->getUserId());
if ($user === null) {
return $this->unauthorized($request);
}
return $handler->handle(
$request->withAttribute('user', $user)
);
}
private function unauthorized(
ServerRequestInterface $request
): ResponseInterface {
if ($this->wantsJson($request)) {
$response = new Response(401);
$response->getBody()->write(
json_encode(['error' => 'unauthenticated'])
);
return $response->withHeader(
'Content-Type',
'application/json'
);
}
return new Response(302)->withHeader('Location', '/login');
}
private function wantsJson(ServerRequestInterface $request): bool
{
$path = $request->getUri()->getPath();
if (str_starts_with($path, '/api/')) {
return true;
}
$accept = $request->getHeaderLine('Accept');
if (str_contains($accept, 'application/json')) {
return true;
}
return false;
}
}

View file

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

13
app/Auth/Clock.php Normal file
View file

@ -0,0 +1,13 @@
<?php
namespace App\Auth;
use DateTimeImmutable;
interface Clock
{
/**
* Returns the current time in UTC.
*/
public function now(): DateTimeImmutable;
}

View file

@ -0,0 +1,15 @@
<?php
namespace App\Auth;
use DateTimeImmutable;
class CreateSessionDto
{
public function __construct(
public string $token,
public int $userId,
public DateTimeImmutable $createdAt,
public DateTimeImmutable $expiresAt,
) {}
}

View file

@ -0,0 +1,84 @@
<?php
namespace App\Auth;
use DateTimeImmutable;
class JsonSessionRepository implements SessionRepository
{
private string $filePath;
public function __construct()
{
$this->filePath = __DIR__ . '/../../data/sessions.json';
}
public function create(CreateSessionDto $dto): Session
{
$sessions = $this->readSessions();
$sessions[] = [
'token' => $dto->token,
'userId' => $dto->userId,
'createdAt' => $dto->createdAt->format(DATE_ATOM),
'expiresAt' => $dto->expiresAt->format(DATE_ATOM),
];
$this->writeSessions($sessions);
return new Session(
token: $dto->token,
userId: $dto->userId,
createdAt: $dto->createdAt,
expiresAt: $dto->expiresAt,
);
}
public function findByToken(string $token): ?Session
{
$sessions = $this->readSessions();
foreach ($sessions as $data) {
if ($data['token'] === $token) {
return new Session(
token: $data['token'],
userId: $data['userId'],
createdAt: new DateTimeImmutable($data['createdAt']),
expiresAt: new DateTimeImmutable($data['expiresAt']),
);
}
}
return null;
}
public function deleteByToken(string $token): void
{
$sessions = $this->readSessions();
$filtered = array_values(array_filter(
$sessions,
function (array $data) use ($token) {
return $data['token'] !== $token;
}
));
$this->writeSessions($filtered);
}
private function readSessions(): array
{
if (!file_exists($this->filePath)) {
return [];
}
$content = file_get_contents($this->filePath);
return json_decode($content, true) ?? [];
}
private function writeSessions(array $sessions): void
{
file_put_contents(
$this->filePath,
json_encode($sessions, JSON_PRETTY_PRINT)
);
}
}

View file

@ -0,0 +1,10 @@
<?php
namespace App\Auth;
interface PasswordHasher
{
public function hash(string $plaintext): string;
public function verify(string $plaintext, 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));
}
}

40
app/Auth/Session.php Normal file
View file

@ -0,0 +1,40 @@
<?php
namespace App\Auth;
use DateTimeImmutable;
class Session
{
public function __construct(
private string $token,
private int $userId,
private DateTimeImmutable $createdAt,
private DateTimeImmutable $expiresAt,
) {}
public function getToken(): string
{
return $this->token;
}
public function getUserId(): int
{
return $this->userId;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getExpiresAt(): DateTimeImmutable
{
return $this->expiresAt;
}
public function isExpired(DateTimeImmutable $now): bool
{
return $now >= $this->expiresAt;
}
}

View file

@ -0,0 +1,10 @@
<?php
namespace App\Auth;
interface SessionRepository
{
public function create(CreateSessionDto $dto): Session;
public function findByToken(string $token): ?Session;
public function deleteByToken(string $token): void;
}

14
app/Auth/SystemClock.php Normal file
View file

@ -0,0 +1,14 @@
<?php
namespace App\Auth;
use DateTimeImmutable;
use DateTimeZone;
class SystemClock implements Clock
{
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('now', new DateTimeZone('UTC'));
}
}

View file

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

View file

@ -0,0 +1,34 @@
<?php
namespace App\Auth\UseCases;
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(),
userId: $user->getId(),
createdAt: $now,
expiresAt: $expiresAt,
));
}
}

View file

@ -0,0 +1,5 @@
<?php
namespace App\Exceptions;
class ForbiddenException extends \RuntimeException {}

View file

@ -0,0 +1,5 @@
<?php
namespace App\Exceptions;
class UnauthorizedException extends \RuntimeException {}

View file

@ -5,6 +5,7 @@ namespace App\Plan;
use App\Exceptions\BadRequestException; use App\Exceptions\BadRequestException;
use App\Plan\UseCases\CreatePlan; use App\Plan\UseCases\CreatePlan;
use App\Plan\UseCases\CreatePlanRequest; use App\Plan\UseCases\CreatePlanRequest;
use App\User\User;
use DomainException; use DomainException;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
@ -16,9 +17,17 @@ class PlanController
Response $response, Response $response,
CreatePlan $createPlanUseCase, CreatePlan $createPlanUseCase,
): Response { ): Response {
$user = $request->getAttribute('user');
if (!$user instanceof User) {
$response->getBody()->write(
json_encode(['error' => 'unauthenticated'])
);
return $response->withStatus(401)
->withHeader('Content-Type', 'application/json');
}
$data = json_decode((string) $request->getBody(), true) ?? []; $data = json_decode((string) $request->getBody(), true) ?? [];
$userId = isset($data['userId']) ? (int) $data['userId'] : null;
$textId = isset($data['textId']) ? (int) $data['textId'] : null; $textId = isset($data['textId']) ? (int) $data['textId'] : null;
$name = $data['name'] ?? null; $name = $data['name'] ?? null;
$dateStart = $data['dateStart'] ?? null; $dateStart = $data['dateStart'] ?? null;
@ -26,7 +35,7 @@ class PlanController
try { try {
$plan = $createPlanUseCase->execute(new CreatePlanRequest( $plan = $createPlanUseCase->execute(new CreatePlanRequest(
userId: $userId, userId: $user->getId(),
textId: $textId, textId: $textId,
name: $name, name: $name,
dateStart: $dateStart, dateStart: $dateStart,

View file

@ -21,13 +21,17 @@ class JsonUserRepository implements UserRepository
$users[] = [ $users[] = [
'id' => $id, 'id' => $id,
'email' => (string) $dto->email, 'email' => $dto->email->value(),
'passwordHash' => $dto->passwordHash,
'isAdmin' => $dto->isAdmin,
]; ];
$this->writeUsers($users); $this->writeUsers($users);
return new User( return new User(
id: $id, id: $id,
email: $dto->email, email: $dto->email,
passwordHash: $dto->passwordHash,
isAdmin: $dto->isAdmin,
); );
} }
@ -37,16 +41,36 @@ class JsonUserRepository implements UserRepository
foreach ($users as $data) { foreach ($users as $data) {
if ($data['id'] === $id) { if ($data['id'] === $id) {
return new User( return $this->hydrate($data);
id: $data['id'],
email: new EmailAddress($data['email']),
);
} }
} }
return null; return null;
} }
public function findByEmail(EmailAddress $email): ?User
{
$users = $this->readUsers();
foreach ($users as $data) {
if ($data['email'] === $email->value()) {
return $this->hydrate($data);
}
}
return null;
}
private function hydrate(array $data): User
{
return new User(
id: $data['id'],
email: new EmailAddress($data['email']),
passwordHash: $data['passwordHash'] ?? '',
isAdmin: $data['isAdmin'] ?? false,
);
}
private function readUsers(): array private function readUsers(): array
{ {
if (!file_exists($this->filePath)) { if (!file_exists($this->filePath)) {

View file

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

View file

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

View file

@ -2,7 +2,9 @@
namespace App\User\UseCases; namespace App\User\UseCases;
use App\Auth\PasswordHasher;
use App\Exceptions\BadRequestException; use App\Exceptions\BadRequestException;
use App\User\User;
use App\User\UserRepository; use App\User\UserRepository;
use App\ValueObjects\EmailAddress; use App\ValueObjects\EmailAddress;
@ -10,19 +12,37 @@ class CreateUser
{ {
public function __construct( public function __construct(
private UserRepository $userRepo, private UserRepository $userRepo,
private PasswordHasher $passwordHasher,
) {} ) {}
/** /**
* @throws BadRequestException * @throws BadRequestException
*/ */
public function execute(CreateUserRequest $dto): void public function execute(CreateUserRequest $dto): User
{ {
if ($dto->email === null) { if ($dto->email === null) {
throw new BadRequestException('email is required'); throw new BadRequestException('email is required');
} }
$this->userRepo->create(new CreateUserDto( if ($dto->password === null) {
email: new EmailAddress($dto->email), throw new BadRequestException('password is required');
}
if (strlen($dto->password) < 8) {
throw new BadRequestException(
'password must be at least 8 characters'
);
}
$email = new EmailAddress($dto->email);
if ($this->userRepo->findByEmail($email) !== null) {
throw new BadRequestException('email already taken');
}
return $this->userRepo->create(new CreateUserDto(
email: $email,
passwordHash: $this->passwordHasher->hash($dto->password),
isAdmin: $dto->isAdmin,
)); ));
} }
} }

View file

@ -8,5 +8,7 @@ class CreateUserDto
{ {
public function __construct( public function __construct(
public EmailAddress $email, public EmailAddress $email,
public string $passwordHash,
public bool $isAdmin = false,
) {} ) {}
} }

View file

@ -6,5 +6,7 @@ class CreateUserRequest
{ {
public function __construct( public function __construct(
public ?string $email, public ?string $email,
public ?string $password,
public bool $isAdmin,
) {} ) {}
} }

View file

@ -9,6 +9,8 @@ class User
public function __construct( public function __construct(
private int $id, private int $id,
private EmailAddress $email, private EmailAddress $email,
private string $passwordHash,
private bool $isAdmin,
) {} ) {}
public function getId(): int public function getId(): int
@ -20,4 +22,14 @@ class User
{ {
return $this->email; return $this->email;
} }
public function getPasswordHash(): string
{
return $this->passwordHash;
}
public function isAdmin(): bool
{
return $this->isAdmin;
}
} }

View file

@ -3,9 +3,11 @@
namespace App\User; namespace App\User;
use App\User\UseCases\CreateUserDto; use App\User\UseCases\CreateUserDto;
use App\ValueObjects\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,11 @@ class EmailAddress
$this->normalized = $email; $this->normalized = $email;
} }
public function value(): string
{
return $this->normalized;
}
public function __toString(): string public function __toString(): string
{ {
return $this->normalized; return $this->normalized;

View file

@ -37,4 +37,24 @@ class ViewController
return $response; return $response;
} }
public function login(Response $response): Response
{
$html = file_get_contents(
__DIR__ . '/../../views/templates/login.php'
);
$response->getBody()->write($html);
return $response;
}
public function register(Response $response): Response
{
$html = file_get_contents(
__DIR__ . '/../../views/templates/register.php'
);
$response->getBody()->write($html);
return $response;
}
} }

View file

@ -3,6 +3,10 @@
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use DI\Bridge\Slim\Bridge; use DI\Bridge\Slim\Bridge;
use Slim\Routing\RouteCollectorProxy;
use App\Auth\AdminMiddleware;
use App\Auth\AuthController;
use App\Auth\AuthMiddleware;
use App\View\ViewController; use App\View\ViewController;
use App\Text\TextController; use App\Text\TextController;
use App\Node\NodeController; use App\Node\NodeController;
@ -14,19 +18,48 @@ $app = Bridge::create($container);
// change first param to false for production // change first param to false for production
$app->addErrorMiddleware(true, true, true); $app->addErrorMiddleware(true, true, true);
$app->get('/home', [ViewController::class, 'home']); // Public routes (no auth required)
$app->get('/admin', [ViewController::class, 'admin']); $app->get('/login', [ViewController::class, 'login']);
$app->get('/admin/texts', [ViewController::class, 'texts']); $app->get('/register', [ViewController::class, 'register']);
$app->get('/admin/texts/{textId}', [ViewController::class, 'text']); $app->post('/api/auth/login', [AuthController::class, 'login']);
$app->post('/api/auth/register', [AuthController::class, 'register']);
$app->get('/api/texts', [TextController::class, 'getTexts']); // Authenticated routes (any logged-in user)
$app->get('/api/texts/{textId}', [TextController::class, 'getText']); $app->group('', function (RouteCollectorProxy $group) {
$app->post('/api/texts', [TextController::class, 'createText']); $group->get('/home', [ViewController::class, 'home']);
$app->get('/api/nodes/{textId}', [NodeController::class, 'getNodesOfText']); $group->post('/api/auth/logout', [AuthController::class, 'logout']);
$app->post('/api/nodes/bulk', [NodeController::class, 'bulkCreateNodes']); $group->get('/api/auth/me', [AuthController::class, 'me']);
$app->post('/api/nodes', [NodeController::class, 'createNode']);
$app->post('/api/plans', [PlanController::class, 'createPlan']); $group->get('/api/texts', [TextController::class, 'getTexts']);
$group->get(
'/api/texts/{textId}',
[TextController::class, 'getText']
);
$group->get(
'/api/nodes/{textId}',
[NodeController::class, 'getNodesOfText']
);
$group->post('/api/plans', [PlanController::class, 'createPlan']);
})->add(AuthMiddleware::class);
// Admin-only routes
$app->group('', function (RouteCollectorProxy $group) {
$group->get('/admin', [ViewController::class, 'admin']);
$group->get('/admin/texts', [ViewController::class, 'texts']);
$group->get(
'/admin/texts/{textId}',
[ViewController::class, 'text']
);
$group->post('/api/texts', [TextController::class, 'createText']);
$group->post(
'/api/nodes/bulk',
[NodeController::class, 'bulkCreateNodes']
);
$group->post('/api/nodes', [NodeController::class, 'createNode']);
})->add(AdminMiddleware::class)->add(AuthMiddleware::class);
return $app; return $app;

View file

@ -2,6 +2,14 @@
use DI; use DI;
use DI\Container; use DI\Container;
use App\Auth\BcryptPasswordHasher;
use App\Auth\Clock;
use App\Auth\JsonSessionRepository;
use App\Auth\PasswordHasher;
use App\Auth\RandomTokenGenerator;
use App\Auth\SessionRepository;
use App\Auth\SystemClock;
use App\Auth\TokenGenerator;
use App\Text\TextRepository; use App\Text\TextRepository;
use App\Text\JsonTextRepository; use App\Text\JsonTextRepository;
use App\Node\NodeRepository; use App\Node\NodeRepository;
@ -20,6 +28,11 @@ $container = new Container([
UserRepository::class => DI\autowire(JsonUserRepository::class), UserRepository::class => DI\autowire(JsonUserRepository::class),
ScheduledNodeRepository::class => ScheduledNodeRepository::class =>
DI\autowire(JsonScheduledNodeRepository::class), DI\autowire(JsonScheduledNodeRepository::class),
SessionRepository::class =>
DI\autowire(JsonSessionRepository::class),
TokenGenerator::class => DI\autowire(RandomTokenGenerator::class),
Clock::class => DI\autowire(SystemClock::class),
PasswordHasher::class => DI\autowire(BcryptPasswordHasher::class),
]); ]);
return $container; return $container;

View file

@ -1,6 +1,7 @@
describe('The admin page', () => { describe('The admin page', () => {
beforeEach(() => { beforeEach(() => {
cy.exec('npm run db:seed') cy.exec('npm run db:seed')
cy.loginAsAdmin()
cy.visit('/admin') cy.visit('/admin')
}) })
afterEach(() => { afterEach(() => {

View file

@ -1,6 +1,7 @@
describe('The admin text detail page', () => { describe('The admin text detail page', () => {
beforeEach(() => { beforeEach(() => {
cy.exec('npm run db:seed') cy.exec('npm run db:seed')
cy.loginAsAdmin()
cy.intercept('GET', '/api/texts/0').as('getText') cy.intercept('GET', '/api/texts/0').as('getText')
cy.intercept('GET', '/api/nodes/0').as('getNodes') cy.intercept('GET', '/api/nodes/0').as('getNodes')
cy.visit('/admin/texts/0') cy.visit('/admin/texts/0')

View file

@ -1,6 +1,7 @@
describe('Bulk add children on the admin text detail page', () => { describe('Bulk add children on the admin text detail page', () => {
beforeEach(() => { beforeEach(() => {
cy.exec('npm run db:seed') cy.exec('npm run db:seed')
cy.loginAsAdmin()
cy.intercept('GET', '/api/texts/0').as('getText') cy.intercept('GET', '/api/texts/0').as('getText')
cy.intercept('GET', '/api/nodes/0').as('getNodes') cy.intercept('GET', '/api/nodes/0').as('getNodes')
cy.visit('/admin/texts/0') cy.visit('/admin/texts/0')

View file

@ -1,6 +1,7 @@
describe('Toggle display of child nodes', () => { describe('Toggle display of child nodes', () => {
beforeEach(() => { beforeEach(() => {
cy.exec('npm run db:seed') cy.exec('npm run db:seed')
cy.loginAsAdmin()
cy.intercept('GET', '/api/texts/0').as('getText') cy.intercept('GET', '/api/texts/0').as('getText')
cy.intercept('GET', '/api/nodes/0').as('getNodes') cy.intercept('GET', '/api/nodes/0').as('getNodes')
cy.visit('/admin/texts/0') cy.visit('/admin/texts/0')

87
cypress/e2e/auth.cy.js Normal file
View file

@ -0,0 +1,87 @@
describe('Authentication flows', () => {
beforeEach(() => {
cy.exec('npm run db:seed')
})
afterEach(() => {
cy.exec('npm run db:wipe')
})
it('unauthenticated home redirects to login', () => {
cy.visit('/home')
cy.url().should('include', '/login')
})
it('login form submits and redirects to home', () => {
cy.visit('/login')
cy.get('#email').type('user@example.com')
cy.get('#password').type('password1')
cy.get('#login-form').submit()
cy.url().should('include', '/home')
cy.get('h1').should('contain', 'Home')
})
it('login shows error on wrong password', () => {
cy.visit('/login')
cy.get('#email').type('user@example.com')
cy.get('#password').type('wrongpassword')
cy.get('#login-form').submit()
cy.get('#login-error').should('be.visible')
cy.url().should('include', '/login')
})
it('register creates user and redirects to home', () => {
cy.visit('/register')
cy.get('#email').type('fresh@example.com')
cy.get('#password').type('password1')
cy.get('#register-form').submit()
cy.url().should('include', '/home')
})
it('register shows error on short password', () => {
cy.visit('/register')
cy.get('#email').type('another@example.com')
cy.get('#password').invoke(
'removeAttr',
'minlength'
)
cy.get('#password').type('short')
cy.get('#register-form').submit()
cy.get('#register-error').should('be.visible')
cy.url().should('include', '/register')
})
it('register shows error on duplicate email', () => {
cy.visit('/register')
cy.get('#email').type('user@example.com')
cy.get('#password').type('password1')
cy.get('#register-form').submit()
cy.get('#register-error').should('be.visible')
cy.url().should('include', '/register')
})
it('logout clears session and redirects to login', () => {
cy.loginAsUser()
cy.visit('/home')
cy.get('#logout').click()
cy.url().should('include', '/login')
cy.visit('/home')
cy.url().should('include', '/login')
})
it('non-admin user hitting /admin gets 403', () => {
cy.loginAsUser()
cy.request({
url: '/admin',
failOnStatusCode: false,
}).then((response) => {
expect(response.status).to.eq(403)
})
})
it('admin user can access /admin', () => {
cy.loginAsAdmin()
cy.visit('/admin')
cy.get('#texts').should('exist')
})
})

View file

@ -1,6 +1,7 @@
describe('The home page', () => { describe('The home page', () => {
beforeEach(() => { beforeEach(() => {
cy.exec('npm run db:seed') cy.exec('npm run db:seed')
cy.loginAsUser()
}) })
afterEach(() => { afterEach(() => {
cy.exec('npm run db:wipe') cy.exec('npm run db:wipe')

View file

@ -1,6 +1,7 @@
describe('Create plan modal on the home page', () => { describe('Create plan modal on the home page', () => {
beforeEach(() => { beforeEach(() => {
cy.exec('npm run db:seed') cy.exec('npm run db:seed')
cy.loginAsUser()
cy.intercept('GET', '/api/texts').as('getTexts') cy.intercept('GET', '/api/texts').as('getTexts')
cy.visit('/home') cy.visit('/home')
cy.wait('@getTexts') cy.wait('@getTexts')
@ -60,7 +61,6 @@ describe('Create plan modal on the home page', () => {
cy.wait('@createPlan').then((createPlanRequest) => { cy.wait('@createPlan').then((createPlanRequest) => {
expect(createPlanRequest.response.statusCode).to.eq(201) expect(createPlanRequest.response.statusCode).to.eq(201)
expect(createPlanRequest.request.body).to.deep.equal({ expect(createPlanRequest.request.body).to.deep.equal({
userId: 0,
textId: 0, textId: 0,
name: 'My reading plan', name: 'My reading plan',
dateStart: '2025-01-01', dateStart: '2025-01-01',

View file

@ -1,25 +1,15 @@
// *********************************************** Cypress.Commands.add('login', (email, password) => {
// This example commands.js shows you how to cy.request({
// create various custom commands and overwrite method: 'POST',
// existing commands. url: '/api/auth/login',
// body: { email, password },
// For more comprehensive examples of custom })
// commands please read more here: })
// https://on.cypress.io/custom-commands
// *********************************************** Cypress.Commands.add('loginAsAdmin', () => {
// cy.login('admin@example.com', 'admin1234')
// })
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... }) Cypress.Commands.add('loginAsUser', () => {
// cy.login('user@example.com', 'password1')
// })
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })

View file

@ -28,15 +28,27 @@ $nodes = [
], ],
]; ];
// Default credentials:
// admin@example.com / admin1234 (admin)
// user@example.com / password1 (regular user)
$users = [ $users = [
[ [
'id' => 0, 'id' => 0,
'email' => 'admin@example.com',
'passwordHash' => password_hash('admin1234', PASSWORD_DEFAULT),
'isAdmin' => true,
],
[
'id' => 1,
'email' => 'user@example.com', 'email' => 'user@example.com',
'passwordHash' => password_hash('password1', PASSWORD_DEFAULT),
'isAdmin' => false,
], ],
]; ];
$plans = []; $plans = [];
$scheduledNodes = []; $scheduledNodes = [];
$sessions = [];
$fileDataMap = [ $fileDataMap = [
'texts.json' => $texts, 'texts.json' => $texts,
@ -44,6 +56,7 @@ $fileDataMap = [
'users.json' => $users, 'users.json' => $users,
'plans.json' => $plans, 'plans.json' => $plans,
'scheduledNodes.json' => $scheduledNodes, 'scheduledNodes.json' => $scheduledNodes,
'sessions.json' => $sessions,
]; ];
foreach ($fileDataMap as $file => $data) { foreach ($fileDataMap as $file => $data) {

View file

@ -6,6 +6,7 @@ $files = [
'users.json', 'users.json',
'plans.json', 'plans.json',
'scheduledNodes.json', 'scheduledNodes.json',
'sessions.json',
]; ];
foreach ($files as $file) { foreach ($files as $file) {

75
public/js/auth.js Normal file
View file

@ -0,0 +1,75 @@
async function submitAuthForm(endpoint, email, password, errorElement) {
errorElement.hidden = true;
errorElement.textContent = '';
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ email, password }),
});
if (response.ok) {
window.location.href = '/home';
return;
}
let message = 'Something went wrong';
try {
const body = await response.json();
if (body.error) {
message = body.error;
}
} catch (parseError) {
// fall through to generic message
}
errorElement.textContent = message;
errorElement.hidden = false;
}
async function logout() {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'same-origin',
});
window.location.href = '/login';
}
document.addEventListener('DOMContentLoaded', () => {
const loginForm = document.getElementById('login-form');
if (loginForm !== null) {
const errorElement = document.getElementById('login-error');
loginForm.addEventListener('submit', async (submitEvent) => {
submitEvent.preventDefault();
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
await submitAuthForm(
'/api/auth/login',
email,
password,
errorElement,
);
});
}
const registerForm = document.getElementById('register-form');
if (registerForm !== null) {
const errorElement = document.getElementById('register-error');
registerForm.addEventListener('submit', async (submitEvent) => {
submitEvent.preventDefault();
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
await submitAuthForm(
'/api/auth/register',
email,
password,
errorElement,
);
});
}
const logoutButton = document.getElementById('logout');
if (logoutButton !== null) {
logoutButton.addEventListener('click', logout);
}
});

View file

@ -3,7 +3,9 @@ document.addEventListener('DOMContentLoaded', () => {
const createPlanModal = document.getElementById('create-plan-modal'); const createPlanModal = document.getElementById('create-plan-modal');
async function loadTexts() { async function loadTexts() {
const response = await fetch('/api/texts'); const response = await fetch('/api/texts', {
credentials: 'same-origin',
});
const texts = await response.json(); const texts = await response.json();
textsList.innerHTML = texts textsList.innerHTML = texts
.map(text => .map(text =>
@ -67,8 +69,8 @@ document.addEventListener('DOMContentLoaded', () => {
const response = await fetch('/api/plans', { const response = await fetch('/api/plans', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ body: JSON.stringify({
userId: 0,
textId: textId, textId: textId,
name: planName, name: planName,
dateStart: dateStart, dateStart: dateStart,

View file

@ -1,7 +1,7 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const textId = window.location.pathname.split('/').pop(); const textId = window.location.pathname.split('/').pop();
fetch('/api/texts/' + textId) fetch('/api/texts/' + textId, { credentials: 'same-origin' })
.then(res => res.json()) .then(res => res.json())
.then(text => { .then(text => {
const h1 = document.createElement('h1'); const h1 = document.createElement('h1');
@ -13,7 +13,7 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
function fetchAndRenderNodes(textId) { function fetchAndRenderNodes(textId) {
return fetch('/api/nodes/' + textId) return fetch('/api/nodes/' + textId, { credentials: 'same-origin' })
.then(res => res.json()) .then(res => res.json())
.then(nodes => { .then(nodes => {
const existing = document.querySelector('#text-detail > ul'); const existing = document.querySelector('#text-detail > ul');
@ -113,6 +113,7 @@ function toggleAddForm(li, parentNodeId, textId) {
fetch('/api/nodes', { fetch('/api/nodes', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ textId: parseInt(textId), title, parentNodeId }), body: JSON.stringify({ textId: parseInt(textId), title, parentNodeId }),
}) })
.then(res => { .then(res => {
@ -157,6 +158,7 @@ function toggleBulkAddForm(li, parentNodeId, textId) {
fetch('/api/nodes/bulk', { fetch('/api/nodes/bulk', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ textId: parseInt(textId), parentNodeId, titlePrefix, count }), body: JSON.stringify({ textId: parseInt(textId), parentNodeId, titlePrefix, count }),
}) })
.then(res => { .then(res => {

View file

@ -3,7 +3,9 @@ document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('texts-form'); const form = document.getElementById('texts-form');
async function loadTexts() { async function loadTexts() {
const res = await fetch('/api/texts'); const res = await fetch('/api/texts', {
credentials: 'same-origin',
});
const texts = await res.json(); const texts = await res.json();
textsList.innerHTML = texts.map(text => textsList.innerHTML = texts.map(text =>
'<li><a href=/admin/texts/' '<li><a href=/admin/texts/'
@ -18,6 +20,7 @@ document.addEventListener('DOMContentLoaded', () => {
const formData = new FormData(form); const formData = new FormData(form);
const res = await fetch('/api/texts', { const res = await fetch('/api/texts', {
method: 'POST', method: 'POST',
credentials: 'same-origin',
body: formData, body: formData,
}); });
if (res.ok) { if (res.ok) {

36
tests/Fakes/FakeClock.php Normal file
View file

@ -0,0 +1,36 @@
<?php
namespace Tests\Fakes;
use App\Auth\Clock;
use DateTimeImmutable;
use InvalidArgumentException;
class FakeClock implements Clock
{
public function __construct(
private DateTimeImmutable $currentTime,
) {
$this->assertUtc($currentTime);
}
public function now(): DateTimeImmutable
{
return $this->currentTime;
}
public function setTime(DateTimeImmutable $newTime): void
{
$this->assertUtc($newTime);
$this->currentTime = $newTime;
}
private function assertUtc(DateTimeImmutable $time): void
{
if ($time->getTimezone()->getOffset($time) !== 0) {
throw new InvalidArgumentException(
'FakeClock requires a DateTimeImmutable in UTC.'
);
}
}
}

View file

@ -0,0 +1,20 @@
<?php
namespace Tests\Fakes;
use App\Auth\PasswordHasher;
class FakePasswordHasher implements PasswordHasher
{
private const PREFIX = 'hashed:';
public function hash(string $plaintext): string
{
return self::PREFIX . $plaintext;
}
public function verify(string $plaintext, string $hash): bool
{
return $hash === self::PREFIX . $plaintext;
}
}

View file

@ -0,0 +1,48 @@
<?php
namespace Tests\Fakes;
use App\Auth\CreateSessionDto;
use App\Auth\Session;
use App\Auth\SessionRepository;
class FakeSessionRepository implements SessionRepository
{
/**
* @var Session[]
*/
private array $existingSessions = [];
public function create(CreateSessionDto $dto): Session
{
$session = new Session(
token: $dto->token,
userId: $dto->userId,
createdAt: $dto->createdAt,
expiresAt: $dto->expiresAt,
);
$this->existingSessions[$dto->token] = $session;
return $session;
}
public function findByToken(string $token): ?Session
{
$session = $this->existingSessions[$token] ?? null;
if ($session === null) {
return null;
}
return new Session(
token: $session->getToken(),
userId: $session->getUserId(),
createdAt: $session->getCreatedAt(),
expiresAt: $session->getExpiresAt(),
);
}
public function deleteByToken(string $token): void
{
unset($this->existingSessions[$token]);
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace Tests\Fakes;
use App\Auth\TokenGenerator;
class FakeTokenGenerator implements TokenGenerator
{
private int $callCount = 0;
/**
* @param string[] $predefinedTokens
*/
public function __construct(
private array $predefinedTokens,
) {}
public function generate(): string
{
$index = $this->callCount % count($this->predefinedTokens);
$this->callCount++;
return $this->predefinedTokens[$index];
}
}

View file

@ -5,6 +5,7 @@ namespace Tests\Fakes;
use App\User\UseCases\CreateUserDto; use App\User\UseCases\CreateUserDto;
use App\User\User; use App\User\User;
use App\User\UserRepository; use App\User\UserRepository;
use App\ValueObjects\EmailAddress;
class FakeUserRepository implements UserRepository class FakeUserRepository implements UserRepository
{ {
@ -28,6 +29,28 @@ class FakeUserRepository implements UserRepository
return new User( return new User(
id: $user->getId(), id: $user->getId(),
email: $user->getEmail(), email: $user->getEmail(),
passwordHash: $user->getPasswordHash(),
isAdmin: $user->isAdmin(),
);
}
public function findByEmail(EmailAddress $email): ?User
{
$user = array_find(
$this->existingUsers,
function (User $user) use ($email) {
return $user->getEmail()->value() === $email->value();
}
);
if ($user === null) {
return null;
}
return new User(
id: $user->getId(),
email: $user->getEmail(),
passwordHash: $user->getPasswordHash(),
isAdmin: $user->isAdmin(),
); );
} }
@ -37,6 +60,8 @@ class FakeUserRepository implements UserRepository
$user = new User( $user = new User(
id: $id, id: $id,
email: $dto->email, email: $dto->email,
passwordHash: $dto->passwordHash,
isAdmin: $dto->isAdmin,
); );
$this->existingUsers[$id] = $user; $this->existingUsers[$id] = $user;

View file

@ -0,0 +1,118 @@
<?php
namespace Tests\Unit\Auth\Middleware;
use App\Auth\AdminMiddleware;
use App\User\User;
use App\ValueObjects\EmailAddress;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Slim\Psr7\Factory\ServerRequestFactory;
use Slim\Psr7\Response;
class AdminMiddlewareTest extends TestCase
{
private AdminMiddleware $middleware;
public function setUp(): void
{
$this->middleware = new AdminMiddleware();
}
private function makeApiRequest(?User $user): ServerRequestInterface
{
$request = new ServerRequestFactory()
->createServerRequest('POST', 'http://localhost/api/texts');
if ($user !== null) {
$request = $request->withAttribute('user', $user);
}
return $request;
}
private function makeHtmlRequest(?User $user): ServerRequestInterface
{
$request = new ServerRequestFactory()
->createServerRequest('GET', 'http://localhost/admin')
->withHeader('Accept', 'text/html');
if ($user !== null) {
$request = $request->withAttribute('user', $user);
}
return $request;
}
private function makeHandler(): RequestHandlerInterface
{
return new class implements RequestHandlerInterface {
public bool $wasCalled = false;
public function handle(
ServerRequestInterface $request
): \Psr\Http\Message\ResponseInterface {
$this->wasCalled = true;
return new Response(200);
}
};
}
private function makeUser(bool $isAdmin): User
{
return new User(
id: 1,
email: new EmailAddress('test@test.com'),
passwordHash: '',
isAdmin: $isAdmin,
);
}
public function test_passes_through_when_user_is_admin(): void
{
$handler = $this->makeHandler();
$response = $this->middleware->process(
$this->makeApiRequest($this->makeUser(isAdmin: true)),
$handler,
);
$this->assertTrue($handler->wasCalled);
$this->assertEquals(200, $response->getStatusCode());
}
public function test_returns_403_json_when_user_not_admin_for_api(): void
{
$response = $this->middleware->process(
$this->makeApiRequest($this->makeUser(isAdmin: false)),
$this->makeHandler(),
);
$this->assertEquals(403, $response->getStatusCode());
$this->assertStringContainsString(
'application/json',
$response->getHeaderLine('Content-Type')
);
}
public function test_returns_403_html_when_user_not_admin_for_view(): void
{
$response = $this->middleware->process(
$this->makeHtmlRequest($this->makeUser(isAdmin: false)),
$this->makeHandler(),
);
$this->assertEquals(403, $response->getStatusCode());
$this->assertStringContainsString(
'403 Forbidden',
(string) $response->getBody()
);
}
public function test_returns_403_when_no_user_attribute(): void
{
$response = $this->middleware->process(
$this->makeApiRequest(null),
$this->makeHandler(),
);
$this->assertEquals(403, $response->getStatusCode());
}
}

View file

@ -0,0 +1,161 @@
<?php
namespace Tests\Unit\Auth\Middleware;
use App\Auth\AuthMiddleware;
use App\Auth\CreateSessionDto;
use App\User\UseCases\CreateUserDto;
use App\User\User;
use App\ValueObjects\EmailAddress;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Slim\Psr7\Factory\ServerRequestFactory;
use Slim\Psr7\Response;
use Tests\Fakes\FakeClock;
use Tests\Fakes\FakeSessionRepository;
use Tests\Fakes\FakeUserRepository;
class AuthMiddlewareTest extends TestCase
{
private FakeUserRepository $userRepo;
private FakeSessionRepository $sessionRepo;
private FakeClock $clock;
private AuthMiddleware $middleware;
private User $user;
public function setUp(): void
{
$this->userRepo = new FakeUserRepository();
$this->sessionRepo = new FakeSessionRepository();
$this->clock = new FakeClock(
new DateTimeImmutable('2025-01-01T12:00:00+00:00')
);
$this->user = $this->userRepo->create(new CreateUserDto(
email: new EmailAddress('test@test.com'),
passwordHash: '',
));
$this->middleware = new AuthMiddleware(
$this->sessionRepo,
$this->userRepo,
$this->clock,
);
}
private function makeApiRequest(
?string $cookieToken = null
): ServerRequestInterface {
$request = new ServerRequestFactory()
->createServerRequest('GET', 'http://localhost/api/texts');
if ($cookieToken !== null) {
$request = $request->withCookieParams([
'auth_token' => $cookieToken,
]);
}
return $request;
}
private function makeHtmlRequest(
?string $cookieToken = null
): ServerRequestInterface {
$request = new ServerRequestFactory()
->createServerRequest('GET', 'http://localhost/home')
->withHeader('Accept', 'text/html');
if ($cookieToken !== null) {
$request = $request->withCookieParams([
'auth_token' => $cookieToken,
]);
}
return $request;
}
private function makeHandler(): RequestHandlerInterface
{
return new class implements RequestHandlerInterface {
public ?ServerRequestInterface $capturedRequest = null;
public function handle(
ServerRequestInterface $request
): \Psr\Http\Message\ResponseInterface {
$this->capturedRequest = $request;
return new Response(200);
}
};
}
public function test_returns_401_json_when_cookie_missing(): void
{
$response = $this->middleware->process(
$this->makeApiRequest(),
$this->makeHandler(),
);
$this->assertEquals(401, $response->getStatusCode());
$this->assertStringContainsString(
'application/json',
$response->getHeaderLine('Content-Type')
);
}
public function test_returns_401_when_token_not_in_repo(): void
{
$response = $this->middleware->process(
$this->makeApiRequest('unknown-token'),
$this->makeHandler(),
);
$this->assertEquals(401, $response->getStatusCode());
}
public function test_returns_401_when_token_expired(): void
{
$this->sessionRepo->create(new CreateSessionDto(
token: 'expired-token',
userId: $this->user->getId(),
createdAt: new DateTimeImmutable('2024-12-01T00:00:00+00:00'),
expiresAt: new DateTimeImmutable('2024-12-08T00:00:00+00:00'),
));
$response = $this->middleware->process(
$this->makeApiRequest('expired-token'),
$this->makeHandler(),
);
$this->assertEquals(401, $response->getStatusCode());
}
public function test_attaches_user_to_request_on_success(): void
{
$this->sessionRepo->create(new CreateSessionDto(
token: 'valid-token',
userId: $this->user->getId(),
createdAt: new DateTimeImmutable('2025-01-01T00:00:00+00:00'),
expiresAt: new DateTimeImmutable('2025-01-08T00:00:00+00:00'),
));
$handler = $this->makeHandler();
$this->middleware->process(
$this->makeApiRequest('valid-token'),
$handler,
);
$attached = $handler->capturedRequest->getAttribute('user');
$this->assertInstanceOf(User::class, $attached);
$this->assertEquals(
'test@test.com',
$attached->getEmail()->value()
);
}
public function test_redirects_to_login_when_html_unauthenticated(): void
{
$response = $this->middleware->process(
$this->makeHtmlRequest(),
$this->makeHandler(),
);
$this->assertEquals(302, $response->getStatusCode());
$this->assertEquals('/login', $response->getHeaderLine('Location'));
}
}

View file

@ -0,0 +1,85 @@
<?php
namespace Tests\Unit\Auth\UseCases;
use App\Auth\UseCases\CreateSession;
use App\User\User;
use App\ValueObjects\EmailAddress;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use Tests\Fakes\FakeClock;
use Tests\Fakes\FakeSessionRepository;
use Tests\Fakes\FakeTokenGenerator;
class CreateSessionTest extends TestCase
{
private FakeSessionRepository $sessionRepo;
private FakeTokenGenerator $tokenGenerator;
private FakeClock $clock;
private CreateSession $useCase;
private User $user;
public function setUp(): void
{
$this->sessionRepo = new FakeSessionRepository();
$this->tokenGenerator = new FakeTokenGenerator(
['generated-token-abc']
);
$this->clock = new FakeClock(
new DateTimeImmutable('2025-01-01T12:00:00+00:00')
);
$this->useCase = new CreateSession(
$this->sessionRepo,
$this->tokenGenerator,
$this->clock,
);
$this->user = new User(
id: 7,
email: new EmailAddress('test@test.com'),
passwordHash: 'hashed:password1',
isAdmin: false,
);
}
public function test_creates_session_for_user(): void
{
$session = $this->useCase->execute($this->user);
$this->assertEquals(7, $session->getUserId());
}
public function test_session_token_comes_from_generator(): void
{
$session = $this->useCase->execute($this->user);
$this->assertEquals('generated-token-abc', $session->getToken());
}
public function test_session_created_at_is_now(): void
{
$session = $this->useCase->execute($this->user);
$this->assertEquals(
new DateTimeImmutable('2025-01-01T12:00:00+00:00'),
$session->getCreatedAt()
);
}
public function test_session_expires_in_seven_days(): void
{
$session = $this->useCase->execute($this->user);
$this->assertEquals(
new DateTimeImmutable('2025-01-08T12:00:00+00:00'),
$session->getExpiresAt()
);
}
public function test_session_is_persisted(): void
{
$this->useCase->execute($this->user);
$found = $this->sessionRepo->findByToken('generated-token-abc');
$this->assertNotNull($found);
}
}

View file

@ -39,6 +39,7 @@ class CreatePlanTest extends TestCase
$this->scheduledNodeRepo = new FakeScheduledNodeRepository(); $this->scheduledNodeRepo = new FakeScheduledNodeRepository();
$this->userRepo->create(new CreateUserDto( $this->userRepo->create(new CreateUserDto(
email: new EmailAddress('test@test.com'), email: new EmailAddress('test@test.com'),
passwordHash: '',
)); ));
$this->createScheduledNode = new CreateScheduledNode( $this->createScheduledNode = new CreateScheduledNode(
scheduledNodeRepo: $this->scheduledNodeRepo, scheduledNodeRepo: $this->scheduledNodeRepo,
@ -232,8 +233,8 @@ class CreatePlanTest extends TestCase
)); ));
} }
public function test_scheduled_nodes_are_scheduled_on_different_days(): void public function test_scheduled_nodes_are_scheduled_on_different_days(): void
{ {
$text = $this->textRepo->find(0); $text = $this->textRepo->find(0);
$rootNode = $this->nodeRepo->create(new CreateNodeDto( $rootNode = $this->nodeRepo->create(new CreateNodeDto(
text: $text, text: $text,
@ -269,10 +270,10 @@ class CreatePlanTest extends TestCase
new DateTimeImmutable('2025-01-02'), new DateTimeImmutable('2025-01-02'),
$childTwo->getDate() $childTwo->getDate()
); );
} }
public function test_more_scheduled_nodes_than_days(): void public function test_more_scheduled_nodes_than_days(): void
{ {
$text = $this->textRepo->find(0); $text = $this->textRepo->find(0);
$rootNode = $this->nodeRepo->create(new CreateNodeDto( $rootNode = $this->nodeRepo->create(new CreateNodeDto(
text: $text, text: $text,
@ -319,5 +320,5 @@ class CreatePlanTest extends TestCase
new DateTimeImmutable('2025-01-02'), new DateTimeImmutable('2025-01-02'),
$childThree->getDate() $childThree->getDate()
); );
} }
} }

View file

@ -30,7 +30,12 @@ class CreateScheduledNodeTest extends TestCase
$this->planRepo = new FakePlanRepository(); $this->planRepo = new FakePlanRepository();
$this->planRepo->create(new CreatePlanDto( $this->planRepo->create(new CreatePlanDto(
name: 'testplan', name: 'testplan',
user: new User(0, new EmailAddress('test@test.com')), user: new User(
id: 0,
email: new EmailAddress('test@test.com'),
passwordHash: 'hashed:password1',
isAdmin: false,
),
)); ));
$this->useCase = new CreateScheduledNode( $this->useCase = new CreateScheduledNode(
$this->scheduledNodeRepo, $this->scheduledNodeRepo,

View file

@ -0,0 +1,95 @@
<?php
namespace Tests\Unit\User\UseCases;
use App\Exceptions\BadRequestException;
use App\Exceptions\UnauthorizedException;
use App\User\UseCases\AuthenticateUser;
use App\User\UseCases\AuthenticateUserRequest;
use App\User\UseCases\CreateUser;
use App\User\UseCases\CreateUserRequest;
use App\User\User;
use PHPUnit\Framework\TestCase;
use Tests\Fakes\FakePasswordHasher;
use Tests\Fakes\FakeUserRepository;
class AuthenticateUserTest extends TestCase
{
private FakeUserRepository $userRepo;
private FakePasswordHasher $passwordHasher;
private AuthenticateUser $useCase;
public function setUp(): void
{
$this->userRepo = new FakeUserRepository();
$this->passwordHasher = new FakePasswordHasher();
$createUser = new CreateUser(
$this->userRepo,
$this->passwordHasher,
);
$createUser->execute(new CreateUserRequest(
email: 'test@test.com',
password: 'password1',
isAdmin: false,
));
$this->useCase = new AuthenticateUser(
$this->userRepo,
$this->passwordHasher,
);
}
public function test_returns_user_on_valid_credentials(): void
{
$user = $this->useCase->execute(new AuthenticateUserRequest(
email: 'test@test.com',
password: 'password1',
));
$this->assertInstanceOf(User::class, $user);
$this->assertEquals('test@test.com', $user->getEmail()->value());
}
public function test_throws_bad_request_when_email_null(): void
{
$this->expectException(BadRequestException::class);
$this->expectExceptionMessage('email is required');
$this->useCase->execute(new AuthenticateUserRequest(
email: null,
password: 'password1',
));
}
public function test_throws_bad_request_when_password_null(): void
{
$this->expectException(BadRequestException::class);
$this->expectExceptionMessage('password is required');
$this->useCase->execute(new AuthenticateUserRequest(
email: 'test@test.com',
password: null,
));
}
public function test_throws_unauthorized_on_wrong_password(): void
{
$this->expectException(UnauthorizedException::class);
$this->expectExceptionMessage('invalid credentials');
$this->useCase->execute(new AuthenticateUserRequest(
email: 'test@test.com',
password: 'wrongpassword',
));
}
public function test_throws_unauthorized_when_email_not_found(): void
{
$this->expectException(UnauthorizedException::class);
$this->expectExceptionMessage('invalid credentials');
$this->useCase->execute(new AuthenticateUserRequest(
email: 'missing@test.com',
password: 'password1',
));
}
}

View file

@ -6,33 +6,119 @@ use App\Exceptions\BadRequestException;
use App\User\User; use App\User\User;
use App\User\UseCases\CreateUser; use App\User\UseCases\CreateUser;
use App\User\UseCases\CreateUserRequest; use App\User\UseCases\CreateUserRequest;
use Tests\Fakes\FakePasswordHasher;
use Tests\Fakes\FakeUserRepository; use Tests\Fakes\FakeUserRepository;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
class CreateUserTest extends TestCase class CreateUserTest extends TestCase
{ {
private FakeUserRepository $userRepo;
private FakePasswordHasher $passwordHasher;
private CreateUser $useCase;
public function setUp(): void
{
$this->userRepo = new FakeUserRepository();
$this->passwordHasher = new FakePasswordHasher();
$this->useCase = new CreateUser(
$this->userRepo,
$this->passwordHasher,
);
}
public function test_create_user(): void public function test_create_user(): void
{ {
$userRepo = new FakeUserRepository(); $this->useCase->execute(new CreateUserRequest(
$useCase = new CreateUser($userRepo);
$useCase->execute(new CreateUserRequest(
email: 'test@test.com', email: 'test@test.com',
password: 'password1',
isAdmin: false,
)); ));
$user = $userRepo->find(0); $user = $this->userRepo->find(0);
$this->assertInstanceOf(User::class, $user); $this->assertInstanceOf(User::class, $user);
$this->assertEquals('test@test.com', $user->getEmail()); $this->assertEquals('test@test.com', $user->getEmail());
} }
public function test_throws_if_email_is_null(): void public function test_throws_if_email_is_null(): void
{ {
$userRepo = new FakeUserRepository();
$useCase = new CreateUser($userRepo);
$this->expectException(BadRequestException::class); $this->expectException(BadRequestException::class);
$this->expectExceptionMessage('email is required'); $this->expectExceptionMessage('email is required');
$useCase->execute(new CreateUserRequest( $this->useCase->execute(new CreateUserRequest(
email: null, email: null,
password: 'password1',
isAdmin: false,
)); ));
} }
public function test_is_admin_can_be_set_true(): void
{
$this->useCase->execute(new CreateUserRequest(
email: 'test@test.com',
password: 'password1',
isAdmin: true,
));
$user = $this->userRepo->find(0);
$this->assertTrue($user->isAdmin());
}
public function test_throws_when_email_already_taken(): void
{
$this->useCase->execute(new CreateUserRequest(
email: 'test@test.com',
password: 'password1',
isAdmin: false,
));
$this->expectException(BadRequestException::class);
$this->expectExceptionMessage('email already taken');
$this->useCase->execute(new CreateUserRequest(
email: 'test@test.com',
password: 'password1',
isAdmin: false
));
}
public function test_throws_if_password_is_null(): void
{
$this->expectException(BadRequestException::class);
$this->expectExceptionMessage('password is required');
$this->useCase->execute(new CreateUserRequest(
email: 'test@test.com',
password: null,
isAdmin: false,
));
}
public function test_throws_if_password_too_short(): void
{
$this->expectException(BadRequestException::class);
$this->expectExceptionMessage(
'password must be at least 8 characters'
);
$this->useCase->execute(new CreateUserRequest(
email: 'test@test.com',
password: 'short',
isAdmin: false,
));
}
public function test_stores_hashed_password(): void
{
$this->useCase->execute(new CreateUserRequest(
email: 'test@test.com',
password: 'password1',
isAdmin: false,
));
$user = $this->userRepo->find(0);
$this->assertNotEquals('password1', $user->getPasswordHash());
$this->assertTrue(
$this->passwordHasher->verify(
'password1',
$user->getPasswordHash()
)
);
}
} }

View file

@ -0,0 +1,294 @@
<?php
namespace Tests\e2e\Controllers;
use App\Auth\AuthController;
use App\Auth\CreateSessionDto;
use App\Auth\UseCases\CreateSession;
use App\User\UseCases\AuthenticateUser;
use App\User\UseCases\CreateUser;
use App\User\UseCases\CreateUserRequest;
use App\User\User;
use App\ValueObjects\EmailAddress;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface;
use Slim\Psr7\Factory\ServerRequestFactory;
use Slim\Psr7\Factory\StreamFactory;
use Slim\Psr7\Response;
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 $userRepo;
private FakeSessionRepository $sessionRepo;
private FakeTokenGenerator $tokenGenerator;
private FakeClock $clock;
private FakePasswordHasher $passwordHasher;
private CreateUser $createUser;
private AuthenticateUser $authenticateUser;
private CreateSession $createSession;
private AuthController $controller;
public function setUp(): void
{
$this->userRepo = new FakeUserRepository();
$this->sessionRepo = new FakeSessionRepository();
$this->tokenGenerator = new FakeTokenGenerator(
['session-token-xyz']
);
$this->clock = new FakeClock(
new DateTimeImmutable('2025-01-01T12:00:00+00:00')
);
$this->passwordHasher = new FakePasswordHasher();
$this->createUser = new CreateUser(
$this->userRepo,
$this->passwordHasher,
);
$this->authenticateUser = new AuthenticateUser(
$this->userRepo,
$this->passwordHasher,
);
$this->createSession = new CreateSession(
$this->sessionRepo,
$this->tokenGenerator,
$this->clock,
);
$this->createUser->execute(new CreateUserRequest(
email: 'existing@test.com',
password: 'password1',
isAdmin: false,
));
$this->controller = new AuthController();
}
private function makeJsonRequest(
string $method,
string $path,
array $data,
): ServerRequestInterface {
$body = new StreamFactory()->createStream(json_encode($data));
return new ServerRequestFactory()
->createServerRequest($method, 'http://localhost' . $path)
->withHeader('Content-Type', 'application/json')
->withBody($body);
}
public function test_login_returns_200_and_user(): void
{
$response = $this->controller->login(
$this->makeJsonRequest('POST', '/api/auth/login', [
'email' => 'existing@test.com',
'password' => 'password1',
]),
new Response(),
$this->authenticateUser,
$this->createSession,
);
$this->assertEquals(200, $response->getStatusCode());
$body = json_decode($response->getBody(), true);
$this->assertEquals(
'existing@test.com',
$body['user']['email']
);
}
public function test_login_sets_auth_cookie(): void
{
$response = $this->controller->login(
$this->makeJsonRequest('POST', '/api/auth/login', [
'email' => 'existing@test.com',
'password' => 'password1',
]),
new Response(),
$this->authenticateUser,
$this->createSession,
);
$setCookie = $response->getHeaderLine('Set-Cookie');
$this->assertStringContainsString(
'auth_token=session-token-xyz',
$setCookie
);
$this->assertStringContainsString('HttpOnly', $setCookie);
$this->assertStringContainsString('SameSite=Lax', $setCookie);
$this->assertStringContainsString('Path=/', $setCookie);
}
public function test_login_creates_session(): void
{
$this->controller->login(
$this->makeJsonRequest('POST', '/api/auth/login', [
'email' => 'existing@test.com',
'password' => 'password1',
]),
new Response(),
$this->authenticateUser,
$this->createSession,
);
$this->assertNotNull(
$this->sessionRepo->findByToken('session-token-xyz')
);
}
public function test_login_returns_401_on_wrong_password(): void
{
$response = $this->controller->login(
$this->makeJsonRequest('POST', '/api/auth/login', [
'email' => 'existing@test.com',
'password' => 'wrongpassword',
]),
new Response(),
$this->authenticateUser,
$this->createSession,
);
$this->assertEquals(401, $response->getStatusCode());
}
public function test_login_returns_400_when_email_missing(): void
{
$response = $this->controller->login(
$this->makeJsonRequest('POST', '/api/auth/login', [
'password' => 'password1',
]),
new Response(),
$this->authenticateUser,
$this->createSession,
);
$this->assertEquals(400, $response->getStatusCode());
}
public function test_register_creates_user_and_logs_in(): void
{
$response = $this->controller->register(
$this->makeJsonRequest('POST', '/api/auth/register', [
'email' => 'new@test.com',
'password' => 'password1',
]),
new Response(),
$this->createUser,
$this->createSession,
);
$this->assertEquals(200, $response->getStatusCode());
$body = json_decode($response->getBody(), true);
$this->assertEquals('new@test.com', $body['user']['email']);
$setCookie = $response->getHeaderLine('Set-Cookie');
$this->assertStringContainsString(
'auth_token=session-token-xyz',
$setCookie
);
}
public function test_register_returns_400_on_short_password(): void
{
$response = $this->controller->register(
$this->makeJsonRequest('POST', '/api/auth/register', [
'email' => 'new@test.com',
'password' => 'short',
]),
new Response(),
$this->createUser,
$this->createSession,
);
$this->assertEquals(400, $response->getStatusCode());
}
public function test_register_returns_400_on_duplicate_email(): void
{
$response = $this->controller->register(
$this->makeJsonRequest('POST', '/api/auth/register', [
'email' => 'existing@test.com',
'password' => 'password1',
]),
new Response(),
$this->createUser,
$this->createSession,
);
$this->assertEquals(400, $response->getStatusCode());
}
public function test_register_ignores_is_admin_in_body(): void
{
$this->controller->register(
$this->makeJsonRequest('POST', '/api/auth/register', [
'email' => 'sneaky@test.com',
'password' => 'password1',
'isAdmin' => true,
]),
new Response(),
$this->createUser,
$this->createSession,
);
$newUser = $this->userRepo->findByEmail(
new EmailAddress('sneaky@test.com')
);
$this->assertFalse($newUser->isAdmin());
}
public function test_logout_deletes_session_and_clears_cookie(): void
{
$this->sessionRepo->create(new CreateSessionDto(
token: 'existing-session',
userId: 0,
createdAt: new DateTimeImmutable('2025-01-01T00:00:00+00:00'),
expiresAt: new DateTimeImmutable('2025-01-08T00:00:00+00:00'),
));
$request = $this->makeJsonRequest(
'POST',
'/api/auth/logout',
[]
)->withCookieParams(['auth_token' => 'existing-session']);
$response = $this->controller->logout(
$request,
new Response(),
$this->sessionRepo,
);
$this->assertEquals(204, $response->getStatusCode());
$this->assertNull(
$this->sessionRepo->findByToken('existing-session')
);
$this->assertStringContainsString(
'auth_token=;',
$response->getHeaderLine('Set-Cookie')
);
}
public function test_me_returns_current_user(): void
{
$user = new User(
id: 5,
email: new EmailAddress('me@test.com'),
passwordHash: '',
isAdmin: true,
);
$request = new ServerRequestFactory()
->createServerRequest('GET', 'http://localhost/api/auth/me')
->withAttribute('user', $user);
$response = $this->controller->me($request, new Response());
$this->assertEquals(200, $response->getStatusCode());
$body = json_decode($response->getBody(), true);
$this->assertEquals(5, $body['user']['id']);
$this->assertEquals('me@test.com', $body['user']['email']);
$this->assertTrue($body['user']['isAdmin']);
}
}

View file

@ -8,6 +8,7 @@ use App\Plan\UseCases\CreatePlan;
use App\ScheduledNode\UseCases\CreateScheduledNode; use App\ScheduledNode\UseCases\CreateScheduledNode;
use App\Text\CreateTextDto; use App\Text\CreateTextDto;
use App\User\UseCases\CreateUserDto; use App\User\UseCases\CreateUserDto;
use App\User\User;
use App\ValueObjects\EmailAddress; use App\ValueObjects\EmailAddress;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
@ -29,6 +30,7 @@ class PlanControllerTest extends TestCase
private FakeScheduledNodeRepository $scheduledNodeRepo; private FakeScheduledNodeRepository $scheduledNodeRepo;
private CreatePlan $createPlan; private CreatePlan $createPlan;
private PlanController $controller; private PlanController $controller;
private User $user;
public function setUp(): void public function setUp(): void
{ {
@ -38,8 +40,9 @@ class PlanControllerTest extends TestCase
$this->nodeRepo = new FakeNodeRepository(); $this->nodeRepo = new FakeNodeRepository();
$this->scheduledNodeRepo = new FakeScheduledNodeRepository(); $this->scheduledNodeRepo = new FakeScheduledNodeRepository();
$this->userRepo->create(new CreateUserDto( $this->user = $this->userRepo->create(new CreateUserDto(
email: new EmailAddress('test@test.com'), email: new EmailAddress('test@test.com'),
passwordHash: '',
)); ));
$text = $this->textRepo->create(new CreateTextDto('testname')); $text = $this->textRepo->create(new CreateTextDto('testname'));
$this->nodeRepo->create(new CreateNodeDto( $this->nodeRepo->create(new CreateNodeDto(
@ -68,6 +71,7 @@ class PlanControllerTest extends TestCase
return new ServerRequestFactory() return new ServerRequestFactory()
->createServerRequest('POST', 'http://localhost/api/plans') ->createServerRequest('POST', 'http://localhost/api/plans')
->withHeader('Content-Type', 'application/json') ->withHeader('Content-Type', 'application/json')
->withAttribute('user', $this->user)
->withBody($body); ->withBody($body);
} }
@ -75,7 +79,6 @@ class PlanControllerTest extends TestCase
{ {
$response = $this->controller->createPlan( $response = $this->controller->createPlan(
$this->makeRequest([ $this->makeRequest([
'userId' => 0,
'textId' => 0, 'textId' => 0,
'name' => 'My Plan', 'name' => 'My Plan',
'dateStart' => '2025-01-01', 'dateStart' => '2025-01-01',
@ -91,29 +94,33 @@ class PlanControllerTest extends TestCase
$this->assertEquals('My Plan', $body['name']); $this->assertEquals('My Plan', $body['name']);
} }
public function test_create_plan_returns_400_when_user_id_missing(): void public function test_create_plan_returns_401_when_no_user(): void
{ {
$requestWithoutUser = new ServerRequestFactory()
->createServerRequest('POST', 'http://localhost/api/plans')
->withHeader('Content-Type', 'application/json')
->withBody(
new StreamFactory()->createStream(json_encode([
'textId' => 0,
'name' => 'My Plan',
'dateStart' => '2025-01-01',
'dateEnd' => '2025-01-01',
]))
);
$response = $this->controller->createPlan( $response = $this->controller->createPlan(
$this->makeRequest([ $requestWithoutUser,
'textId' => 0,
'name' => 'My Plan',
'dateStart' => '2025-01-01',
'dateEnd' => '2025-01-01',
]),
new Response(), new Response(),
$this->createPlan, $this->createPlan,
); );
$this->assertEquals(400, $response->getStatusCode()); $this->assertEquals(401, $response->getStatusCode());
$body = json_decode($response->getBody(), true);
$this->assertArrayHasKey('error', $body);
} }
public function test_create_plan_returns_400_when_text_id_missing(): void public function test_create_plan_returns_400_when_text_id_missing(): void
{ {
$response = $this->controller->createPlan( $response = $this->controller->createPlan(
$this->makeRequest([ $this->makeRequest([
'userId' => 0,
'name' => 'My Plan', 'name' => 'My Plan',
'dateStart' => '2025-01-01', 'dateStart' => '2025-01-01',
'dateEnd' => '2025-01-01', 'dateEnd' => '2025-01-01',
@ -131,7 +138,6 @@ class PlanControllerTest extends TestCase
{ {
$response = $this->controller->createPlan( $response = $this->controller->createPlan(
$this->makeRequest([ $this->makeRequest([
'userId' => 0,
'textId' => 0, 'textId' => 0,
'dateStart' => '2025-01-01', 'dateStart' => '2025-01-01',
'dateEnd' => '2025-01-01', 'dateEnd' => '2025-01-01',
@ -149,7 +155,6 @@ class PlanControllerTest extends TestCase
{ {
$response = $this->controller->createPlan( $response = $this->controller->createPlan(
$this->makeRequest([ $this->makeRequest([
'userId' => 0,
'textId' => 0, 'textId' => 0,
'name' => 'My Plan', 'name' => 'My Plan',
'dateEnd' => '2025-01-01', 'dateEnd' => '2025-01-01',
@ -167,7 +172,6 @@ class PlanControllerTest extends TestCase
{ {
$response = $this->controller->createPlan( $response = $this->controller->createPlan(
$this->makeRequest([ $this->makeRequest([
'userId' => 0,
'textId' => 0, 'textId' => 0,
'name' => 'My Plan', 'name' => 'My Plan',
'dateStart' => '2025-01-01', 'dateStart' => '2025-01-01',
@ -185,7 +189,6 @@ class PlanControllerTest extends TestCase
{ {
$response = $this->controller->createPlan( $response = $this->controller->createPlan(
$this->makeRequest([ $this->makeRequest([
'userId' => 0,
'textId' => 0, 'textId' => 0,
'name' => 'My Plan', 'name' => 'My Plan',
'dateStart' => '2025-01-02', 'dateStart' => '2025-01-02',
@ -200,30 +203,10 @@ class PlanControllerTest extends TestCase
$this->assertArrayHasKey('error', $body); $this->assertArrayHasKey('error', $body);
} }
public function test_create_plan_returns_404_when_user_not_found(): void
{
$response = $this->controller->createPlan(
$this->makeRequest([
'userId' => 99,
'textId' => 0,
'name' => 'My Plan',
'dateStart' => '2025-01-01',
'dateEnd' => '2025-01-01',
]),
new Response(),
$this->createPlan,
);
$this->assertEquals(404, $response->getStatusCode());
$body = json_decode($response->getBody(), true);
$this->assertArrayHasKey('error', $body);
}
public function test_create_plan_returns_404_when_text_not_found(): void public function test_create_plan_returns_404_when_text_not_found(): void
{ {
$response = $this->controller->createPlan( $response = $this->controller->createPlan(
$this->makeRequest([ $this->makeRequest([
'userId' => 0,
'textId' => 99, 'textId' => 99,
'name' => 'My Plan', 'name' => 'My Plan',
'dateStart' => '2025-01-01', 'dateStart' => '2025-01-01',
@ -242,7 +225,6 @@ class PlanControllerTest extends TestCase
{ {
$this->controller->createPlan( $this->controller->createPlan(
$this->makeRequest([ $this->makeRequest([
'userId' => 0,
'textId' => 0, 'textId' => 0,
'name' => 'Persistent Plan', 'name' => 'Persistent Plan',
'dateStart' => '2025-01-01', 'dateStart' => '2025-01-01',
@ -261,7 +243,6 @@ class PlanControllerTest extends TestCase
{ {
$this->controller->createPlan( $this->controller->createPlan(
$this->makeRequest([ $this->makeRequest([
'userId' => 0,
'textId' => 0, 'textId' => 0,
'name' => 'Scheduling Plan', 'name' => 'Scheduling Plan',
'dateStart' => '2025-01-01', 'dateStart' => '2025-01-01',

View file

@ -4,6 +4,8 @@
<title>Daily Goals - Admin</title> <title>Daily Goals - Admin</title>
</head> </head>
<body> <body>
<button id="logout">Logout</button>
<a href="/admin/texts" id="texts">Texts</a> <a href="/admin/texts" id="texts">Texts</a>
<script src="/js/auth.js"></script>
</body> </body>
</html> </html>

View file

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html>
<head>
<title>Daily Goals - Forbidden</title>
</head>
<body>
<h1>403 Forbidden</h1>
<p>You do not have permission to access this page.</p>
<a href="/home">Back to Home</a>
</body>
</html>

View file

@ -5,6 +5,7 @@
</head> </head>
<body> <body>
<h1>Home</h1> <h1>Home</h1>
<button id="logout">Logout</button>
<ul id="texts-list"> <ul id="texts-list">
</ul> </ul>
<div id="create-plan-modal" hidden> <div id="create-plan-modal" hidden>
@ -21,6 +22,7 @@
<button class="save-plan">Save</button> <button class="save-plan">Save</button>
<button class="cancel-plan">Cancel</button> <button class="cancel-plan">Cancel</button>
</div> </div>
<script src="/js/auth.js"></script>
<script src="/js/home.js"></script> <script src="/js/home.js"></script>
</body> </body>
</html> </html>

26
views/templates/login.php Normal file
View file

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html>
<head>
<title>Daily Goals - Login</title>
</head>
<body>
<h1>Login</h1>
<form id="login-form">
<label>Email
<input type="email" id="email" name="email" required />
</label>
<label>Password
<input
type="password"
id="password"
name="password"
required
/>
</label>
<button type="submit">Login</button>
</form>
<p id="login-error" hidden></p>
<p><a href="/register">Register</a></p>
<script src="/js/auth.js"></script>
</body>
</html>

View file

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html>
<head>
<title>Daily Goals - Register</title>
</head>
<body>
<h1>Register</h1>
<form id="register-form">
<label>Email
<input type="email" id="email" name="email" required />
</label>
<label>Password (min 8 characters)
<input
type="password"
id="password"
name="password"
minlength="8"
required
/>
</label>
<button type="submit">Register</button>
</form>
<p id="register-error" hidden></p>
<p><a href="/login">Already have an account? Login</a></p>
<script src="/js/auth.js"></script>
</body>
</html>