Compare commits

...

87 commits

Author SHA1 Message Date
041590da15
Merge branch 'auth-and-admin' 2026-04-26 20:05:31 +03:00
b41652af71
remove default value in fake token generator 2026-04-26 10:51:03 +03:00
cd40483cd4
remove default values from user constructors
Forcing every call site to be explicit about admin status and
password eliminates a class of bugs where an unintended
isAdmin=false or empty passwordHash could silently slip through.
The CreateUserTest case that asserted the isAdmin default is
dropped since the default no longer exists.
2026-04-26 10:46:07 +03:00
f95adddaaf
fix code style in test files 2026-04-26 10:45:57 +03:00
13da7c311a
return utc from clock 2026-04-26 10:32:08 +03:00
2fe41a5fe7
no need to test concrete implementations 2026-04-26 10:23:57 +03:00
a65c9259fa
dont cast email to string, use new value method 2026-04-26 10:19:38 +03:00
099883a13d
change em dashes to hyphen and add rule outlawing emdashes 2026-04-26 10:15:03 +03:00
40726c3984
loosen commit granularity rule in prompts
Replace the strict "one commit per file" guidance with grouping by
related changes, while keeping the small-and-focused intent. Add
explicit guidance on when to include a commit body and how to format
it (blank line separator, ~72 col wrap).

Applied to both backend and frontend prompt templates.
2026-04-26 10:11:23 +03:00
bb6bd7cbb3
use FakePasswordHasher in tests to eliminate bcrypt cost
Add a trivial prefix-based PasswordHasher fake and inject it into the
three test files that exercise CreateUser or AuthenticateUser. Drops
the full phpunit suite from ~7.4s to ~30ms (about 224x) without
losing coverage: the round-trip through hash/verify still validates
that CreateUser stores something other than the plaintext and that
AuthenticateUser only succeeds on a matching hash.

CreateUserTest is also refactored to use a setUp method, matching
the pattern already used in AuthenticateUserTest and AuthControllerTest.
2026-04-26 09:06:26 +03:00
632085f5b6
inject PasswordHasher into CreateUser and AuthenticateUser
Replace direct password_hash and password_verify calls with the
injected PasswordHasher so the bcrypt cost can be substituted out
in tests. Production wiring is handled by the container's autowiring
of BcryptPasswordHasher.

This commit alone breaks the test suite because the existing tests
construct these use cases without the new dependency; the next
commit restores green by introducing FakePasswordHasher.
2026-04-26 09:06:21 +03:00
b1247d2fa1
add PasswordHasher interface with bcrypt implementation
Introduce an injectable abstraction over password_hash and
password_verify so callers can be swapped for a fast fake in tests
without paying bcrypt's CPU cost. The bcrypt implementation is a
direct passthrough using PASSWORD_DEFAULT, matching the prior inline
behavior, so existing stored hashes continue to verify.

Wired into the DI container alongside the other auth primitives
(Clock, TokenGenerator). No callers reference it yet, so production
behavior is unchanged.
2026-04-26 09:06:17 +03:00
d93b668d5a
add session entity 2026-04-25 22:08:25 +03:00
4975da19be
test auth flows in cypress 2026-04-24 16:08:32 +03:00
4c393e813a
login as admin in admin text bulk add cypress spec 2026-04-24 16:08:14 +03:00
e8fcac654b
login as admin in admin text toggle cypress spec 2026-04-24 16:08:03 +03:00
3ee6057978
login as admin in admin text cypress spec 2026-04-24 16:07:53 +03:00
5f2bba070c
login as admin in admin cypress spec 2026-04-24 16:07:44 +03:00
6e93bd3872
update create plan body assertion 2026-04-24 16:07:36 +03:00
8bfc110ed3
login as user in home create plan cypress spec 2026-04-24 16:07:25 +03:00
cddc72e6cf
login as user in home cypress spec 2026-04-24 16:07:16 +03:00
49c5ed49b0
add cypress login commands 2026-04-24 16:07:07 +03:00
95f7f1cb78
seed regular user for cypress 2026-04-24 16:06:59 +03:00
5a24f5bde4
read user from request in plan controller 2026-04-24 13:32:04 +03:00
05374991c5
update plan controller tests for auth 2026-04-24 13:31:44 +03:00
c649dbbcc2
include credentials on fetch calls 2026-04-24 13:30:54 +03:00
4e039fb583
add logout button to admin 2026-04-24 13:30:14 +03:00
e4494a0577
add logout button to home 2026-04-24 13:30:04 +03:00
8c52294b10
remove hardcoded user id from home 2026-04-24 13:29:49 +03:00
cb697daa03
add auth javascript 2026-04-24 13:29:38 +03:00
ce029fafa2
add register template 2026-04-24 13:29:25 +03:00
6e0cda7f3e
add login template 2026-04-24 13:29:16 +03:00
74a0e5980f
wire auth routes and middleware groups 2026-04-24 13:28:58 +03:00
5f207f7fcb
add login and register view methods 2026-04-24 13:28:40 +03:00
c9d5ad37b8
add auth controller 2026-04-24 13:28:22 +03:00
edfe7259a3
test auth controller 2026-04-24 13:27:49 +03:00
6c5833af5e
return user from create user use case 2026-04-24 13:27:15 +03:00
bb4e27a45b
add admin middleware 2026-04-24 13:26:38 +03:00
40649ded8e
test admin middleware 2026-04-24 13:26:20 +03:00
a7b7a4a96b
add forbidden template 2026-04-24 13:26:01 +03:00
2666f40c27
add forbidden exception 2026-04-24 13:25:52 +03:00
d549cf914f
add auth middleware 2026-04-24 13:25:36 +03:00
cd2168c822
test auth middleware 2026-04-24 13:25:17 +03:00
821f654d69
bind clock and token generator 2026-04-24 13:24:39 +03:00
05f4f334e6
add create session use case 2026-04-24 13:24:21 +03:00
2a281386a5
test create session 2026-04-24 13:24:01 +03:00
78ffb77f9f
add fake clock 2026-04-24 13:23:46 +03:00
de4d577781
add system clock 2026-04-24 13:23:38 +03:00
04712bdd2d
add clock interface 2026-04-24 13:23:30 +03:00
057df09dda
add fake token generator 2026-04-24 13:23:23 +03:00
07040851ec
add random token generator 2026-04-24 13:23:12 +03:00
a0bea204b4
add token generator interface 2026-04-24 13:23:05 +03:00
cb73688a99
seed and wipe sessions file 2026-04-24 13:22:48 +03:00
ef842f5758
bind session repository 2026-04-24 13:22:33 +03:00
762bbb7fda
add json session repository 2026-04-24 13:22:19 +03:00
619ebd3907
add fake session repository 2026-04-24 13:22:06 +03:00
c2ade8a601
test fake session repository 2026-04-24 13:21:51 +03:00
503df8be7a
add session repository interface 2026-04-24 13:21:37 +03:00
b37e80147c
add create session dto 2026-04-24 13:21:28 +03:00
6fbdc82589
add session entity 2026-04-24 13:21:20 +03:00
79d9ece2ae
add authenticate user use case 2026-04-24 13:21:02 +03:00
fd5278b3fe
test authenticate user 2026-04-24 13:20:45 +03:00
20e4a6ee69
add authenticate user request 2026-04-24 13:20:30 +03:00
271f28936d
add unauthorized exception 2026-04-24 13:20:22 +03:00
f012728876
seed admin with hashed password 2026-04-24 13:20:05 +03:00
73ade7f971
update tests for user password hash 2026-04-24 13:19:51 +03:00
ada29ea957
store password hash in json user repo 2026-04-24 13:19:15 +03:00
a52bb18b13
store password hash in fake user repo 2026-04-24 13:19:00 +03:00
0f179e53c2
hash password in create user 2026-04-24 13:18:44 +03:00
016e98412b
add password hash to user entity 2026-04-24 13:18:33 +03:00
5093259063
add password hash to create user dto 2026-04-24 13:18:24 +03:00
261319078d
add password to create user request 2026-04-24 13:18:15 +03:00
38cfd34645
test create user requires password 2026-04-24 13:18:00 +03:00
96ad78425f
reject duplicate email in create user 2026-04-24 13:17:11 +03:00
30b8cc2c74
test create user rejects duplicate email 2026-04-24 13:16:57 +03:00
ac461afcf0
implement find by email in json user repo 2026-04-24 13:16:41 +03:00
64edec5141
implement find by email in fake user repo 2026-04-24 13:16:29 +03:00
ee271e162e
add find by email to user repository 2026-04-24 13:16:11 +03:00
b2fc6a7ded
test fake user repo find by email 2026-04-24 13:16:02 +03:00
cbeb43f18c
seed is admin on default user 2026-04-24 13:15:33 +03:00
54db92a76c
persist is admin in json user repo 2026-04-24 13:15:24 +03:00
dcb4df043e
store is admin in fake user repo 2026-04-24 13:15:12 +03:00
4157710187
pass is admin through create user 2026-04-24 13:14:57 +03:00
0e86af3e81
add is admin to user entity 2026-04-24 13:14:48 +03:00
affa1e7b1b
add is admin to create user dto 2026-04-24 13:14:39 +03:00
b9f7fcf148
add is admin to create user request 2026-04-24 13:14:30 +03:00
160181888d
test user has is admin flag 2026-04-24 13:14:20 +03:00
65 changed files with 2160 additions and 125 deletions

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,9 +3,11 @@
namespace App\User;
use App\User\UseCases\CreateUserDto;
use App\ValueObjects\EmailAddress;
interface UserRepository
{
public function create(CreateUserDto $dto): User;
public function find(int $id): ?User;
public function findByEmail(EmailAddress $email): ?User;
}

View file

@ -11,6 +11,11 @@ class EmailAddress
$this->normalized = $email;
}
public function value(): string
{
return $this->normalized;
}
public function __toString(): string
{
return $this->normalized;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -39,6 +39,7 @@ class CreatePlanTest extends TestCase
$this->scheduledNodeRepo = new FakeScheduledNodeRepository();
$this->userRepo->create(new CreateUserDto(
email: new EmailAddress('test@test.com'),
passwordHash: '',
));
$this->createScheduledNode = new CreateScheduledNode(
scheduledNodeRepo: $this->scheduledNodeRepo,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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