Compare commits
87 commits
ceb956739b
...
041590da15
| Author | SHA1 | Date | |
|---|---|---|---|
| 041590da15 | |||
| b41652af71 | |||
| cd40483cd4 | |||
| f95adddaaf | |||
| 13da7c311a | |||
| 2fe41a5fe7 | |||
| a65c9259fa | |||
| 099883a13d | |||
| 40726c3984 | |||
| bb6bd7cbb3 | |||
| 632085f5b6 | |||
| b1247d2fa1 | |||
| d93b668d5a | |||
| 4975da19be | |||
| 4c393e813a | |||
| e8fcac654b | |||
| 3ee6057978 | |||
| 5f2bba070c | |||
| 6e93bd3872 | |||
| 8bfc110ed3 | |||
| cddc72e6cf | |||
| 49c5ed49b0 | |||
| 95f7f1cb78 | |||
| 5a24f5bde4 | |||
| 05374991c5 | |||
| c649dbbcc2 | |||
| 4e039fb583 | |||
| e4494a0577 | |||
| 8c52294b10 | |||
| cb697daa03 | |||
| ce029fafa2 | |||
| 6e0cda7f3e | |||
| 74a0e5980f | |||
| 5f207f7fcb | |||
| c9d5ad37b8 | |||
| edfe7259a3 | |||
| 6c5833af5e | |||
| bb4e27a45b | |||
| 40649ded8e | |||
| a7b7a4a96b | |||
| 2666f40c27 | |||
| d549cf914f | |||
| cd2168c822 | |||
| 821f654d69 | |||
| 05f4f334e6 | |||
| 2a281386a5 | |||
| 78ffb77f9f | |||
| de4d577781 | |||
| 04712bdd2d | |||
| 057df09dda | |||
| 07040851ec | |||
| a0bea204b4 | |||
| cb73688a99 | |||
| ef842f5758 | |||
| 762bbb7fda | |||
| 619ebd3907 | |||
| c2ade8a601 | |||
| 503df8be7a | |||
| b37e80147c | |||
| 6fbdc82589 | |||
| 79d9ece2ae | |||
| fd5278b3fe | |||
| 20e4a6ee69 | |||
| 271f28936d | |||
| f012728876 | |||
| 73ade7f971 | |||
| ada29ea957 | |||
| a52bb18b13 | |||
| 0f179e53c2 | |||
| 016e98412b | |||
| 5093259063 | |||
| 261319078d | |||
| 38cfd34645 | |||
| 96ad78425f | |||
| 30b8cc2c74 | |||
| ac461afcf0 | |||
| 64edec5141 | |||
| ee271e162e | |||
| b2fc6a7ded | |||
| cbeb43f18c | |||
| 54db92a76c | |||
| dcb4df043e | |||
| 4157710187 | |||
| 0e86af3e81 | |||
| affa1e7b1b | |||
| b9f7fcf148 | |||
| 160181888d |
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">
|
<mxCell id="UlVOh7WOaItsqOB8hf6W-9" parent="1" style="whiteSpace=wrap;html=1;aspect=fixed;" value="Node" vertex="1">
|
||||||
<mxGeometry height="80" width="80" x="400" y="60" as="geometry" />
|
<mxGeometry height="80" width="80" x="400" y="60" as="geometry" />
|
||||||
</mxCell>
|
</mxCell>
|
||||||
|
<mxCell id="UlVOh7WOaItsqOB8hf6W-19" edge="1" parent="1" source="UlVOh7WOaItsqOB8hf6W-17" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" target="UlVOh7WOaItsqOB8hf6W-1">
|
||||||
|
<mxGeometry relative="1" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="UlVOh7WOaItsqOB8hf6W-17" parent="1" style="whiteSpace=wrap;html=1;aspect=fixed;" value="Session" vertex="1">
|
||||||
|
<mxGeometry height="80" width="80" x="130" y="290" as="geometry" />
|
||||||
|
</mxCell>
|
||||||
</root>
|
</root>
|
||||||
</mxGraphModel>
|
</mxGraphModel>
|
||||||
</diagram>
|
</diagram>
|
||||||
|
|
|
||||||
|
|
@ -20,29 +20,41 @@ Code patterns to follow:
|
||||||
- Entities: constructor with properties, getters
|
- Entities: constructor with properties, getters
|
||||||
- DTOs: simple data containers for creation
|
- DTOs: simple data containers for creation
|
||||||
- Repositories: interfaces that define data access
|
- Repositories: interfaces that define data access
|
||||||
|
- Do not write unit tests for concrete repository implementations
|
||||||
|
(e.g., Doctrine/persistence-backed). They are exercised by e2e
|
||||||
|
tests. Use cases are tested with fake repositories.
|
||||||
- Use cases: business logic with Request objects
|
- Use cases: business logic with Request objects
|
||||||
- When throwing exceptions, add @throws docblock
|
- When throwing exceptions, add @throws docblock
|
||||||
- Fakes: in-memory implementations for testing
|
- Fakes: in-memory implementations for testing
|
||||||
- Look at tests/Fakes/ for examples
|
- Look at tests/Fakes/ for examples
|
||||||
- Find/lookup methods must return a new instance of the entity, not the stored reference
|
- Find/lookup methods must return a new instance of the entity, not the stored reference
|
||||||
- Tests: follow existing patterns in tests/Unit/[Entity]/UseCases/
|
- Tests: follow existing patterns in tests/Unit/[Entity]/UseCases/
|
||||||
- In setUp, only use fake repositories for entities under test — construct dependency objects directly with `new` (e.g., `new Text(....)`) instead of creating them through their fake repositories
|
- In setUp, only use fake repositories for entities under test - construct dependency objects directly with `new` (e.g., `new Text(....)`) instead of creating them through their fake repositories
|
||||||
- Lines should not exceed 80 columns, but should use up to 80 columns when possible — do not split lines unnecessarily
|
- Lines should not exceed 80 columns, but should use up to 80 columns when possible - do not split lines unnecessarily
|
||||||
- Imports: always put use statements at the top of the file, never use inline imports (e.g., \App\Foo\Bar::class)
|
- Imports: always put use statements at the top of the file, never use inline imports (e.g., \App\Foo\Bar::class)
|
||||||
- Variable names: use explicit, descriptive names — never single-letter or abbreviated variables (e.g., use $sponsorship not $s, $event not $e)
|
- Variable names: use explicit, descriptive names - never single-letter or abbreviated variables (e.g., use $sponsorship not $s, $event not $e)
|
||||||
|
- Never use em-dashes (—) in code, comments, commit messages, or any
|
||||||
|
written output. Use a regular hyphen (-), a colon, or rephrase
|
||||||
|
with parentheses instead.
|
||||||
|
|
||||||
Git commit style:
|
Git commit style:
|
||||||
- Present tense, imperative mood (add, create, test, fix)
|
- Subject: present tense, imperative mood (add, create, test, fix)
|
||||||
- Lowercase
|
- Subject: lowercase, short (3-6 words)
|
||||||
- Short (3-6 words)
|
- Match subject patterns found in git history
|
||||||
- Match patterns found in git history
|
- Add a body when the change needs explanation beyond the subject -
|
||||||
|
e.g., why the change was made, non-obvious tradeoffs, or notable
|
||||||
|
implementation details. Skip the body for trivial/self-evident commits.
|
||||||
|
- Separate subject and body with a blank line; wrap body at ~72 columns
|
||||||
|
|
||||||
Git commits:
|
Git commits:
|
||||||
- Tests should be committed first, before implementation
|
- Tests should be committed first, before implementation
|
||||||
- One commit per file - each new file gets its own commit
|
- Group related changes together in a single commit (e.g., a new class
|
||||||
- Make commits SMALL and FREQUENT - every meaningful change should be a commit
|
plus its registration, or a getter plus the property it exposes).
|
||||||
|
Avoid mixing unrelated concerns in one commit.
|
||||||
|
- Keep commits small and focused - prefer many small commits over few
|
||||||
|
large ones, but don't artificially split a single logical change
|
||||||
|
across multiple commits
|
||||||
- Commits are for reviewing and documenting the development of code
|
- Commits are for reviewing and documenting the development of code
|
||||||
- A commit can be as simple as adding one import, one getter, one property, etc.
|
|
||||||
- Don't wait to commit - commit as you go
|
- Don't wait to commit - commit as you go
|
||||||
- Run `php-cs-fixer fix` on worked on directories before committing
|
- Run `php-cs-fixer fix` on worked on directories before committing
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,22 +17,31 @@ Code patterns to follow:
|
||||||
- First, explore the codebase to understand existing entity patterns
|
- First, explore the codebase to understand existing entity patterns
|
||||||
- Look at similar pages for reference
|
- Look at similar pages for reference
|
||||||
- Tests: follow existing patterns in cypress/e2e/
|
- Tests: follow existing patterns in cypress/e2e/
|
||||||
- Lines should not exceed 80 columns, but should use up to 80 columns when possible — do not split lines unnecessarily
|
- Lines should not exceed 80 columns, but should use up to 80 columns when possible - do not split lines unnecessarily
|
||||||
- Imports: always put imports at the top of the file
|
- Imports: always put imports at the top of the file
|
||||||
- Variable names: use explicit, descriptive names — never single-letter or abbreviated variables (e.g., use sponsorship not s, event not e)
|
- Variable names: use explicit, descriptive names - never single-letter or abbreviated variables (e.g., use sponsorship not s, event not e)
|
||||||
|
- Never use em-dashes (—) in code, comments, commit messages, or any
|
||||||
|
written output. Use a regular hyphen (-), a colon, or rephrase
|
||||||
|
with parentheses instead.
|
||||||
|
|
||||||
Git commit style:
|
Git commit style:
|
||||||
- Present tense, imperative mood (add, create, test, fix)
|
- Subject: present tense, imperative mood (add, create, test, fix)
|
||||||
- Lowercase
|
- Subject: lowercase, short (3-6 words)
|
||||||
- Short (3-6 words)
|
- Match subject patterns found in git history
|
||||||
- Match patterns found in git history
|
- Add a body when the change needs explanation beyond the subject -
|
||||||
|
e.g., why the change was made, non-obvious tradeoffs, or notable
|
||||||
|
implementation details. Skip the body for trivial/self-evident commits.
|
||||||
|
- Separate subject and body with a blank line; wrap body at ~72 columns
|
||||||
|
|
||||||
Git commits:
|
Git commits:
|
||||||
- Tests should be committed first, before implementation
|
- Tests should be committed first, before implementation
|
||||||
- One commit per file - each new file gets its own commit
|
- Group related changes together in a single commit (e.g., a new class
|
||||||
- Make commits SMALL and FREQUENT - every meaningful change should be a commit
|
plus its registration, or a getter plus the property it exposes).
|
||||||
|
Avoid mixing unrelated concerns in one commit.
|
||||||
|
- Keep commits small and focused - prefer many small commits over few
|
||||||
|
large ones, but don't artificially split a single logical change
|
||||||
|
across multiple commits
|
||||||
- Commits are for reviewing and documenting the development of code
|
- Commits are for reviewing and documenting the development of code
|
||||||
- A commit can be as simple as adding one import, one getter, one property, etc.
|
|
||||||
- Don't wait to commit - commit as you go
|
- Don't wait to commit - commit as you go
|
||||||
|
|
||||||
Branch naming:
|
Branch naming:
|
||||||
|
|
|
||||||
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\Exceptions\BadRequestException;
|
||||||
use App\Plan\UseCases\CreatePlan;
|
use App\Plan\UseCases\CreatePlan;
|
||||||
use App\Plan\UseCases\CreatePlanRequest;
|
use App\Plan\UseCases\CreatePlanRequest;
|
||||||
|
use App\User\User;
|
||||||
use DomainException;
|
use DomainException;
|
||||||
use Psr\Http\Message\ResponseInterface as Response;
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
|
@ -16,9 +17,17 @@ class PlanController
|
||||||
Response $response,
|
Response $response,
|
||||||
CreatePlan $createPlanUseCase,
|
CreatePlan $createPlanUseCase,
|
||||||
): Response {
|
): Response {
|
||||||
|
$user = $request->getAttribute('user');
|
||||||
|
if (!$user instanceof User) {
|
||||||
|
$response->getBody()->write(
|
||||||
|
json_encode(['error' => 'unauthenticated'])
|
||||||
|
);
|
||||||
|
return $response->withStatus(401)
|
||||||
|
->withHeader('Content-Type', 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
$data = json_decode((string) $request->getBody(), true) ?? [];
|
$data = json_decode((string) $request->getBody(), true) ?? [];
|
||||||
|
|
||||||
$userId = isset($data['userId']) ? (int) $data['userId'] : null;
|
|
||||||
$textId = isset($data['textId']) ? (int) $data['textId'] : null;
|
$textId = isset($data['textId']) ? (int) $data['textId'] : null;
|
||||||
$name = $data['name'] ?? null;
|
$name = $data['name'] ?? null;
|
||||||
$dateStart = $data['dateStart'] ?? null;
|
$dateStart = $data['dateStart'] ?? null;
|
||||||
|
|
@ -26,7 +35,7 @@ class PlanController
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$plan = $createPlanUseCase->execute(new CreatePlanRequest(
|
$plan = $createPlanUseCase->execute(new CreatePlanRequest(
|
||||||
userId: $userId,
|
userId: $user->getId(),
|
||||||
textId: $textId,
|
textId: $textId,
|
||||||
name: $name,
|
name: $name,
|
||||||
dateStart: $dateStart,
|
dateStart: $dateStart,
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,17 @@ class JsonUserRepository implements UserRepository
|
||||||
|
|
||||||
$users[] = [
|
$users[] = [
|
||||||
'id' => $id,
|
'id' => $id,
|
||||||
'email' => (string) $dto->email,
|
'email' => $dto->email->value(),
|
||||||
|
'passwordHash' => $dto->passwordHash,
|
||||||
|
'isAdmin' => $dto->isAdmin,
|
||||||
];
|
];
|
||||||
$this->writeUsers($users);
|
$this->writeUsers($users);
|
||||||
|
|
||||||
return new User(
|
return new User(
|
||||||
id: $id,
|
id: $id,
|
||||||
email: $dto->email,
|
email: $dto->email,
|
||||||
|
passwordHash: $dto->passwordHash,
|
||||||
|
isAdmin: $dto->isAdmin,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -37,16 +41,36 @@ class JsonUserRepository implements UserRepository
|
||||||
|
|
||||||
foreach ($users as $data) {
|
foreach ($users as $data) {
|
||||||
if ($data['id'] === $id) {
|
if ($data['id'] === $id) {
|
||||||
return new User(
|
return $this->hydrate($data);
|
||||||
id: $data['id'],
|
|
||||||
email: new EmailAddress($data['email']),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function findByEmail(EmailAddress $email): ?User
|
||||||
|
{
|
||||||
|
$users = $this->readUsers();
|
||||||
|
|
||||||
|
foreach ($users as $data) {
|
||||||
|
if ($data['email'] === $email->value()) {
|
||||||
|
return $this->hydrate($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hydrate(array $data): User
|
||||||
|
{
|
||||||
|
return new User(
|
||||||
|
id: $data['id'],
|
||||||
|
email: new EmailAddress($data['email']),
|
||||||
|
passwordHash: $data['passwordHash'] ?? '',
|
||||||
|
isAdmin: $data['isAdmin'] ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private function readUsers(): array
|
private function readUsers(): array
|
||||||
{
|
{
|
||||||
if (!file_exists($this->filePath)) {
|
if (!file_exists($this->filePath)) {
|
||||||
|
|
|
||||||
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;
|
namespace App\User\UseCases;
|
||||||
|
|
||||||
|
use App\Auth\PasswordHasher;
|
||||||
use App\Exceptions\BadRequestException;
|
use App\Exceptions\BadRequestException;
|
||||||
|
use App\User\User;
|
||||||
use App\User\UserRepository;
|
use App\User\UserRepository;
|
||||||
use App\ValueObjects\EmailAddress;
|
use App\ValueObjects\EmailAddress;
|
||||||
|
|
||||||
|
|
@ -10,19 +12,37 @@ class CreateUser
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private UserRepository $userRepo,
|
private UserRepository $userRepo,
|
||||||
|
private PasswordHasher $passwordHasher,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws BadRequestException
|
* @throws BadRequestException
|
||||||
*/
|
*/
|
||||||
public function execute(CreateUserRequest $dto): void
|
public function execute(CreateUserRequest $dto): User
|
||||||
{
|
{
|
||||||
if ($dto->email === null) {
|
if ($dto->email === null) {
|
||||||
throw new BadRequestException('email is required');
|
throw new BadRequestException('email is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->userRepo->create(new CreateUserDto(
|
if ($dto->password === null) {
|
||||||
email: new EmailAddress($dto->email),
|
throw new BadRequestException('password is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($dto->password) < 8) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'password must be at least 8 characters'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$email = new EmailAddress($dto->email);
|
||||||
|
if ($this->userRepo->findByEmail($email) !== null) {
|
||||||
|
throw new BadRequestException('email already taken');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->userRepo->create(new CreateUserDto(
|
||||||
|
email: $email,
|
||||||
|
passwordHash: $this->passwordHasher->hash($dto->password),
|
||||||
|
isAdmin: $dto->isAdmin,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,5 +8,7 @@ class CreateUserDto
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public EmailAddress $email,
|
public EmailAddress $email,
|
||||||
|
public string $passwordHash,
|
||||||
|
public bool $isAdmin = false,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,5 +6,7 @@ class CreateUserRequest
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public ?string $email,
|
public ?string $email,
|
||||||
|
public ?string $password,
|
||||||
|
public bool $isAdmin,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ class User
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private int $id,
|
private int $id,
|
||||||
private EmailAddress $email,
|
private EmailAddress $email,
|
||||||
|
private string $passwordHash,
|
||||||
|
private bool $isAdmin,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function getId(): int
|
public function getId(): int
|
||||||
|
|
@ -20,4 +22,14 @@ class User
|
||||||
{
|
{
|
||||||
return $this->email;
|
return $this->email;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getPasswordHash(): string
|
||||||
|
{
|
||||||
|
return $this->passwordHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAdmin(): bool
|
||||||
|
{
|
||||||
|
return $this->isAdmin;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@
|
||||||
namespace App\User;
|
namespace App\User;
|
||||||
|
|
||||||
use App\User\UseCases\CreateUserDto;
|
use App\User\UseCases\CreateUserDto;
|
||||||
|
use App\ValueObjects\EmailAddress;
|
||||||
|
|
||||||
interface UserRepository
|
interface UserRepository
|
||||||
{
|
{
|
||||||
public function create(CreateUserDto $dto): User;
|
public function create(CreateUserDto $dto): User;
|
||||||
public function find(int $id): ?User;
|
public function find(int $id): ?User;
|
||||||
|
public function findByEmail(EmailAddress $email): ?User;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,11 @@ class EmailAddress
|
||||||
$this->normalized = $email;
|
$this->normalized = $email;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function value(): string
|
||||||
|
{
|
||||||
|
return $this->normalized;
|
||||||
|
}
|
||||||
|
|
||||||
public function __toString(): string
|
public function __toString(): string
|
||||||
{
|
{
|
||||||
return $this->normalized;
|
return $this->normalized;
|
||||||
|
|
|
||||||
|
|
@ -37,4 +37,24 @@ class ViewController
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function login(Response $response): Response
|
||||||
|
{
|
||||||
|
$html = file_get_contents(
|
||||||
|
__DIR__ . '/../../views/templates/login.php'
|
||||||
|
);
|
||||||
|
$response->getBody()->write($html);
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register(Response $response): Response
|
||||||
|
{
|
||||||
|
$html = file_get_contents(
|
||||||
|
__DIR__ . '/../../views/templates/register.php'
|
||||||
|
);
|
||||||
|
$response->getBody()->write($html);
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,10 @@
|
||||||
use Psr\Http\Message\ResponseInterface as Response;
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
use DI\Bridge\Slim\Bridge;
|
use DI\Bridge\Slim\Bridge;
|
||||||
|
use Slim\Routing\RouteCollectorProxy;
|
||||||
|
use App\Auth\AdminMiddleware;
|
||||||
|
use App\Auth\AuthController;
|
||||||
|
use App\Auth\AuthMiddleware;
|
||||||
use App\View\ViewController;
|
use App\View\ViewController;
|
||||||
use App\Text\TextController;
|
use App\Text\TextController;
|
||||||
use App\Node\NodeController;
|
use App\Node\NodeController;
|
||||||
|
|
@ -14,19 +18,48 @@ $app = Bridge::create($container);
|
||||||
// change first param to false for production
|
// change first param to false for production
|
||||||
$app->addErrorMiddleware(true, true, true);
|
$app->addErrorMiddleware(true, true, true);
|
||||||
|
|
||||||
$app->get('/home', [ViewController::class, 'home']);
|
// Public routes (no auth required)
|
||||||
$app->get('/admin', [ViewController::class, 'admin']);
|
$app->get('/login', [ViewController::class, 'login']);
|
||||||
$app->get('/admin/texts', [ViewController::class, 'texts']);
|
$app->get('/register', [ViewController::class, 'register']);
|
||||||
$app->get('/admin/texts/{textId}', [ViewController::class, 'text']);
|
$app->post('/api/auth/login', [AuthController::class, 'login']);
|
||||||
|
$app->post('/api/auth/register', [AuthController::class, 'register']);
|
||||||
|
|
||||||
$app->get('/api/texts', [TextController::class, 'getTexts']);
|
// Authenticated routes (any logged-in user)
|
||||||
$app->get('/api/texts/{textId}', [TextController::class, 'getText']);
|
$app->group('', function (RouteCollectorProxy $group) {
|
||||||
$app->post('/api/texts', [TextController::class, 'createText']);
|
$group->get('/home', [ViewController::class, 'home']);
|
||||||
|
|
||||||
$app->get('/api/nodes/{textId}', [NodeController::class, 'getNodesOfText']);
|
$group->post('/api/auth/logout', [AuthController::class, 'logout']);
|
||||||
$app->post('/api/nodes/bulk', [NodeController::class, 'bulkCreateNodes']);
|
$group->get('/api/auth/me', [AuthController::class, 'me']);
|
||||||
$app->post('/api/nodes', [NodeController::class, 'createNode']);
|
|
||||||
|
|
||||||
$app->post('/api/plans', [PlanController::class, 'createPlan']);
|
$group->get('/api/texts', [TextController::class, 'getTexts']);
|
||||||
|
$group->get(
|
||||||
|
'/api/texts/{textId}',
|
||||||
|
[TextController::class, 'getText']
|
||||||
|
);
|
||||||
|
|
||||||
|
$group->get(
|
||||||
|
'/api/nodes/{textId}',
|
||||||
|
[NodeController::class, 'getNodesOfText']
|
||||||
|
);
|
||||||
|
|
||||||
|
$group->post('/api/plans', [PlanController::class, 'createPlan']);
|
||||||
|
})->add(AuthMiddleware::class);
|
||||||
|
|
||||||
|
// Admin-only routes
|
||||||
|
$app->group('', function (RouteCollectorProxy $group) {
|
||||||
|
$group->get('/admin', [ViewController::class, 'admin']);
|
||||||
|
$group->get('/admin/texts', [ViewController::class, 'texts']);
|
||||||
|
$group->get(
|
||||||
|
'/admin/texts/{textId}',
|
||||||
|
[ViewController::class, 'text']
|
||||||
|
);
|
||||||
|
|
||||||
|
$group->post('/api/texts', [TextController::class, 'createText']);
|
||||||
|
$group->post(
|
||||||
|
'/api/nodes/bulk',
|
||||||
|
[NodeController::class, 'bulkCreateNodes']
|
||||||
|
);
|
||||||
|
$group->post('/api/nodes', [NodeController::class, 'createNode']);
|
||||||
|
})->add(AdminMiddleware::class)->add(AuthMiddleware::class);
|
||||||
|
|
||||||
return $app;
|
return $app;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,14 @@
|
||||||
|
|
||||||
use DI;
|
use DI;
|
||||||
use DI\Container;
|
use DI\Container;
|
||||||
|
use App\Auth\BcryptPasswordHasher;
|
||||||
|
use App\Auth\Clock;
|
||||||
|
use App\Auth\JsonSessionRepository;
|
||||||
|
use App\Auth\PasswordHasher;
|
||||||
|
use App\Auth\RandomTokenGenerator;
|
||||||
|
use App\Auth\SessionRepository;
|
||||||
|
use App\Auth\SystemClock;
|
||||||
|
use App\Auth\TokenGenerator;
|
||||||
use App\Text\TextRepository;
|
use App\Text\TextRepository;
|
||||||
use App\Text\JsonTextRepository;
|
use App\Text\JsonTextRepository;
|
||||||
use App\Node\NodeRepository;
|
use App\Node\NodeRepository;
|
||||||
|
|
@ -20,6 +28,11 @@ $container = new Container([
|
||||||
UserRepository::class => DI\autowire(JsonUserRepository::class),
|
UserRepository::class => DI\autowire(JsonUserRepository::class),
|
||||||
ScheduledNodeRepository::class =>
|
ScheduledNodeRepository::class =>
|
||||||
DI\autowire(JsonScheduledNodeRepository::class),
|
DI\autowire(JsonScheduledNodeRepository::class),
|
||||||
|
SessionRepository::class =>
|
||||||
|
DI\autowire(JsonSessionRepository::class),
|
||||||
|
TokenGenerator::class => DI\autowire(RandomTokenGenerator::class),
|
||||||
|
Clock::class => DI\autowire(SystemClock::class),
|
||||||
|
PasswordHasher::class => DI\autowire(BcryptPasswordHasher::class),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $container;
|
return $container;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
describe('The admin page', () => {
|
describe('The admin page', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.exec('npm run db:seed')
|
cy.exec('npm run db:seed')
|
||||||
|
cy.loginAsAdmin()
|
||||||
cy.visit('/admin')
|
cy.visit('/admin')
|
||||||
})
|
})
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
describe('The admin text detail page', () => {
|
describe('The admin text detail page', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.exec('npm run db:seed')
|
cy.exec('npm run db:seed')
|
||||||
|
cy.loginAsAdmin()
|
||||||
cy.intercept('GET', '/api/texts/0').as('getText')
|
cy.intercept('GET', '/api/texts/0').as('getText')
|
||||||
cy.intercept('GET', '/api/nodes/0').as('getNodes')
|
cy.intercept('GET', '/api/nodes/0').as('getNodes')
|
||||||
cy.visit('/admin/texts/0')
|
cy.visit('/admin/texts/0')
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
describe('Bulk add children on the admin text detail page', () => {
|
describe('Bulk add children on the admin text detail page', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.exec('npm run db:seed')
|
cy.exec('npm run db:seed')
|
||||||
|
cy.loginAsAdmin()
|
||||||
cy.intercept('GET', '/api/texts/0').as('getText')
|
cy.intercept('GET', '/api/texts/0').as('getText')
|
||||||
cy.intercept('GET', '/api/nodes/0').as('getNodes')
|
cy.intercept('GET', '/api/nodes/0').as('getNodes')
|
||||||
cy.visit('/admin/texts/0')
|
cy.visit('/admin/texts/0')
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
describe('Toggle display of child nodes', () => {
|
describe('Toggle display of child nodes', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.exec('npm run db:seed')
|
cy.exec('npm run db:seed')
|
||||||
|
cy.loginAsAdmin()
|
||||||
cy.intercept('GET', '/api/texts/0').as('getText')
|
cy.intercept('GET', '/api/texts/0').as('getText')
|
||||||
cy.intercept('GET', '/api/nodes/0').as('getNodes')
|
cy.intercept('GET', '/api/nodes/0').as('getNodes')
|
||||||
cy.visit('/admin/texts/0')
|
cy.visit('/admin/texts/0')
|
||||||
|
|
|
||||||
87
cypress/e2e/auth.cy.js
Normal file
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', () => {
|
describe('The home page', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.exec('npm run db:seed')
|
cy.exec('npm run db:seed')
|
||||||
|
cy.loginAsUser()
|
||||||
})
|
})
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cy.exec('npm run db:wipe')
|
cy.exec('npm run db:wipe')
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
describe('Create plan modal on the home page', () => {
|
describe('Create plan modal on the home page', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.exec('npm run db:seed')
|
cy.exec('npm run db:seed')
|
||||||
|
cy.loginAsUser()
|
||||||
cy.intercept('GET', '/api/texts').as('getTexts')
|
cy.intercept('GET', '/api/texts').as('getTexts')
|
||||||
cy.visit('/home')
|
cy.visit('/home')
|
||||||
cy.wait('@getTexts')
|
cy.wait('@getTexts')
|
||||||
|
|
@ -60,7 +61,6 @@ describe('Create plan modal on the home page', () => {
|
||||||
cy.wait('@createPlan').then((createPlanRequest) => {
|
cy.wait('@createPlan').then((createPlanRequest) => {
|
||||||
expect(createPlanRequest.response.statusCode).to.eq(201)
|
expect(createPlanRequest.response.statusCode).to.eq(201)
|
||||||
expect(createPlanRequest.request.body).to.deep.equal({
|
expect(createPlanRequest.request.body).to.deep.equal({
|
||||||
userId: 0,
|
|
||||||
textId: 0,
|
textId: 0,
|
||||||
name: 'My reading plan',
|
name: 'My reading plan',
|
||||||
dateStart: '2025-01-01',
|
dateStart: '2025-01-01',
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,15 @@
|
||||||
// ***********************************************
|
Cypress.Commands.add('login', (email, password) => {
|
||||||
// This example commands.js shows you how to
|
cy.request({
|
||||||
// create various custom commands and overwrite
|
method: 'POST',
|
||||||
// existing commands.
|
url: '/api/auth/login',
|
||||||
//
|
body: { email, password },
|
||||||
// For more comprehensive examples of custom
|
})
|
||||||
// commands please read more here:
|
})
|
||||||
// https://on.cypress.io/custom-commands
|
|
||||||
// ***********************************************
|
Cypress.Commands.add('loginAsAdmin', () => {
|
||||||
//
|
cy.login('admin@example.com', 'admin1234')
|
||||||
//
|
})
|
||||||
// -- This is a parent command --
|
|
||||||
// Cypress.Commands.add('login', (email, password) => { ... })
|
Cypress.Commands.add('loginAsUser', () => {
|
||||||
//
|
cy.login('user@example.com', 'password1')
|
||||||
//
|
})
|
||||||
// -- This is a child command --
|
|
||||||
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// -- This is a dual command --
|
|
||||||
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// -- This will overwrite an existing command --
|
|
||||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
|
||||||
|
|
|
||||||
|
|
@ -28,15 +28,27 @@ $nodes = [
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Default credentials:
|
||||||
|
// admin@example.com / admin1234 (admin)
|
||||||
|
// user@example.com / password1 (regular user)
|
||||||
$users = [
|
$users = [
|
||||||
[
|
[
|
||||||
'id' => 0,
|
'id' => 0,
|
||||||
|
'email' => 'admin@example.com',
|
||||||
|
'passwordHash' => password_hash('admin1234', PASSWORD_DEFAULT),
|
||||||
|
'isAdmin' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 1,
|
||||||
'email' => 'user@example.com',
|
'email' => 'user@example.com',
|
||||||
|
'passwordHash' => password_hash('password1', PASSWORD_DEFAULT),
|
||||||
|
'isAdmin' => false,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
$plans = [];
|
$plans = [];
|
||||||
$scheduledNodes = [];
|
$scheduledNodes = [];
|
||||||
|
$sessions = [];
|
||||||
|
|
||||||
$fileDataMap = [
|
$fileDataMap = [
|
||||||
'texts.json' => $texts,
|
'texts.json' => $texts,
|
||||||
|
|
@ -44,6 +56,7 @@ $fileDataMap = [
|
||||||
'users.json' => $users,
|
'users.json' => $users,
|
||||||
'plans.json' => $plans,
|
'plans.json' => $plans,
|
||||||
'scheduledNodes.json' => $scheduledNodes,
|
'scheduledNodes.json' => $scheduledNodes,
|
||||||
|
'sessions.json' => $sessions,
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($fileDataMap as $file => $data) {
|
foreach ($fileDataMap as $file => $data) {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ $files = [
|
||||||
'users.json',
|
'users.json',
|
||||||
'plans.json',
|
'plans.json',
|
||||||
'scheduledNodes.json',
|
'scheduledNodes.json',
|
||||||
|
'sessions.json',
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($files as $file) {
|
foreach ($files as $file) {
|
||||||
|
|
|
||||||
75
public/js/auth.js
Normal file
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');
|
const createPlanModal = document.getElementById('create-plan-modal');
|
||||||
|
|
||||||
async function loadTexts() {
|
async function loadTexts() {
|
||||||
const response = await fetch('/api/texts');
|
const response = await fetch('/api/texts', {
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
const texts = await response.json();
|
const texts = await response.json();
|
||||||
textsList.innerHTML = texts
|
textsList.innerHTML = texts
|
||||||
.map(text =>
|
.map(text =>
|
||||||
|
|
@ -67,8 +69,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
const response = await fetch('/api/plans', {
|
const response = await fetch('/api/plans', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
userId: 0,
|
|
||||||
textId: textId,
|
textId: textId,
|
||||||
name: planName,
|
name: planName,
|
||||||
dateStart: dateStart,
|
dateStart: dateStart,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const textId = window.location.pathname.split('/').pop();
|
const textId = window.location.pathname.split('/').pop();
|
||||||
|
|
||||||
fetch('/api/texts/' + textId)
|
fetch('/api/texts/' + textId, { credentials: 'same-origin' })
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(text => {
|
.then(text => {
|
||||||
const h1 = document.createElement('h1');
|
const h1 = document.createElement('h1');
|
||||||
|
|
@ -13,7 +13,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
function fetchAndRenderNodes(textId) {
|
function fetchAndRenderNodes(textId) {
|
||||||
return fetch('/api/nodes/' + textId)
|
return fetch('/api/nodes/' + textId, { credentials: 'same-origin' })
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(nodes => {
|
.then(nodes => {
|
||||||
const existing = document.querySelector('#text-detail > ul');
|
const existing = document.querySelector('#text-detail > ul');
|
||||||
|
|
@ -113,6 +113,7 @@ function toggleAddForm(li, parentNodeId, textId) {
|
||||||
fetch('/api/nodes', {
|
fetch('/api/nodes', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
body: JSON.stringify({ textId: parseInt(textId), title, parentNodeId }),
|
body: JSON.stringify({ textId: parseInt(textId), title, parentNodeId }),
|
||||||
})
|
})
|
||||||
.then(res => {
|
.then(res => {
|
||||||
|
|
@ -157,6 +158,7 @@ function toggleBulkAddForm(li, parentNodeId, textId) {
|
||||||
fetch('/api/nodes/bulk', {
|
fetch('/api/nodes/bulk', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
body: JSON.stringify({ textId: parseInt(textId), parentNodeId, titlePrefix, count }),
|
body: JSON.stringify({ textId: parseInt(textId), parentNodeId, titlePrefix, count }),
|
||||||
})
|
})
|
||||||
.then(res => {
|
.then(res => {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
const form = document.getElementById('texts-form');
|
const form = document.getElementById('texts-form');
|
||||||
|
|
||||||
async function loadTexts() {
|
async function loadTexts() {
|
||||||
const res = await fetch('/api/texts');
|
const res = await fetch('/api/texts', {
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
const texts = await res.json();
|
const texts = await res.json();
|
||||||
textsList.innerHTML = texts.map(text =>
|
textsList.innerHTML = texts.map(text =>
|
||||||
'<li><a href=/admin/texts/'
|
'<li><a href=/admin/texts/'
|
||||||
|
|
@ -18,6 +20,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
const formData = new FormData(form);
|
const formData = new FormData(form);
|
||||||
const res = await fetch('/api/texts', {
|
const res = await fetch('/api/texts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
|
|
||||||
36
tests/Fakes/FakeClock.php
Normal file
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\UseCases\CreateUserDto;
|
||||||
use App\User\User;
|
use App\User\User;
|
||||||
use App\User\UserRepository;
|
use App\User\UserRepository;
|
||||||
|
use App\ValueObjects\EmailAddress;
|
||||||
|
|
||||||
class FakeUserRepository implements UserRepository
|
class FakeUserRepository implements UserRepository
|
||||||
{
|
{
|
||||||
|
|
@ -28,6 +29,28 @@ class FakeUserRepository implements UserRepository
|
||||||
return new User(
|
return new User(
|
||||||
id: $user->getId(),
|
id: $user->getId(),
|
||||||
email: $user->getEmail(),
|
email: $user->getEmail(),
|
||||||
|
passwordHash: $user->getPasswordHash(),
|
||||||
|
isAdmin: $user->isAdmin(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findByEmail(EmailAddress $email): ?User
|
||||||
|
{
|
||||||
|
$user = array_find(
|
||||||
|
$this->existingUsers,
|
||||||
|
function (User $user) use ($email) {
|
||||||
|
return $user->getEmail()->value() === $email->value();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if ($user === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new User(
|
||||||
|
id: $user->getId(),
|
||||||
|
email: $user->getEmail(),
|
||||||
|
passwordHash: $user->getPasswordHash(),
|
||||||
|
isAdmin: $user->isAdmin(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -37,6 +60,8 @@ class FakeUserRepository implements UserRepository
|
||||||
$user = new User(
|
$user = new User(
|
||||||
id: $id,
|
id: $id,
|
||||||
email: $dto->email,
|
email: $dto->email,
|
||||||
|
passwordHash: $dto->passwordHash,
|
||||||
|
isAdmin: $dto->isAdmin,
|
||||||
);
|
);
|
||||||
$this->existingUsers[$id] = $user;
|
$this->existingUsers[$id] = $user;
|
||||||
|
|
||||||
|
|
|
||||||
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->scheduledNodeRepo = new FakeScheduledNodeRepository();
|
||||||
$this->userRepo->create(new CreateUserDto(
|
$this->userRepo->create(new CreateUserDto(
|
||||||
email: new EmailAddress('test@test.com'),
|
email: new EmailAddress('test@test.com'),
|
||||||
|
passwordHash: '',
|
||||||
));
|
));
|
||||||
$this->createScheduledNode = new CreateScheduledNode(
|
$this->createScheduledNode = new CreateScheduledNode(
|
||||||
scheduledNodeRepo: $this->scheduledNodeRepo,
|
scheduledNodeRepo: $this->scheduledNodeRepo,
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,12 @@ class CreateScheduledNodeTest extends TestCase
|
||||||
$this->planRepo = new FakePlanRepository();
|
$this->planRepo = new FakePlanRepository();
|
||||||
$this->planRepo->create(new CreatePlanDto(
|
$this->planRepo->create(new CreatePlanDto(
|
||||||
name: 'testplan',
|
name: 'testplan',
|
||||||
user: new User(0, new EmailAddress('test@test.com')),
|
user: new User(
|
||||||
|
id: 0,
|
||||||
|
email: new EmailAddress('test@test.com'),
|
||||||
|
passwordHash: 'hashed:password1',
|
||||||
|
isAdmin: false,
|
||||||
|
),
|
||||||
));
|
));
|
||||||
$this->useCase = new CreateScheduledNode(
|
$this->useCase = new CreateScheduledNode(
|
||||||
$this->scheduledNodeRepo,
|
$this->scheduledNodeRepo,
|
||||||
|
|
|
||||||
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\User;
|
||||||
use App\User\UseCases\CreateUser;
|
use App\User\UseCases\CreateUser;
|
||||||
use App\User\UseCases\CreateUserRequest;
|
use App\User\UseCases\CreateUserRequest;
|
||||||
|
use Tests\Fakes\FakePasswordHasher;
|
||||||
use Tests\Fakes\FakeUserRepository;
|
use Tests\Fakes\FakeUserRepository;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
class CreateUserTest extends TestCase
|
class CreateUserTest extends TestCase
|
||||||
{
|
{
|
||||||
|
private FakeUserRepository $userRepo;
|
||||||
|
private FakePasswordHasher $passwordHasher;
|
||||||
|
private CreateUser $useCase;
|
||||||
|
|
||||||
|
public function setUp(): void
|
||||||
|
{
|
||||||
|
$this->userRepo = new FakeUserRepository();
|
||||||
|
$this->passwordHasher = new FakePasswordHasher();
|
||||||
|
$this->useCase = new CreateUser(
|
||||||
|
$this->userRepo,
|
||||||
|
$this->passwordHasher,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_create_user(): void
|
public function test_create_user(): void
|
||||||
{
|
{
|
||||||
$userRepo = new FakeUserRepository();
|
$this->useCase->execute(new CreateUserRequest(
|
||||||
$useCase = new CreateUser($userRepo);
|
|
||||||
$useCase->execute(new CreateUserRequest(
|
|
||||||
email: 'test@test.com',
|
email: 'test@test.com',
|
||||||
|
password: 'password1',
|
||||||
|
isAdmin: false,
|
||||||
));
|
));
|
||||||
$user = $userRepo->find(0);
|
$user = $this->userRepo->find(0);
|
||||||
$this->assertInstanceOf(User::class, $user);
|
$this->assertInstanceOf(User::class, $user);
|
||||||
$this->assertEquals('test@test.com', $user->getEmail());
|
$this->assertEquals('test@test.com', $user->getEmail());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_throws_if_email_is_null(): void
|
public function test_throws_if_email_is_null(): void
|
||||||
{
|
{
|
||||||
$userRepo = new FakeUserRepository();
|
|
||||||
$useCase = new CreateUser($userRepo);
|
|
||||||
|
|
||||||
$this->expectException(BadRequestException::class);
|
$this->expectException(BadRequestException::class);
|
||||||
$this->expectExceptionMessage('email is required');
|
$this->expectExceptionMessage('email is required');
|
||||||
|
|
||||||
$useCase->execute(new CreateUserRequest(
|
$this->useCase->execute(new CreateUserRequest(
|
||||||
email: null,
|
email: null,
|
||||||
|
password: 'password1',
|
||||||
|
isAdmin: false,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_is_admin_can_be_set_true(): void
|
||||||
|
{
|
||||||
|
$this->useCase->execute(new CreateUserRequest(
|
||||||
|
email: 'test@test.com',
|
||||||
|
password: 'password1',
|
||||||
|
isAdmin: true,
|
||||||
|
));
|
||||||
|
$user = $this->userRepo->find(0);
|
||||||
|
$this->assertTrue($user->isAdmin());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_throws_when_email_already_taken(): void
|
||||||
|
{
|
||||||
|
$this->useCase->execute(new CreateUserRequest(
|
||||||
|
email: 'test@test.com',
|
||||||
|
password: 'password1',
|
||||||
|
isAdmin: false,
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->expectException(BadRequestException::class);
|
||||||
|
$this->expectExceptionMessage('email already taken');
|
||||||
|
|
||||||
|
$this->useCase->execute(new CreateUserRequest(
|
||||||
|
email: 'test@test.com',
|
||||||
|
password: 'password1',
|
||||||
|
isAdmin: false
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_throws_if_password_is_null(): void
|
||||||
|
{
|
||||||
|
$this->expectException(BadRequestException::class);
|
||||||
|
$this->expectExceptionMessage('password is required');
|
||||||
|
|
||||||
|
$this->useCase->execute(new CreateUserRequest(
|
||||||
|
email: 'test@test.com',
|
||||||
|
password: null,
|
||||||
|
isAdmin: false,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_throws_if_password_too_short(): void
|
||||||
|
{
|
||||||
|
$this->expectException(BadRequestException::class);
|
||||||
|
$this->expectExceptionMessage(
|
||||||
|
'password must be at least 8 characters'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->useCase->execute(new CreateUserRequest(
|
||||||
|
email: 'test@test.com',
|
||||||
|
password: 'short',
|
||||||
|
isAdmin: false,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_stores_hashed_password(): void
|
||||||
|
{
|
||||||
|
$this->useCase->execute(new CreateUserRequest(
|
||||||
|
email: 'test@test.com',
|
||||||
|
password: 'password1',
|
||||||
|
isAdmin: false,
|
||||||
|
));
|
||||||
|
$user = $this->userRepo->find(0);
|
||||||
|
$this->assertNotEquals('password1', $user->getPasswordHash());
|
||||||
|
$this->assertTrue(
|
||||||
|
$this->passwordHasher->verify(
|
||||||
|
'password1',
|
||||||
|
$user->getPasswordHash()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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\ScheduledNode\UseCases\CreateScheduledNode;
|
||||||
use App\Text\CreateTextDto;
|
use App\Text\CreateTextDto;
|
||||||
use App\User\UseCases\CreateUserDto;
|
use App\User\UseCases\CreateUserDto;
|
||||||
|
use App\User\User;
|
||||||
use App\ValueObjects\EmailAddress;
|
use App\ValueObjects\EmailAddress;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
|
@ -29,6 +30,7 @@ class PlanControllerTest extends TestCase
|
||||||
private FakeScheduledNodeRepository $scheduledNodeRepo;
|
private FakeScheduledNodeRepository $scheduledNodeRepo;
|
||||||
private CreatePlan $createPlan;
|
private CreatePlan $createPlan;
|
||||||
private PlanController $controller;
|
private PlanController $controller;
|
||||||
|
private User $user;
|
||||||
|
|
||||||
public function setUp(): void
|
public function setUp(): void
|
||||||
{
|
{
|
||||||
|
|
@ -38,8 +40,9 @@ class PlanControllerTest extends TestCase
|
||||||
$this->nodeRepo = new FakeNodeRepository();
|
$this->nodeRepo = new FakeNodeRepository();
|
||||||
$this->scheduledNodeRepo = new FakeScheduledNodeRepository();
|
$this->scheduledNodeRepo = new FakeScheduledNodeRepository();
|
||||||
|
|
||||||
$this->userRepo->create(new CreateUserDto(
|
$this->user = $this->userRepo->create(new CreateUserDto(
|
||||||
email: new EmailAddress('test@test.com'),
|
email: new EmailAddress('test@test.com'),
|
||||||
|
passwordHash: '',
|
||||||
));
|
));
|
||||||
$text = $this->textRepo->create(new CreateTextDto('testname'));
|
$text = $this->textRepo->create(new CreateTextDto('testname'));
|
||||||
$this->nodeRepo->create(new CreateNodeDto(
|
$this->nodeRepo->create(new CreateNodeDto(
|
||||||
|
|
@ -68,6 +71,7 @@ class PlanControllerTest extends TestCase
|
||||||
return new ServerRequestFactory()
|
return new ServerRequestFactory()
|
||||||
->createServerRequest('POST', 'http://localhost/api/plans')
|
->createServerRequest('POST', 'http://localhost/api/plans')
|
||||||
->withHeader('Content-Type', 'application/json')
|
->withHeader('Content-Type', 'application/json')
|
||||||
|
->withAttribute('user', $this->user)
|
||||||
->withBody($body);
|
->withBody($body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,7 +79,6 @@ class PlanControllerTest extends TestCase
|
||||||
{
|
{
|
||||||
$response = $this->controller->createPlan(
|
$response = $this->controller->createPlan(
|
||||||
$this->makeRequest([
|
$this->makeRequest([
|
||||||
'userId' => 0,
|
|
||||||
'textId' => 0,
|
'textId' => 0,
|
||||||
'name' => 'My Plan',
|
'name' => 'My Plan',
|
||||||
'dateStart' => '2025-01-01',
|
'dateStart' => '2025-01-01',
|
||||||
|
|
@ -91,29 +94,33 @@ class PlanControllerTest extends TestCase
|
||||||
$this->assertEquals('My Plan', $body['name']);
|
$this->assertEquals('My Plan', $body['name']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_create_plan_returns_400_when_user_id_missing(): void
|
public function test_create_plan_returns_401_when_no_user(): void
|
||||||
{
|
{
|
||||||
$response = $this->controller->createPlan(
|
$requestWithoutUser = new ServerRequestFactory()
|
||||||
$this->makeRequest([
|
->createServerRequest('POST', 'http://localhost/api/plans')
|
||||||
|
->withHeader('Content-Type', 'application/json')
|
||||||
|
->withBody(
|
||||||
|
new StreamFactory()->createStream(json_encode([
|
||||||
'textId' => 0,
|
'textId' => 0,
|
||||||
'name' => 'My Plan',
|
'name' => 'My Plan',
|
||||||
'dateStart' => '2025-01-01',
|
'dateStart' => '2025-01-01',
|
||||||
'dateEnd' => '2025-01-01',
|
'dateEnd' => '2025-01-01',
|
||||||
]),
|
]))
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->controller->createPlan(
|
||||||
|
$requestWithoutUser,
|
||||||
new Response(),
|
new Response(),
|
||||||
$this->createPlan,
|
$this->createPlan,
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->assertEquals(400, $response->getStatusCode());
|
$this->assertEquals(401, $response->getStatusCode());
|
||||||
$body = json_decode($response->getBody(), true);
|
|
||||||
$this->assertArrayHasKey('error', $body);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_create_plan_returns_400_when_text_id_missing(): void
|
public function test_create_plan_returns_400_when_text_id_missing(): void
|
||||||
{
|
{
|
||||||
$response = $this->controller->createPlan(
|
$response = $this->controller->createPlan(
|
||||||
$this->makeRequest([
|
$this->makeRequest([
|
||||||
'userId' => 0,
|
|
||||||
'name' => 'My Plan',
|
'name' => 'My Plan',
|
||||||
'dateStart' => '2025-01-01',
|
'dateStart' => '2025-01-01',
|
||||||
'dateEnd' => '2025-01-01',
|
'dateEnd' => '2025-01-01',
|
||||||
|
|
@ -131,7 +138,6 @@ class PlanControllerTest extends TestCase
|
||||||
{
|
{
|
||||||
$response = $this->controller->createPlan(
|
$response = $this->controller->createPlan(
|
||||||
$this->makeRequest([
|
$this->makeRequest([
|
||||||
'userId' => 0,
|
|
||||||
'textId' => 0,
|
'textId' => 0,
|
||||||
'dateStart' => '2025-01-01',
|
'dateStart' => '2025-01-01',
|
||||||
'dateEnd' => '2025-01-01',
|
'dateEnd' => '2025-01-01',
|
||||||
|
|
@ -149,7 +155,6 @@ class PlanControllerTest extends TestCase
|
||||||
{
|
{
|
||||||
$response = $this->controller->createPlan(
|
$response = $this->controller->createPlan(
|
||||||
$this->makeRequest([
|
$this->makeRequest([
|
||||||
'userId' => 0,
|
|
||||||
'textId' => 0,
|
'textId' => 0,
|
||||||
'name' => 'My Plan',
|
'name' => 'My Plan',
|
||||||
'dateEnd' => '2025-01-01',
|
'dateEnd' => '2025-01-01',
|
||||||
|
|
@ -167,7 +172,6 @@ class PlanControllerTest extends TestCase
|
||||||
{
|
{
|
||||||
$response = $this->controller->createPlan(
|
$response = $this->controller->createPlan(
|
||||||
$this->makeRequest([
|
$this->makeRequest([
|
||||||
'userId' => 0,
|
|
||||||
'textId' => 0,
|
'textId' => 0,
|
||||||
'name' => 'My Plan',
|
'name' => 'My Plan',
|
||||||
'dateStart' => '2025-01-01',
|
'dateStart' => '2025-01-01',
|
||||||
|
|
@ -185,7 +189,6 @@ class PlanControllerTest extends TestCase
|
||||||
{
|
{
|
||||||
$response = $this->controller->createPlan(
|
$response = $this->controller->createPlan(
|
||||||
$this->makeRequest([
|
$this->makeRequest([
|
||||||
'userId' => 0,
|
|
||||||
'textId' => 0,
|
'textId' => 0,
|
||||||
'name' => 'My Plan',
|
'name' => 'My Plan',
|
||||||
'dateStart' => '2025-01-02',
|
'dateStart' => '2025-01-02',
|
||||||
|
|
@ -200,30 +203,10 @@ class PlanControllerTest extends TestCase
|
||||||
$this->assertArrayHasKey('error', $body);
|
$this->assertArrayHasKey('error', $body);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_create_plan_returns_404_when_user_not_found(): void
|
|
||||||
{
|
|
||||||
$response = $this->controller->createPlan(
|
|
||||||
$this->makeRequest([
|
|
||||||
'userId' => 99,
|
|
||||||
'textId' => 0,
|
|
||||||
'name' => 'My Plan',
|
|
||||||
'dateStart' => '2025-01-01',
|
|
||||||
'dateEnd' => '2025-01-01',
|
|
||||||
]),
|
|
||||||
new Response(),
|
|
||||||
$this->createPlan,
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->assertEquals(404, $response->getStatusCode());
|
|
||||||
$body = json_decode($response->getBody(), true);
|
|
||||||
$this->assertArrayHasKey('error', $body);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_create_plan_returns_404_when_text_not_found(): void
|
public function test_create_plan_returns_404_when_text_not_found(): void
|
||||||
{
|
{
|
||||||
$response = $this->controller->createPlan(
|
$response = $this->controller->createPlan(
|
||||||
$this->makeRequest([
|
$this->makeRequest([
|
||||||
'userId' => 0,
|
|
||||||
'textId' => 99,
|
'textId' => 99,
|
||||||
'name' => 'My Plan',
|
'name' => 'My Plan',
|
||||||
'dateStart' => '2025-01-01',
|
'dateStart' => '2025-01-01',
|
||||||
|
|
@ -242,7 +225,6 @@ class PlanControllerTest extends TestCase
|
||||||
{
|
{
|
||||||
$this->controller->createPlan(
|
$this->controller->createPlan(
|
||||||
$this->makeRequest([
|
$this->makeRequest([
|
||||||
'userId' => 0,
|
|
||||||
'textId' => 0,
|
'textId' => 0,
|
||||||
'name' => 'Persistent Plan',
|
'name' => 'Persistent Plan',
|
||||||
'dateStart' => '2025-01-01',
|
'dateStart' => '2025-01-01',
|
||||||
|
|
@ -261,7 +243,6 @@ class PlanControllerTest extends TestCase
|
||||||
{
|
{
|
||||||
$this->controller->createPlan(
|
$this->controller->createPlan(
|
||||||
$this->makeRequest([
|
$this->makeRequest([
|
||||||
'userId' => 0,
|
|
||||||
'textId' => 0,
|
'textId' => 0,
|
||||||
'name' => 'Scheduling Plan',
|
'name' => 'Scheduling Plan',
|
||||||
'dateStart' => '2025-01-01',
|
'dateStart' => '2025-01-01',
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
<title>Daily Goals - Admin</title>
|
<title>Daily Goals - Admin</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<button id="logout">Logout</button>
|
||||||
<a href="/admin/texts" id="texts">Texts</a>
|
<a href="/admin/texts" id="texts">Texts</a>
|
||||||
|
<script src="/js/auth.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Home</h1>
|
<h1>Home</h1>
|
||||||
|
<button id="logout">Logout</button>
|
||||||
<ul id="texts-list">
|
<ul id="texts-list">
|
||||||
</ul>
|
</ul>
|
||||||
<div id="create-plan-modal" hidden>
|
<div id="create-plan-modal" hidden>
|
||||||
|
|
@ -21,6 +22,7 @@
|
||||||
<button class="save-plan">Save</button>
|
<button class="save-plan">Save</button>
|
||||||
<button class="cancel-plan">Cancel</button>
|
<button class="cancel-plan">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="/js/auth.js"></script>
|
||||||
<script src="/js/home.js"></script>
|
<script src="/js/home.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
26
views/templates/login.php
Normal file
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