Merge branch 'auth-and-admin'
This commit is contained in:
commit
041590da15
65 changed files with 2160 additions and 125 deletions
|
|
@ -37,6 +37,12 @@
|
|||
<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" />
|
||||
</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>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
64
app/Auth/AdminMiddleware.php
Normal file
64
app/Auth/AdminMiddleware.php
Normal 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
166
app/Auth/AuthController.php
Normal 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';
|
||||
}
|
||||
}
|
||||
84
app/Auth/AuthMiddleware.php
Normal file
84
app/Auth/AuthMiddleware.php
Normal 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;
|
||||
}
|
||||
}
|
||||
16
app/Auth/BcryptPasswordHasher.php
Normal file
16
app/Auth/BcryptPasswordHasher.php
Normal 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
13
app/Auth/Clock.php
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?php
|
||||
|
||||
namespace App\Auth;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
interface Clock
|
||||
{
|
||||
/**
|
||||
* Returns the current time in UTC.
|
||||
*/
|
||||
public function now(): DateTimeImmutable;
|
||||
}
|
||||
15
app/Auth/CreateSessionDto.php
Normal file
15
app/Auth/CreateSessionDto.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
84
app/Auth/JsonSessionRepository.php
Normal file
84
app/Auth/JsonSessionRepository.php
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
10
app/Auth/PasswordHasher.php
Normal file
10
app/Auth/PasswordHasher.php
Normal 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;
|
||||
}
|
||||
11
app/Auth/RandomTokenGenerator.php
Normal file
11
app/Auth/RandomTokenGenerator.php
Normal 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
40
app/Auth/Session.php
Normal 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;
|
||||
}
|
||||
}
|
||||
10
app/Auth/SessionRepository.php
Normal file
10
app/Auth/SessionRepository.php
Normal 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
14
app/Auth/SystemClock.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
8
app/Auth/TokenGenerator.php
Normal file
8
app/Auth/TokenGenerator.php
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
|
||||
namespace App\Auth;
|
||||
|
||||
interface TokenGenerator
|
||||
{
|
||||
public function generate(): string;
|
||||
}
|
||||
34
app/Auth/UseCases/CreateSession.php
Normal file
34
app/Auth/UseCases/CreateSession.php
Normal 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,
|
||||
));
|
||||
}
|
||||
}
|
||||
5
app/Exceptions/ForbiddenException.php
Normal file
5
app/Exceptions/ForbiddenException.php
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
class ForbiddenException extends \RuntimeException {}
|
||||
5
app/Exceptions/UnauthorizedException.php
Normal file
5
app/Exceptions/UnauthorizedException.php
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
class UnauthorizedException extends \RuntimeException {}
|
||||
|
|
@ -5,6 +5,7 @@ namespace App\Plan;
|
|||
use App\Exceptions\BadRequestException;
|
||||
use App\Plan\UseCases\CreatePlan;
|
||||
use App\Plan\UseCases\CreatePlanRequest;
|
||||
use App\User\User;
|
||||
use DomainException;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
|
|
@ -16,9 +17,17 @@ class PlanController
|
|||
Response $response,
|
||||
CreatePlan $createPlanUseCase,
|
||||
): 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) ?? [];
|
||||
|
||||
$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,
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
50
app/User/UseCases/AuthenticateUser.php
Normal file
50
app/User/UseCases/AuthenticateUser.php
Normal 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;
|
||||
}
|
||||
}
|
||||
11
app/User/UseCases/AuthenticateUserRequest.php
Normal file
11
app/User/UseCases/AuthenticateUserRequest.php
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
|
||||
namespace App\User\UseCases;
|
||||
|
||||
class AuthenticateUserRequest
|
||||
{
|
||||
public function __construct(
|
||||
public ?string $email,
|
||||
public ?string $password,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
namespace App\User\UseCases;
|
||||
|
||||
use App\Auth\PasswordHasher;
|
||||
use App\Exceptions\BadRequestException;
|
||||
use App\User\User;
|
||||
use App\User\UserRepository;
|
||||
use App\ValueObjects\EmailAddress;
|
||||
|
||||
|
|
@ -10,19 +12,37 @@ class CreateUser
|
|||
{
|
||||
public function __construct(
|
||||
private UserRepository $userRepo,
|
||||
private PasswordHasher $passwordHasher,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws BadRequestException
|
||||
*/
|
||||
public function execute(CreateUserRequest $dto): void
|
||||
public function execute(CreateUserRequest $dto): User
|
||||
{
|
||||
if ($dto->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,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,5 +8,7 @@ class CreateUserDto
|
|||
{
|
||||
public function __construct(
|
||||
public EmailAddress $email,
|
||||
public string $passwordHash,
|
||||
public bool $isAdmin = false,
|
||||
) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,5 +6,7 @@ class CreateUserRequest
|
|||
{
|
||||
public function __construct(
|
||||
public ?string $email,
|
||||
public ?string $password,
|
||||
public bool $isAdmin,
|
||||
) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@ class EmailAddress
|
|||
$this->normalized = $email;
|
||||
}
|
||||
|
||||
public function value(): string
|
||||
{
|
||||
return $this->normalized;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->normalized;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
describe('The admin page', () => {
|
||||
beforeEach(() => {
|
||||
cy.exec('npm run db:seed')
|
||||
cy.loginAsAdmin()
|
||||
cy.visit('/admin')
|
||||
})
|
||||
afterEach(() => {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
87
cypress/e2e/auth.cy.js
Normal file
87
cypress/e2e/auth.cy.js
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
describe('The home page', () => {
|
||||
beforeEach(() => {
|
||||
cy.exec('npm run db:seed')
|
||||
cy.loginAsUser()
|
||||
})
|
||||
afterEach(() => {
|
||||
cy.exec('npm run db:wipe')
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) => { ... })
|
||||
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')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ $files = [
|
|||
'users.json',
|
||||
'plans.json',
|
||||
'scheduledNodes.json',
|
||||
'sessions.json',
|
||||
];
|
||||
|
||||
foreach ($files as $file) {
|
||||
|
|
|
|||
75
public/js/auth.js
Normal file
75
public/js/auth.js
Normal 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);
|
||||
}
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
'<li><a href=/admin/texts/'
|
||||
|
|
@ -18,6 +20,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
const formData = new FormData(form);
|
||||
const res = await fetch('/api/texts', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
body: formData,
|
||||
});
|
||||
if (res.ok) {
|
||||
|
|
|
|||
36
tests/Fakes/FakeClock.php
Normal file
36
tests/Fakes/FakeClock.php
Normal 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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
20
tests/Fakes/FakePasswordHasher.php
Normal file
20
tests/Fakes/FakePasswordHasher.php
Normal 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;
|
||||
}
|
||||
}
|
||||
48
tests/Fakes/FakeSessionRepository.php
Normal file
48
tests/Fakes/FakeSessionRepository.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
25
tests/Fakes/FakeTokenGenerator.php
Normal file
25
tests/Fakes/FakeTokenGenerator.php
Normal 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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
118
tests/Unit/Auth/Middleware/AdminMiddlewareTest.php
Normal file
118
tests/Unit/Auth/Middleware/AdminMiddlewareTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
161
tests/Unit/Auth/Middleware/AuthMiddlewareTest.php
Normal file
161
tests/Unit/Auth/Middleware/AuthMiddlewareTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
85
tests/Unit/Auth/UseCases/CreateSessionTest.php
Normal file
85
tests/Unit/Auth/UseCases/CreateSessionTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
95
tests/Unit/User/UseCases/AuthenticateUserTest.php
Normal file
95
tests/Unit/User/UseCases/AuthenticateUserTest.php
Normal 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',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
294
tests/e2e/Controllers/AuthControllerTest.php
Normal file
294
tests/e2e/Controllers/AuthControllerTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
$response = $this->controller->createPlan(
|
||||
$this->makeRequest([
|
||||
$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(
|
||||
$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',
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
<title>Daily Goals - Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<button id="logout">Logout</button>
|
||||
<a href="/admin/texts" id="texts">Texts</a>
|
||||
<script src="/js/auth.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
11
views/templates/forbidden.php
Normal file
11
views/templates/forbidden.php
Normal 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>
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
</head>
|
||||
<body>
|
||||
<h1>Home</h1>
|
||||
<button id="logout">Logout</button>
|
||||
<ul id="texts-list">
|
||||
</ul>
|
||||
<div id="create-plan-modal" hidden>
|
||||
|
|
@ -21,6 +22,7 @@
|
|||
<button class="save-plan">Save</button>
|
||||
<button class="cancel-plan">Cancel</button>
|
||||
</div>
|
||||
<script src="/js/auth.js"></script>
|
||||
<script src="/js/home.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
26
views/templates/login.php
Normal file
26
views/templates/login.php
Normal 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>
|
||||
27
views/templates/register.php
Normal file
27
views/templates/register.php
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue