diff --git a/DailyGoals.drawio b/DailyGoals.drawio index ca3101c..b0937cb 100644 --- a/DailyGoals.drawio +++ b/DailyGoals.drawio @@ -37,6 +37,12 @@ + + + + + + diff --git a/ai/backend_prompt_template.md b/ai/backend_prompt_template.md index 1aa846d..c16ef26 100644 --- a/ai/backend_prompt_template.md +++ b/ai/backend_prompt_template.md @@ -20,29 +20,41 @@ Code patterns to follow: - Entities: constructor with properties, getters - DTOs: simple data containers for creation - 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 - When throwing exceptions, add @throws docblock - Fakes: in-memory implementations for testing - Look at tests/Fakes/ for examples - Find/lookup methods must return a new instance of the entity, not the stored reference - 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 -- Lines should not exceed 80 columns, but should use up to 80 columns when possible — do not split lines unnecessarily + - 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 - 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: -- Present tense, imperative mood (add, create, test, fix) -- Lowercase -- Short (3-6 words) -- Match patterns found in git history +- Subject: present tense, imperative mood (add, create, test, fix) +- Subject: lowercase, short (3-6 words) +- Match subject 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: - Tests should be committed first, before implementation -- One commit per file - each new file gets its own commit -- Make commits SMALL and FREQUENT - every meaningful change should be a commit +- Group related changes together in a single commit (e.g., a new class + 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 -- A commit can be as simple as adding one import, one getter, one property, etc. - Don't wait to commit - commit as you go - Run `php-cs-fixer fix` on worked on directories before committing diff --git a/ai/frontend_prompt_template.md b/ai/frontend_prompt_template.md index 24eb7c3..a865577 100644 --- a/ai/frontend_prompt_template.md +++ b/ai/frontend_prompt_template.md @@ -17,22 +17,31 @@ Code patterns to follow: - First, explore the codebase to understand existing entity patterns - Look at similar pages for reference - 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 -- 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: -- Present tense, imperative mood (add, create, test, fix) -- Lowercase -- Short (3-6 words) -- Match patterns found in git history +- Subject: present tense, imperative mood (add, create, test, fix) +- Subject: lowercase, short (3-6 words) +- Match subject 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: - Tests should be committed first, before implementation -- One commit per file - each new file gets its own commit -- Make commits SMALL and FREQUENT - every meaningful change should be a commit +- Group related changes together in a single commit (e.g., a new class + 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 -- A commit can be as simple as adding one import, one getter, one property, etc. - Don't wait to commit - commit as you go Branch naming: diff --git a/app/Auth/AdminMiddleware.php b/app/Auth/AdminMiddleware.php new file mode 100644 index 0000000..47e16be --- /dev/null +++ b/app/Auth/AdminMiddleware.php @@ -0,0 +1,64 @@ +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; + } +} diff --git a/app/Auth/AuthController.php b/app/Auth/AuthController.php new file mode 100644 index 0000000..0864730 --- /dev/null +++ b/app/Auth/AuthController.php @@ -0,0 +1,166 @@ +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'; + } +} diff --git a/app/Auth/AuthMiddleware.php b/app/Auth/AuthMiddleware.php new file mode 100644 index 0000000..e72fba8 --- /dev/null +++ b/app/Auth/AuthMiddleware.php @@ -0,0 +1,84 @@ +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; + } +} diff --git a/app/Auth/BcryptPasswordHasher.php b/app/Auth/BcryptPasswordHasher.php new file mode 100644 index 0000000..8593710 --- /dev/null +++ b/app/Auth/BcryptPasswordHasher.php @@ -0,0 +1,16 @@ +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) + ); + } +} diff --git a/app/Auth/PasswordHasher.php b/app/Auth/PasswordHasher.php new file mode 100644 index 0000000..332d105 --- /dev/null +++ b/app/Auth/PasswordHasher.php @@ -0,0 +1,10 @@ +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; + } +} diff --git a/app/Auth/SessionRepository.php b/app/Auth/SessionRepository.php new file mode 100644 index 0000000..073a16c --- /dev/null +++ b/app/Auth/SessionRepository.php @@ -0,0 +1,10 @@ +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, + )); + } +} diff --git a/app/Exceptions/ForbiddenException.php b/app/Exceptions/ForbiddenException.php new file mode 100644 index 0000000..c9b05cd --- /dev/null +++ b/app/Exceptions/ForbiddenException.php @@ -0,0 +1,5 @@ +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) ?? []; - $userId = isset($data['userId']) ? (int) $data['userId'] : null; $textId = isset($data['textId']) ? (int) $data['textId'] : null; $name = $data['name'] ?? null; $dateStart = $data['dateStart'] ?? null; @@ -26,7 +35,7 @@ class PlanController try { $plan = $createPlanUseCase->execute(new CreatePlanRequest( - userId: $userId, + userId: $user->getId(), textId: $textId, name: $name, dateStart: $dateStart, diff --git a/app/User/JsonUserRepository.php b/app/User/JsonUserRepository.php index bde8c62..d5de3a2 100644 --- a/app/User/JsonUserRepository.php +++ b/app/User/JsonUserRepository.php @@ -21,13 +21,17 @@ class JsonUserRepository implements UserRepository $users[] = [ 'id' => $id, - 'email' => (string) $dto->email, + 'email' => $dto->email->value(), + 'passwordHash' => $dto->passwordHash, + 'isAdmin' => $dto->isAdmin, ]; $this->writeUsers($users); return new User( id: $id, email: $dto->email, + passwordHash: $dto->passwordHash, + isAdmin: $dto->isAdmin, ); } @@ -37,16 +41,36 @@ class JsonUserRepository implements UserRepository foreach ($users as $data) { if ($data['id'] === $id) { - return new User( - id: $data['id'], - email: new EmailAddress($data['email']), - ); + return $this->hydrate($data); } } 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 { if (!file_exists($this->filePath)) { diff --git a/app/User/UseCases/AuthenticateUser.php b/app/User/UseCases/AuthenticateUser.php new file mode 100644 index 0000000..a889a06 --- /dev/null +++ b/app/User/UseCases/AuthenticateUser.php @@ -0,0 +1,50 @@ +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; + } +} diff --git a/app/User/UseCases/AuthenticateUserRequest.php b/app/User/UseCases/AuthenticateUserRequest.php new file mode 100644 index 0000000..953ff6e --- /dev/null +++ b/app/User/UseCases/AuthenticateUserRequest.php @@ -0,0 +1,11 @@ +email === null) { throw new BadRequestException('email is required'); } - $this->userRepo->create(new CreateUserDto( - email: new EmailAddress($dto->email), + if ($dto->password === null) { + 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, )); } } diff --git a/app/User/UseCases/CreateUserDto.php b/app/User/UseCases/CreateUserDto.php index e978287..a9c38fa 100644 --- a/app/User/UseCases/CreateUserDto.php +++ b/app/User/UseCases/CreateUserDto.php @@ -8,5 +8,7 @@ class CreateUserDto { public function __construct( public EmailAddress $email, + public string $passwordHash, + public bool $isAdmin = false, ) {} } diff --git a/app/User/UseCases/CreateUserRequest.php b/app/User/UseCases/CreateUserRequest.php index 7c48913..c70ef72 100644 --- a/app/User/UseCases/CreateUserRequest.php +++ b/app/User/UseCases/CreateUserRequest.php @@ -6,5 +6,7 @@ class CreateUserRequest { public function __construct( public ?string $email, + public ?string $password, + public bool $isAdmin, ) {} } diff --git a/app/User/User.php b/app/User/User.php index e685a32..9e8ae97 100644 --- a/app/User/User.php +++ b/app/User/User.php @@ -9,6 +9,8 @@ class User public function __construct( private int $id, private EmailAddress $email, + private string $passwordHash, + private bool $isAdmin, ) {} public function getId(): int @@ -20,4 +22,14 @@ class User { return $this->email; } + + public function getPasswordHash(): string + { + return $this->passwordHash; + } + + public function isAdmin(): bool + { + return $this->isAdmin; + } } diff --git a/app/User/UserRepository.php b/app/User/UserRepository.php index c58a649..278cd98 100644 --- a/app/User/UserRepository.php +++ b/app/User/UserRepository.php @@ -3,9 +3,11 @@ namespace App\User; use App\User\UseCases\CreateUserDto; +use App\ValueObjects\EmailAddress; interface UserRepository { public function create(CreateUserDto $dto): User; public function find(int $id): ?User; + public function findByEmail(EmailAddress $email): ?User; } diff --git a/app/ValueObjects/EmailAddress.php b/app/ValueObjects/EmailAddress.php index 6952ae9..e646f4c 100644 --- a/app/ValueObjects/EmailAddress.php +++ b/app/ValueObjects/EmailAddress.php @@ -11,6 +11,11 @@ class EmailAddress $this->normalized = $email; } + public function value(): string + { + return $this->normalized; + } + public function __toString(): string { return $this->normalized; diff --git a/app/View/ViewController.php b/app/View/ViewController.php index c131683..4fd8d84 100644 --- a/app/View/ViewController.php +++ b/app/View/ViewController.php @@ -37,4 +37,24 @@ class ViewController 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; + } } diff --git a/bootstrap/app.php b/bootstrap/app.php index 1ceeb8e..c05dc0f 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,6 +3,10 @@ use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; 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\Text\TextController; use App\Node\NodeController; @@ -14,19 +18,48 @@ $app = Bridge::create($container); // change first param to false for production $app->addErrorMiddleware(true, true, true); -$app->get('/home', [ViewController::class, 'home']); -$app->get('/admin', [ViewController::class, 'admin']); -$app->get('/admin/texts', [ViewController::class, 'texts']); -$app->get('/admin/texts/{textId}', [ViewController::class, 'text']); +// Public routes (no auth required) +$app->get('/login', [ViewController::class, 'login']); +$app->get('/register', [ViewController::class, 'register']); +$app->post('/api/auth/login', [AuthController::class, 'login']); +$app->post('/api/auth/register', [AuthController::class, 'register']); -$app->get('/api/texts', [TextController::class, 'getTexts']); -$app->get('/api/texts/{textId}', [TextController::class, 'getText']); -$app->post('/api/texts', [TextController::class, 'createText']); +// Authenticated routes (any logged-in user) +$app->group('', function (RouteCollectorProxy $group) { + $group->get('/home', [ViewController::class, 'home']); -$app->get('/api/nodes/{textId}', [NodeController::class, 'getNodesOfText']); -$app->post('/api/nodes/bulk', [NodeController::class, 'bulkCreateNodes']); -$app->post('/api/nodes', [NodeController::class, 'createNode']); + $group->post('/api/auth/logout', [AuthController::class, 'logout']); + $group->get('/api/auth/me', [AuthController::class, 'me']); -$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; diff --git a/bootstrap/container.php b/bootstrap/container.php index fb80996..2ae6e4c 100644 --- a/bootstrap/container.php +++ b/bootstrap/container.php @@ -2,6 +2,14 @@ use DI; 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\JsonTextRepository; use App\Node\NodeRepository; @@ -20,6 +28,11 @@ $container = new Container([ UserRepository::class => DI\autowire(JsonUserRepository::class), ScheduledNodeRepository::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; diff --git a/cypress/e2e/admin.cy.js b/cypress/e2e/admin.cy.js index 2c8054c..3bd205f 100644 --- a/cypress/e2e/admin.cy.js +++ b/cypress/e2e/admin.cy.js @@ -1,6 +1,7 @@ describe('The admin page', () => { beforeEach(() => { cy.exec('npm run db:seed') + cy.loginAsAdmin() cy.visit('/admin') }) afterEach(() => { diff --git a/cypress/e2e/adminText.cy.js b/cypress/e2e/adminText.cy.js index 84be4cd..f5cad89 100644 --- a/cypress/e2e/adminText.cy.js +++ b/cypress/e2e/adminText.cy.js @@ -1,6 +1,7 @@ describe('The admin text detail page', () => { beforeEach(() => { cy.exec('npm run db:seed') + cy.loginAsAdmin() cy.intercept('GET', '/api/texts/0').as('getText') cy.intercept('GET', '/api/nodes/0').as('getNodes') cy.visit('/admin/texts/0') diff --git a/cypress/e2e/adminTextBulkAdd.cy.js b/cypress/e2e/adminTextBulkAdd.cy.js index 03159a2..7d1afc2 100644 --- a/cypress/e2e/adminTextBulkAdd.cy.js +++ b/cypress/e2e/adminTextBulkAdd.cy.js @@ -1,6 +1,7 @@ describe('Bulk add children on the admin text detail page', () => { beforeEach(() => { cy.exec('npm run db:seed') + cy.loginAsAdmin() cy.intercept('GET', '/api/texts/0').as('getText') cy.intercept('GET', '/api/nodes/0').as('getNodes') cy.visit('/admin/texts/0') diff --git a/cypress/e2e/adminTextToggle.cy.js b/cypress/e2e/adminTextToggle.cy.js index c75132b..23cf830 100644 --- a/cypress/e2e/adminTextToggle.cy.js +++ b/cypress/e2e/adminTextToggle.cy.js @@ -1,6 +1,7 @@ describe('Toggle display of child nodes', () => { beforeEach(() => { cy.exec('npm run db:seed') + cy.loginAsAdmin() cy.intercept('GET', '/api/texts/0').as('getText') cy.intercept('GET', '/api/nodes/0').as('getNodes') cy.visit('/admin/texts/0') diff --git a/cypress/e2e/auth.cy.js b/cypress/e2e/auth.cy.js new file mode 100644 index 0000000..ae22d22 --- /dev/null +++ b/cypress/e2e/auth.cy.js @@ -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') + }) +}) diff --git a/cypress/e2e/home.cy.js b/cypress/e2e/home.cy.js index d0ba1fe..83c6984 100644 --- a/cypress/e2e/home.cy.js +++ b/cypress/e2e/home.cy.js @@ -1,6 +1,7 @@ describe('The home page', () => { beforeEach(() => { cy.exec('npm run db:seed') + cy.loginAsUser() }) afterEach(() => { cy.exec('npm run db:wipe') diff --git a/cypress/e2e/homeCreatePlan.cy.js b/cypress/e2e/homeCreatePlan.cy.js index 3c9ee87..8509a61 100644 --- a/cypress/e2e/homeCreatePlan.cy.js +++ b/cypress/e2e/homeCreatePlan.cy.js @@ -1,6 +1,7 @@ describe('Create plan modal on the home page', () => { beforeEach(() => { cy.exec('npm run db:seed') + cy.loginAsUser() cy.intercept('GET', '/api/texts').as('getTexts') cy.visit('/home') cy.wait('@getTexts') @@ -60,7 +61,6 @@ describe('Create plan modal on the home page', () => { cy.wait('@createPlan').then((createPlanRequest) => { expect(createPlanRequest.response.statusCode).to.eq(201) expect(createPlanRequest.request.body).to.deep.equal({ - userId: 0, textId: 0, name: 'My reading plan', dateStart: '2025-01-01', diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 66ea16e..61e6549 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -1,25 +1,15 @@ -// *********************************************** -// This example commands.js shows you how to -// create various custom commands and overwrite -// existing commands. -// -// For more comprehensive examples of custom -// commands please read more here: -// https://on.cypress.io/custom-commands -// *********************************************** -// -// -// -- This is a parent command -- -// Cypress.Commands.add('login', (email, password) => { ... }) -// -// -// -- 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) => { ... }) \ No newline at end of file +Cypress.Commands.add('login', (email, password) => { + cy.request({ + method: 'POST', + url: '/api/auth/login', + body: { email, password }, + }) +}) + +Cypress.Commands.add('loginAsAdmin', () => { + cy.login('admin@example.com', 'admin1234') +}) + +Cypress.Commands.add('loginAsUser', () => { + cy.login('user@example.com', 'password1') +}) diff --git a/data/seedDb.php b/data/seedDb.php index 922ca90..aaa329f 100644 --- a/data/seedDb.php +++ b/data/seedDb.php @@ -28,15 +28,27 @@ $nodes = [ ], ]; +// Default credentials: +// admin@example.com / admin1234 (admin) +// user@example.com / password1 (regular user) $users = [ [ 'id' => 0, + 'email' => 'admin@example.com', + 'passwordHash' => password_hash('admin1234', PASSWORD_DEFAULT), + 'isAdmin' => true, + ], + [ + 'id' => 1, 'email' => 'user@example.com', + 'passwordHash' => password_hash('password1', PASSWORD_DEFAULT), + 'isAdmin' => false, ], ]; $plans = []; $scheduledNodes = []; +$sessions = []; $fileDataMap = [ 'texts.json' => $texts, @@ -44,6 +56,7 @@ $fileDataMap = [ 'users.json' => $users, 'plans.json' => $plans, 'scheduledNodes.json' => $scheduledNodes, + 'sessions.json' => $sessions, ]; foreach ($fileDataMap as $file => $data) { diff --git a/data/wipeDb.php b/data/wipeDb.php index 658bb2f..6428a92 100644 --- a/data/wipeDb.php +++ b/data/wipeDb.php @@ -6,6 +6,7 @@ $files = [ 'users.json', 'plans.json', 'scheduledNodes.json', + 'sessions.json', ]; foreach ($files as $file) { diff --git a/public/js/auth.js b/public/js/auth.js new file mode 100644 index 0000000..0e8fc96 --- /dev/null +++ b/public/js/auth.js @@ -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); + } +}); diff --git a/public/js/home.js b/public/js/home.js index b46a051..e7abc7c 100644 --- a/public/js/home.js +++ b/public/js/home.js @@ -3,7 +3,9 @@ document.addEventListener('DOMContentLoaded', () => { const createPlanModal = document.getElementById('create-plan-modal'); async function loadTexts() { - const response = await fetch('/api/texts'); + const response = await fetch('/api/texts', { + credentials: 'same-origin', + }); const texts = await response.json(); textsList.innerHTML = texts .map(text => @@ -67,8 +69,8 @@ document.addEventListener('DOMContentLoaded', () => { const response = await fetch('/api/plans', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', body: JSON.stringify({ - userId: 0, textId: textId, name: planName, dateStart: dateStart, diff --git a/public/js/text.js b/public/js/text.js index 0388bf6..59fc4ee 100644 --- a/public/js/text.js +++ b/public/js/text.js @@ -1,7 +1,7 @@ document.addEventListener('DOMContentLoaded', () => { const textId = window.location.pathname.split('/').pop(); - fetch('/api/texts/' + textId) + fetch('/api/texts/' + textId, { credentials: 'same-origin' }) .then(res => res.json()) .then(text => { const h1 = document.createElement('h1'); @@ -13,7 +13,7 @@ document.addEventListener('DOMContentLoaded', () => { }); function fetchAndRenderNodes(textId) { - return fetch('/api/nodes/' + textId) + return fetch('/api/nodes/' + textId, { credentials: 'same-origin' }) .then(res => res.json()) .then(nodes => { const existing = document.querySelector('#text-detail > ul'); @@ -113,6 +113,7 @@ function toggleAddForm(li, parentNodeId, textId) { fetch('/api/nodes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', body: JSON.stringify({ textId: parseInt(textId), title, parentNodeId }), }) .then(res => { @@ -157,6 +158,7 @@ function toggleBulkAddForm(li, parentNodeId, textId) { fetch('/api/nodes/bulk', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', body: JSON.stringify({ textId: parseInt(textId), parentNodeId, titlePrefix, count }), }) .then(res => { diff --git a/public/js/texts.js b/public/js/texts.js index 1937749..029196d 100644 --- a/public/js/texts.js +++ b/public/js/texts.js @@ -3,7 +3,9 @@ document.addEventListener('DOMContentLoaded', () => { const form = document.getElementById('texts-form'); async function loadTexts() { - const res = await fetch('/api/texts'); + const res = await fetch('/api/texts', { + credentials: 'same-origin', + }); const texts = await res.json(); textsList.innerHTML = texts.map(text => '
  • { const formData = new FormData(form); const res = await fetch('/api/texts', { method: 'POST', + credentials: 'same-origin', body: formData, }); if (res.ok) { diff --git a/tests/Fakes/FakeClock.php b/tests/Fakes/FakeClock.php new file mode 100644 index 0000000..31e649b --- /dev/null +++ b/tests/Fakes/FakeClock.php @@ -0,0 +1,36 @@ +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.' + ); + } + } +} diff --git a/tests/Fakes/FakePasswordHasher.php b/tests/Fakes/FakePasswordHasher.php new file mode 100644 index 0000000..4e9a17d --- /dev/null +++ b/tests/Fakes/FakePasswordHasher.php @@ -0,0 +1,20 @@ +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]); + } +} diff --git a/tests/Fakes/FakeTokenGenerator.php b/tests/Fakes/FakeTokenGenerator.php new file mode 100644 index 0000000..fbe8913 --- /dev/null +++ b/tests/Fakes/FakeTokenGenerator.php @@ -0,0 +1,25 @@ +callCount % count($this->predefinedTokens); + $this->callCount++; + + return $this->predefinedTokens[$index]; + } +} diff --git a/tests/Fakes/FakeUserRepository.php b/tests/Fakes/FakeUserRepository.php index 062cae8..b2e766a 100644 --- a/tests/Fakes/FakeUserRepository.php +++ b/tests/Fakes/FakeUserRepository.php @@ -5,6 +5,7 @@ namespace Tests\Fakes; use App\User\UseCases\CreateUserDto; use App\User\User; use App\User\UserRepository; +use App\ValueObjects\EmailAddress; class FakeUserRepository implements UserRepository { @@ -28,6 +29,28 @@ class FakeUserRepository implements UserRepository return new User( id: $user->getId(), 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( id: $id, email: $dto->email, + passwordHash: $dto->passwordHash, + isAdmin: $dto->isAdmin, ); $this->existingUsers[$id] = $user; diff --git a/tests/Unit/Auth/Middleware/AdminMiddlewareTest.php b/tests/Unit/Auth/Middleware/AdminMiddlewareTest.php new file mode 100644 index 0000000..54eecd3 --- /dev/null +++ b/tests/Unit/Auth/Middleware/AdminMiddlewareTest.php @@ -0,0 +1,118 @@ +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()); + } +} diff --git a/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php b/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php new file mode 100644 index 0000000..8408384 --- /dev/null +++ b/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php @@ -0,0 +1,161 @@ +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')); + } +} diff --git a/tests/Unit/Auth/UseCases/CreateSessionTest.php b/tests/Unit/Auth/UseCases/CreateSessionTest.php new file mode 100644 index 0000000..a79eb9c --- /dev/null +++ b/tests/Unit/Auth/UseCases/CreateSessionTest.php @@ -0,0 +1,85 @@ +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); + } +} diff --git a/tests/Unit/Plan/UseCases/CreatePlanTest.php b/tests/Unit/Plan/UseCases/CreatePlanTest.php index 667e72c..f88bc89 100644 --- a/tests/Unit/Plan/UseCases/CreatePlanTest.php +++ b/tests/Unit/Plan/UseCases/CreatePlanTest.php @@ -39,6 +39,7 @@ class CreatePlanTest extends TestCase $this->scheduledNodeRepo = new FakeScheduledNodeRepository(); $this->userRepo->create(new CreateUserDto( email: new EmailAddress('test@test.com'), + passwordHash: '', )); $this->createScheduledNode = new CreateScheduledNode( 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); $rootNode = $this->nodeRepo->create(new CreateNodeDto( text: $text, @@ -269,10 +270,10 @@ class CreatePlanTest extends TestCase new DateTimeImmutable('2025-01-02'), $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); $rootNode = $this->nodeRepo->create(new CreateNodeDto( text: $text, @@ -319,5 +320,5 @@ class CreatePlanTest extends TestCase new DateTimeImmutable('2025-01-02'), $childThree->getDate() ); - } + } } diff --git a/tests/Unit/ScheduledNode/UseCases/CreateScheduledNodeTest.php b/tests/Unit/ScheduledNode/UseCases/CreateScheduledNodeTest.php index d2bfc2b..477a07a 100644 --- a/tests/Unit/ScheduledNode/UseCases/CreateScheduledNodeTest.php +++ b/tests/Unit/ScheduledNode/UseCases/CreateScheduledNodeTest.php @@ -30,7 +30,12 @@ class CreateScheduledNodeTest extends TestCase $this->planRepo = new FakePlanRepository(); $this->planRepo->create(new CreatePlanDto( 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->scheduledNodeRepo, diff --git a/tests/Unit/User/UseCases/AuthenticateUserTest.php b/tests/Unit/User/UseCases/AuthenticateUserTest.php new file mode 100644 index 0000000..88bb1a9 --- /dev/null +++ b/tests/Unit/User/UseCases/AuthenticateUserTest.php @@ -0,0 +1,95 @@ +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', + )); + } +} diff --git a/tests/Unit/User/UseCases/CreateUserTest.php b/tests/Unit/User/UseCases/CreateUserTest.php index 180046f..0333a52 100644 --- a/tests/Unit/User/UseCases/CreateUserTest.php +++ b/tests/Unit/User/UseCases/CreateUserTest.php @@ -6,33 +6,119 @@ use App\Exceptions\BadRequestException; use App\User\User; use App\User\UseCases\CreateUser; use App\User\UseCases\CreateUserRequest; +use Tests\Fakes\FakePasswordHasher; use Tests\Fakes\FakeUserRepository; use PHPUnit\Framework\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 { - $userRepo = new FakeUserRepository(); - $useCase = new CreateUser($userRepo); - $useCase->execute(new CreateUserRequest( + $this->useCase->execute(new CreateUserRequest( email: 'test@test.com', + password: 'password1', + isAdmin: false, )); - $user = $userRepo->find(0); + $user = $this->userRepo->find(0); $this->assertInstanceOf(User::class, $user); $this->assertEquals('test@test.com', $user->getEmail()); } public function test_throws_if_email_is_null(): void { - $userRepo = new FakeUserRepository(); - $useCase = new CreateUser($userRepo); - $this->expectException(BadRequestException::class); $this->expectExceptionMessage('email is required'); - $useCase->execute(new CreateUserRequest( + $this->useCase->execute(new CreateUserRequest( 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() + ) + ); + } } diff --git a/tests/e2e/Controllers/AuthControllerTest.php b/tests/e2e/Controllers/AuthControllerTest.php new file mode 100644 index 0000000..50e39a5 --- /dev/null +++ b/tests/e2e/Controllers/AuthControllerTest.php @@ -0,0 +1,294 @@ +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']); + } +} diff --git a/tests/e2e/Controllers/PlanControllerTest.php b/tests/e2e/Controllers/PlanControllerTest.php index ee51012..cf0eeb0 100644 --- a/tests/e2e/Controllers/PlanControllerTest.php +++ b/tests/e2e/Controllers/PlanControllerTest.php @@ -8,6 +8,7 @@ use App\Plan\UseCases\CreatePlan; use App\ScheduledNode\UseCases\CreateScheduledNode; use App\Text\CreateTextDto; use App\User\UseCases\CreateUserDto; +use App\User\User; use App\ValueObjects\EmailAddress; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; @@ -29,6 +30,7 @@ class PlanControllerTest extends TestCase private FakeScheduledNodeRepository $scheduledNodeRepo; private CreatePlan $createPlan; private PlanController $controller; + private User $user; public function setUp(): void { @@ -38,8 +40,9 @@ class PlanControllerTest extends TestCase $this->nodeRepo = new FakeNodeRepository(); $this->scheduledNodeRepo = new FakeScheduledNodeRepository(); - $this->userRepo->create(new CreateUserDto( + $this->user = $this->userRepo->create(new CreateUserDto( email: new EmailAddress('test@test.com'), + passwordHash: '', )); $text = $this->textRepo->create(new CreateTextDto('testname')); $this->nodeRepo->create(new CreateNodeDto( @@ -68,6 +71,7 @@ class PlanControllerTest extends TestCase return new ServerRequestFactory() ->createServerRequest('POST', 'http://localhost/api/plans') ->withHeader('Content-Type', 'application/json') + ->withAttribute('user', $this->user) ->withBody($body); } @@ -75,7 +79,6 @@ class PlanControllerTest extends TestCase { $response = $this->controller->createPlan( $this->makeRequest([ - 'userId' => 0, 'textId' => 0, 'name' => 'My Plan', 'dateStart' => '2025-01-01', @@ -91,29 +94,33 @@ class PlanControllerTest extends TestCase $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( - $this->makeRequest([ - 'textId' => 0, - 'name' => 'My Plan', - 'dateStart' => '2025-01-01', - 'dateEnd' => '2025-01-01', - ]), + $requestWithoutUser, new Response(), $this->createPlan, ); - $this->assertEquals(400, $response->getStatusCode()); - $body = json_decode($response->getBody(), true); - $this->assertArrayHasKey('error', $body); + $this->assertEquals(401, $response->getStatusCode()); } public function test_create_plan_returns_400_when_text_id_missing(): void { $response = $this->controller->createPlan( $this->makeRequest([ - 'userId' => 0, 'name' => 'My Plan', 'dateStart' => '2025-01-01', 'dateEnd' => '2025-01-01', @@ -131,7 +138,6 @@ class PlanControllerTest extends TestCase { $response = $this->controller->createPlan( $this->makeRequest([ - 'userId' => 0, 'textId' => 0, 'dateStart' => '2025-01-01', 'dateEnd' => '2025-01-01', @@ -149,7 +155,6 @@ class PlanControllerTest extends TestCase { $response = $this->controller->createPlan( $this->makeRequest([ - 'userId' => 0, 'textId' => 0, 'name' => 'My Plan', 'dateEnd' => '2025-01-01', @@ -167,7 +172,6 @@ class PlanControllerTest extends TestCase { $response = $this->controller->createPlan( $this->makeRequest([ - 'userId' => 0, 'textId' => 0, 'name' => 'My Plan', 'dateStart' => '2025-01-01', @@ -185,7 +189,6 @@ class PlanControllerTest extends TestCase { $response = $this->controller->createPlan( $this->makeRequest([ - 'userId' => 0, 'textId' => 0, 'name' => 'My Plan', 'dateStart' => '2025-01-02', @@ -200,30 +203,10 @@ class PlanControllerTest extends TestCase $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 { $response = $this->controller->createPlan( $this->makeRequest([ - 'userId' => 0, 'textId' => 99, 'name' => 'My Plan', 'dateStart' => '2025-01-01', @@ -242,7 +225,6 @@ class PlanControllerTest extends TestCase { $this->controller->createPlan( $this->makeRequest([ - 'userId' => 0, 'textId' => 0, 'name' => 'Persistent Plan', 'dateStart' => '2025-01-01', @@ -261,7 +243,6 @@ class PlanControllerTest extends TestCase { $this->controller->createPlan( $this->makeRequest([ - 'userId' => 0, 'textId' => 0, 'name' => 'Scheduling Plan', 'dateStart' => '2025-01-01', diff --git a/views/templates/admin.php b/views/templates/admin.php index 7f463d4..f1fc83d 100644 --- a/views/templates/admin.php +++ b/views/templates/admin.php @@ -4,6 +4,8 @@ Daily Goals - Admin + Texts + diff --git a/views/templates/forbidden.php b/views/templates/forbidden.php new file mode 100644 index 0000000..43cd644 --- /dev/null +++ b/views/templates/forbidden.php @@ -0,0 +1,11 @@ + + + + Daily Goals - Forbidden + + +

    403 Forbidden

    +

    You do not have permission to access this page.

    + Back to Home + + diff --git a/views/templates/home.php b/views/templates/home.php index ddbc361..4f7a096 100644 --- a/views/templates/home.php +++ b/views/templates/home.php @@ -5,6 +5,7 @@

    Home

    + + diff --git a/views/templates/login.php b/views/templates/login.php new file mode 100644 index 0000000..93f4b56 --- /dev/null +++ b/views/templates/login.php @@ -0,0 +1,26 @@ + + + + Daily Goals - Login + + +

    Login

    +
    + + + +
    + +

    Register

    + + + diff --git a/views/templates/register.php b/views/templates/register.php new file mode 100644 index 0000000..223247c --- /dev/null +++ b/views/templates/register.php @@ -0,0 +1,27 @@ + + + + Daily Goals - Register + + +

    Register

    +
    + + + +
    + +

    Already have an account? Login

    + + +