diff --git a/backend/.gitignore b/backend/.gitignore index a725465..c15afe2 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1 +1,2 @@ -vendor/ \ No newline at end of file +vendor/ +.phpunit.cache/ diff --git a/backend/app/Auth/BcryptPasswordHasher.php b/backend/app/Auth/BcryptPasswordHasher.php new file mode 100644 index 0000000..0bc4a46 --- /dev/null +++ b/backend/app/Auth/BcryptPasswordHasher.php @@ -0,0 +1,16 @@ +token; + } + + public function getUser(): User + { + return $this->user; + } + + public function getCreatedAt(): DateTimeImmutable + { + return $this->createdAt; + } + + public function getExpiresAt(): DateTimeImmutable + { + return $this->expiresAt; + } + + public function isExpired(DateTimeImmutable $now): bool + { + return $now >= $this->expiresAt; + } +} diff --git a/backend/app/Auth/SessionRepository.php b/backend/app/Auth/SessionRepository.php new file mode 100644 index 0000000..cabae60 --- /dev/null +++ b/backend/app/Auth/SessionRepository.php @@ -0,0 +1,12 @@ +email === null || $request->email === '') { + throw new BadRequestException('email is required'); + } + if ($request->password === null || $request->password === '') { + throw new BadRequestException('password is required'); + } + + $user = $this->userRepo->findByEmail( + new EmailAddress($request->email), + ); + + if ($user === null) { + throw new UnauthorizedException('invalid credentials'); + } + + $passwordMatches = $this->hasher->verify( + $request->password, + $user->getPasswordHash(), + ); + + if (! $passwordMatches) { + throw new UnauthorizedException('invalid credentials'); + } + + return $user; + } +} diff --git a/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUserRequest.php b/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUserRequest.php new file mode 100644 index 0000000..d7cf6dd --- /dev/null +++ b/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUserRequest.php @@ -0,0 +1,12 @@ +clock->now(); + $expiresAt = $now->modify(self::SESSION_LIFETIME); + + return $this->sessionRepo->create(new CreateSessionDto( + token: $this->tokenGenerator->generate(), + user: $user, + createdAt: $now, + expiresAt: $expiresAt, + )); + } +} diff --git a/backend/app/Auth/UseCases/Logout/Logout.php b/backend/app/Auth/UseCases/Logout/Logout.php new file mode 100644 index 0000000..f702346 --- /dev/null +++ b/backend/app/Auth/UseCases/Logout/Logout.php @@ -0,0 +1,22 @@ +sessionRepo->deleteByToken($token); + } +} diff --git a/backend/app/Controllers/AuthController.php b/backend/app/Controllers/AuthController.php new file mode 100644 index 0000000..5cb7c20 --- /dev/null +++ b/backend/app/Controllers/AuthController.php @@ -0,0 +1,152 @@ +parseBody($request); + + try { + $user = $this->authenticateUser->execute( + new AuthenticateUserRequest( + email: $body['email'] ?? null, + password: $body['password'] ?? null, + ), + ); + } catch (Throwable $exception) { + return $this->errorResponse($exception); + } + + $session = $this->createSession->execute($user); + + $response = $this->jsonResponse( + ['user' => $this->buildUserPayload($user)], + 200, + ); + + $cookieValue = sprintf( + '%s=%s; Expires=%s; Path=/; HttpOnly; SameSite=Lax', + AuthMiddleware::COOKIE_NAME, + $session->getToken(), + $session->getExpiresAt()->format('D, d-M-Y H:i:s T'), + ); + + return $response->withHeader('Set-Cookie', $cookieValue); + } + + public function logout( + ServerRequestInterface $request, + ): ResponseInterface { + $cookies = $request->getCookieParams(); + $token = $cookies[AuthMiddleware::COOKIE_NAME] ?? null; + + $this->logout->execute($token); + + $response = new Response(204); + + $cookieValue = sprintf( + '%s=; Expires=%s; Path=/; HttpOnly; SameSite=Lax', + AuthMiddleware::COOKIE_NAME, + 'Thu, 01-Jan-1970 00:00:00 GMT', + ); + + return $response->withHeader('Set-Cookie', $cookieValue); + } + + public function me( + ServerRequestInterface $request, + ): ResponseInterface { + $user = $request->getAttribute('user'); + + if (! $user instanceof User) { + return $this->jsonResponse( + ['error' => 'unauthenticated'], + 401, + ); + } + + return $this->jsonResponse( + ['user' => $this->buildUserPayload($user)], + 200, + ); + } + + private function buildUserPayload(User $user): array + { + return [ + 'id' => $user->getId(), + 'email' => $user->getEmail()->value(), + ]; + } + + private function jsonResponse( + array $data, + int $status, + ): ResponseInterface { + $response = new Response($status); + $response->getBody()->write(json_encode($data)); + + return $response->withHeader('Content-Type', 'application/json'); + } + + private function errorResponse(Throwable $exception): ResponseInterface + { + if ($exception instanceof BadRequestException) { + return $this->jsonResponse( + ['error' => $exception->getMessage()], + 400, + ); + } + if ($exception instanceof UnauthorizedException) { + return $this->jsonResponse( + ['error' => $exception->getMessage()], + 401, + ); + } + if ($exception instanceof DomainException) { + return $this->jsonResponse( + ['error' => $exception->getMessage()], + 409, + ); + } + throw $exception; + } + + private function parseBody(ServerRequestInterface $request): array + { + $contentType = $request->getHeaderLine('Content-Type'); + + if (str_contains($contentType, 'application/json')) { + $body = (string) $request->getBody(); + $decoded = json_decode($body, true); + + return is_array($decoded) ? $decoded : []; + } + + return (array) $request->getParsedBody(); + } +} diff --git a/backend/app/Exceptions/BadRequestException.php b/backend/app/Exceptions/BadRequestException.php new file mode 100644 index 0000000..1670d31 --- /dev/null +++ b/backend/app/Exceptions/BadRequestException.php @@ -0,0 +1,9 @@ +getCookieParams(); + $token = $cookies[self::COOKIE_NAME] ?? null; + + if (! is_string($token) || $token === '') { + return $this->unauthorized(); + } + + $session = $this->sessionRepo->findByToken($token); + + if ($session === null) { + return $this->unauthorized(); + } + + if ($session->isExpired($this->clock->now())) { + $this->sessionRepo->deleteByToken($token); + + return $this->unauthorized(); + } + + $request = $request->withAttribute('user', $session->getUser()); + + return $handler->handle($request); + } + + private function unauthorized(): ResponseInterface + { + $response = new Response(401); + $response->getBody()->write( + json_encode(['error' => 'unauthenticated']), + ); + + return $response->withHeader('Content-Type', 'application/json'); + } +} diff --git a/backend/app/Shared/ValueObject/EmailAddress.php b/backend/app/Shared/ValueObject/EmailAddress.php new file mode 100644 index 0000000..ce7bcf9 --- /dev/null +++ b/backend/app/Shared/ValueObject/EmailAddress.php @@ -0,0 +1,47 @@ +normalized = $normalized; + } + + public function value(): string + { + return $this->normalized; + } + + public function equals(self $other): bool + { + return $this->normalized === $other->normalized; + } + + public function __toString(): string + { + return $this->normalized; + } +} diff --git a/backend/app/User/CreateUserDto.php b/backend/app/User/CreateUserDto.php new file mode 100644 index 0000000..848d9f7 --- /dev/null +++ b/backend/app/User/CreateUserDto.php @@ -0,0 +1,14 @@ +id; + } + + public function getEmail(): EmailAddress + { + return $this->email; + } + + public function getPasswordHash(): string + { + return $this->passwordHash; + } +} diff --git a/backend/app/User/UserRepository.php b/backend/app/User/UserRepository.php new file mode 100644 index 0000000..975b50d --- /dev/null +++ b/backend/app/User/UserRepository.php @@ -0,0 +1,14 @@ +=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "14.1.9", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "655533a65696bbc4231cd8027af150dadc40ec88" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/655533a65696bbc4231cd8027af150dadc40ec88", + "reference": "655533a65696bbc4231cd8027af150dadc40ec88", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.4", + "phpunit/php-text-template": "^6.0", + "sebastian/complexity": "^6.0", + "sebastian/environment": "^9.2", + "sebastian/git-state": "^1.0", + "sebastian/lines-of-code": "^5.0", + "sebastian/version": "^7.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^13.1" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "14.1.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.1.9" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2026-05-16T05:16:14+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:33:26+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^13.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-invoker", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:34:47+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/a47af19f93f76aa3368303d752aa5272ca3299f4", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-text-template", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:36:37+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "9.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/a0e12065831f6ab0d83120dc61513eb8d9a966f6", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/9.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-timer", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:37:53+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "13.1.10", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "38959098d3c10660a189afaa35a94290c1de67bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/38959098d3c10660a189afaa35a94290c1de67bb", + "reference": "38959098d3c10660a189afaa35a94290c1de67bb", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.4.1", + "phpunit/php-code-coverage": "^14.1.8", + "phpunit/php-file-iterator": "^7.0.0", + "phpunit/php-invoker": "^7.0.0", + "phpunit/php-text-template": "^6.0.0", + "phpunit/php-timer": "^9.0.0", + "sebastian/cli-parser": "^5.0.0", + "sebastian/comparator": "^8.1.3", + "sebastian/diff": "^8.3.0", + "sebastian/environment": "^9.3.0", + "sebastian/exporter": "^8.0.2", + "sebastian/git-state": "^1.0", + "sebastian/global-state": "^9.0.0", + "sebastian/object-enumerator": "^8.0.0", + "sebastian/recursion-context": "^8.0.0", + "sebastian/type": "^7.0.0", + "sebastian/version": "^7.0.0", + "staabm/side-effects-detector": "^1.0.5" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "13.1-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.1.10" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-05-15T08:03:56+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/48a4654fa5e48c1c81214e9930048a572d4b23ca", + "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:39:44+00:00" + }, + { + "name": "sebastian/comparator", + "version": "8.1.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "1edd557042bf4ff9978ec125d8131b147d5c8224" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/1edd557042bf4ff9978ec125d8131b147d5c8224", + "reference": "1edd557042bf4ff9978ec125d8131b147d5c8224", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/diff": "^8.3", + "sebastian/exporter": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/8.1.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-05-15T08:30:51+00:00" + }, + { + "name": "sebastian/complexity", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/c5651c795c98093480df79350cb050813fc7a2f3", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/complexity", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:41:32+00:00" + }, + { + "name": "sebastian/diff", + "version": "8.3.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b36d33b6e796513de7cb7df053afb3f55eefcd47" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b36d33b6e796513de7cb7df053afb3f55eefcd47", + "reference": "b36d33b6e796513de7cb7df053afb3f55eefcd47", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0", + "symfony/process": "^7.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/8.3.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" + } + ], + "time": "2026-05-15T04:58:09+00:00" + }, + { + "name": "sebastian/environment", + "version": "9.3.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "6767059a30e4277ac95ee034809e793528464768" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/6767059a30e4277ac95ee034809e793528464768", + "reference": "6767059a30e4277ac95ee034809e793528464768", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/9.3.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2026-04-15T12:14:03+00:00" + }, + { + "name": "sebastian/exporter", + "version": "8.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "9cee180ebe62259e3ed48df2212d1fc8cfd971bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/9cee180ebe62259e3ed48df2212d1fc8cfd971bb", + "reference": "9cee180ebe62259e3ed48df2212d1fc8cfd971bb", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/8.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2026-04-15T12:38:05+00:00" + }, + { + "name": "sebastian/git-state", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/git-state.git", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/git-state/zipball/792a952e0eba55b6960a48aeceb9f371aad1f76b", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for describing the state of a Git checkout", + "homepage": "https://github.com/sebastianbergmann/git-state", + "support": { + "issues": "https://github.com/sebastianbergmann/git-state/issues", + "security": "https://github.com/sebastianbergmann/git-state/security/policy", + "source": "https://github.com/sebastianbergmann/git-state/tree/1.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/git-state", + "type": "tidelift" + } + ], + "time": "2026-03-21T12:54:28+00:00" + }, + { + "name": "sebastian/global-state", + "version": "9.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "e52e3dc22441e6218c710afe72c3042f8fc41ea7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/e52e3dc22441e6218c710afe72c3042f8fc41ea7", + "reference": "e52e3dc22441e6218c710afe72c3042f8fc41ea7", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/9.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:45:13+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "4f21bb7768e1c997722ccc7efb1d6b5c11bfd471" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/4f21bb7768e1c997722ccc7efb1d6b5c11bfd471", + "reference": "4f21bb7768e1c997722ccc7efb1d6b5c11bfd471", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:45:54+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-enumerator", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:46:36+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-reflector", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:47:13+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/74c5af21f6a5833e91767ca068c4d3dfec15317e", + "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:51:28+00:00" + }, + { + "name": "sebastian/type", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "42412224607bd3931241bbd17f38e0f972f5a916" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/42412224607bd3931241bbd17f38e0f972f5a916", + "reference": "42412224607bd3931241bbd17f38e0f972f5a916", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:52:09+00:00" + }, + { + "name": "sebastian/version", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/ad37a5552c8e2b88572249fdc19b6da7792e021b", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/version", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:52:52+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^8.1" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-12-08T11:19:18+00:00" + } + ], "aliases": [], "minimum-stability": "stable", "stability-flags": {}, diff --git a/backend/config/container.php b/backend/config/container.php new file mode 100644 index 0000000..d01341b --- /dev/null +++ b/backend/config/container.php @@ -0,0 +1,37 @@ +addDefinitions([ + + // Services + PasswordHasher::class => DI\create(BcryptPasswordHasher::class), + TokenGenerator::class => DI\create(RandomTokenGenerator::class), + Clock::class => DI\create(SystemClock::class), + + // Use cases + AuthenticateUser::class => DI\autowire(), + CreateSession::class => DI\autowire(), + Logout::class => DI\autowire(), + + // HTTP layer + AuthController::class => DI\autowire(), + AuthMiddleware::class => DI\autowire(), + +]); + +return $builder->build(); diff --git a/backend/config/routes.php b/backend/config/routes.php new file mode 100644 index 0000000..cd9f359 --- /dev/null +++ b/backend/config/routes.php @@ -0,0 +1,17 @@ +get('/me', [AuthController::class, 'me']) + ->add(AuthMiddleware::class); + + $app->post('/login', [AuthController::class, 'login']); + + $app->post('/logout', [AuthController::class, 'logout']) + ->add(AuthMiddleware::class); +}; diff --git a/backend/phpunit.xml b/backend/phpunit.xml new file mode 100644 index 0000000..e0a733a --- /dev/null +++ b/backend/phpunit.xml @@ -0,0 +1,19 @@ + + + + + tests/Unit + + + + + app + + + diff --git a/backend/public/index.php b/backend/public/index.php new file mode 100644 index 0000000..0209daf --- /dev/null +++ b/backend/public/index.php @@ -0,0 +1,19 @@ +run(); +})(); diff --git a/backend/tests/Fakes/FakeClock.php b/backend/tests/Fakes/FakeClock.php new file mode 100644 index 0000000..7b14d8d --- /dev/null +++ b/backend/tests/Fakes/FakeClock.php @@ -0,0 +1,23 @@ +currentTime; + } + + public function setTime(DateTimeImmutable $newTime): void + { + $this->currentTime = $newTime; + } +} diff --git a/backend/tests/Fakes/FakePasswordHasher.php b/backend/tests/Fakes/FakePasswordHasher.php new file mode 100644 index 0000000..507c966 --- /dev/null +++ b/backend/tests/Fakes/FakePasswordHasher.php @@ -0,0 +1,18 @@ +hash($password) === $hash; + } +} diff --git a/backend/tests/Fakes/FakeSessionRepository.php b/backend/tests/Fakes/FakeSessionRepository.php new file mode 100644 index 0000000..78a402d --- /dev/null +++ b/backend/tests/Fakes/FakeSessionRepository.php @@ -0,0 +1,46 @@ +token, + user: $dto->user, + createdAt: $dto->createdAt, + expiresAt: $dto->expiresAt, + ); + $this->sessions[$dto->token] = $session; + + return $session; + } + + public function findByToken(string $token): ?Session + { + $session = $this->sessions[$token] ?? null; + + if ($session === null) { + return null; + } + + return new Session( + token: $session->getToken(), + user: $session->getUser(), + createdAt: $session->getCreatedAt(), + expiresAt: $session->getExpiresAt(), + ); + } + + public function deleteByToken(string $token): void + { + unset($this->sessions[$token]); + } +} diff --git a/backend/tests/Fakes/FakeTokenGenerator.php b/backend/tests/Fakes/FakeTokenGenerator.php new file mode 100644 index 0000000..e808ffd --- /dev/null +++ b/backend/tests/Fakes/FakeTokenGenerator.php @@ -0,0 +1,28 @@ +callCount >= count($this->tokens)) { + throw new RuntimeException( + 'FakeTokenGenerator exhausted' + ); + } + $token = $this->tokens[$this->callCount]; + $this->callCount++; + + return $token; + } +} diff --git a/backend/tests/Fakes/FakeUserRepository.php b/backend/tests/Fakes/FakeUserRepository.php new file mode 100644 index 0000000..c816d1e --- /dev/null +++ b/backend/tests/Fakes/FakeUserRepository.php @@ -0,0 +1,63 @@ +nextId(); + + $user = new User( + id: $id, + email: $dto->email, + passwordHash: $dto->passwordHash, + ); + + $this->users[$id] = $user; + + return $user; + } + + public function findByEmail(EmailAddress $email): ?User + { + foreach ($this->users as $user) { + if ($user->getEmail()->value() === $email->value()) { + return new User( + id: $user->getId(), + email: $user->getEmail(), + passwordHash: $user->getPasswordHash(), + ); + } + } + + return null; + } + + public function find(int $id): ?User + { + $user = $this->users[$id] ?? null; + + if ($user === null) { + return null; + } + + return new User( + id: $user->getId(), + email: $user->getEmail(), + passwordHash: $user->getPasswordHash(), + ); + } + + private function nextId(): int + { + return count($this->users); + } +} diff --git a/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php b/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php new file mode 100644 index 0000000..31ec0c9 --- /dev/null +++ b/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php @@ -0,0 +1,126 @@ +userRepo = new FakeUserRepository(); + $this->hasher = new FakePasswordHasher(); + $this->useCase = new AuthenticateUser( + $this->userRepo, + $this->hasher, + ); + + $this->userRepo->create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + passwordHash: $this->hasher->hash('correct-password'), + )); + } + + public function testAuthenticatesWithValidCredentials(): void + { + $user = $this->useCase->execute(new AuthenticateUserRequest( + email: 'user@example.com', + password: 'correct-password', + )); + + $this->assertSame('user@example.com', $user->getEmail()->value()); + } + + public function testThrowsBadRequestWhenEmailIsEmpty(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('email is required'); + + $this->useCase->execute(new AuthenticateUserRequest( + email: '', + password: 'some-password', + )); + } + + public function testThrowsBadRequestWhenPasswordIsEmpty(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('password is required'); + + $this->useCase->execute(new AuthenticateUserRequest( + email: 'user@example.com', + password: '', + )); + } + + public function testThrowsBadRequestWhenEmailIsNull(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('email is required'); + + $this->useCase->execute(new AuthenticateUserRequest( + email: null, + password: 'some-password', + )); + } + + public function testThrowsBadRequestWhenPasswordIsNull(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('password is required'); + + $this->useCase->execute(new AuthenticateUserRequest( + email: 'user@example.com', + password: null, + )); + } + + public function testThrowsUnauthorizedForUnknownEmail(): void + { + $this->expectException(UnauthorizedException::class); + $this->expectExceptionMessage('invalid credentials'); + + $this->useCase->execute(new AuthenticateUserRequest( + email: 'unknown@example.com', + password: 'some-password', + )); + } + + public function testThrowsUnauthorizedForWrongPassword(): void + { + $this->expectException(UnauthorizedException::class); + $this->expectExceptionMessage('invalid credentials'); + + $this->useCase->execute(new AuthenticateUserRequest( + email: 'user@example.com', + password: 'wrong-password', + )); + } + + public function testReturnsNewInstanceOnEachCall(): void + { + $user1 = $this->useCase->execute(new AuthenticateUserRequest( + email: 'user@example.com', + password: 'correct-password', + )); + + $user2 = $this->useCase->execute(new AuthenticateUserRequest( + email: 'user@example.com', + password: 'correct-password', + )); + + $this->assertNotSame($user1, $user2); + } +} diff --git a/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php b/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php new file mode 100644 index 0000000..d08c05c --- /dev/null +++ b/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php @@ -0,0 +1,102 @@ +sessionRepo = new FakeSessionRepository(); + $this->tokenGenerator = new FakeTokenGenerator([ + 'token-1', + ]); + $this->clock = new FakeClock( + new DateTimeImmutable('2026-05-16 12:00:00'), + ); + $this->useCase = new CreateSession( + $this->sessionRepo, + $this->tokenGenerator, + $this->clock, + ); + + $userRepo = new FakeUserRepository(); + $this->user = $userRepo->create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + passwordHash: 'hashed:password', + )); + } + + public function testCreatesSessionWithGivenToken(): void + { + $session = $this->useCase->execute($this->user); + + $this->assertSame('token-1', $session->getToken()); + } + + public function testCreatesSessionWithUser(): void + { + $session = $this->useCase->execute($this->user); + + $this->assertSame( + $this->user->getId(), + $session->getUser()->getId(), + ); + } + + public function testCreatesSessionWithCreatedAtFromClock(): void + { + $session = $this->useCase->execute($this->user); + + $this->assertEquals( + new DateTimeImmutable('2026-05-16 12:00:00'), + $session->getCreatedAt(), + ); + } + + public function testCreatesSessionWithExpirySevenDaysLater(): void + { + $session = $this->useCase->execute($this->user); + + $this->assertEquals( + new DateTimeImmutable('2026-05-23 12:00:00'), + $session->getExpiresAt(), + ); + } + + public function testPersistsSessionInRepository(): void + { + $session = $this->useCase->execute($this->user); + + $found = $this->sessionRepo->findByToken('token-1'); + + $this->assertNotNull($found); + $this->assertSame($session->getToken(), $found->getToken()); + } + + public function testFreshInstanceReturnedOnEachCall(): void + { + $session = $this->useCase->execute($this->user); + + $this->assertNotSame( + $session, + $this->sessionRepo->findByToken('token-1'), + ); + } +} diff --git a/backend/tests/Unit/Auth/UseCases/LogoutTest.php b/backend/tests/Unit/Auth/UseCases/LogoutTest.php new file mode 100644 index 0000000..ffd4763 --- /dev/null +++ b/backend/tests/Unit/Auth/UseCases/LogoutTest.php @@ -0,0 +1,67 @@ +sessionRepo = new FakeSessionRepository(); + $this->useCase = new Logout($this->sessionRepo); + + $userRepo = new FakeUserRepository(); + $user = $userRepo->create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + passwordHash: 'hashed:password', + )); + + $this->sessionRepo->create(new CreateSessionDto( + token: 'session-token', + user: $user, + createdAt: new DateTimeImmutable('2026-05-16 12:00:00'), + expiresAt: new DateTimeImmutable('2026-05-23 12:00:00'), + )); + } + + public function testDeletesSessionByToken(): void + { + $this->useCase->execute('session-token'); + + $this->assertNull( + $this->sessionRepo->findByToken('session-token'), + ); + } + + public function testDoesNotThrowForUnknownToken(): void + { + $this->useCase->execute('nonexistent-token'); + + $this->assertTrue(true); + } + + public function testDoesNotThrowForNullToken(): void + { + $this->useCase->execute(null); + + $this->assertTrue(true); + } + + public function testDoesNotThrowForEmptyStringToken(): void + { + $this->useCase->execute(''); + + $this->assertTrue(true); + } +} diff --git a/backend/tests/Unit/Controllers/AuthControllerTest.php b/backend/tests/Unit/Controllers/AuthControllerTest.php new file mode 100644 index 0000000..c96e137 --- /dev/null +++ b/backend/tests/Unit/Controllers/AuthControllerTest.php @@ -0,0 +1,170 @@ +userRepo = new FakeUserRepository(); + $this->sessionRepo = new FakeSessionRepository(); + $this->hasher = new FakePasswordHasher(); + $this->tokenGenerator = new FakeTokenGenerator([ + 'session-token-1', + 'session-token-2', + ]); + $this->clock = new FakeClock( + new DateTimeImmutable('2026-05-16 12:00:00'), + ); + + $authenticateUser = new AuthenticateUser( + $this->userRepo, + $this->hasher, + ); + $createSession = new CreateSession( + $this->sessionRepo, + $this->tokenGenerator, + $this->clock, + ); + $logout = new Logout($this->sessionRepo); + + $this->controller = new AuthController( + $authenticateUser, + $createSession, + $logout, + ); + + $this->user = $this->userRepo->create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + passwordHash: $this->hasher->hash('correct-password'), + )); + } + + public function testLoginReturnsUserAndSetsCookie(): void + { + $request = $this->jsonRequest('POST', '/login', [ + 'email' => 'user@example.com', + 'password' => 'correct-password', + ]); + + $response = $this->controller->login($request, new Response()); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertStringContainsString( + 'user@example.com', + (string) $response->getBody(), + ); + + $cookieHeader = $response->getHeaderLine('Set-Cookie'); + $this->assertStringContainsString( + AuthMiddleware::COOKIE_NAME . '=session-token-1', + $cookieHeader, + ); + $this->assertStringContainsString('HttpOnly', $cookieHeader); + } + + public function testLoginReturns400ForMissingEmail(): void + { + $request = $this->jsonRequest('POST', '/login', [ + 'password' => 'correct-password', + ]); + + $response = $this->controller->login($request, new Response()); + + $this->assertSame(400, $response->getStatusCode()); + } + + public function testLoginReturns401ForInvalidCredentials(): void + { + $request = $this->jsonRequest('POST', '/login', [ + 'email' => 'user@example.com', + 'password' => 'wrong-password', + ]); + + $response = $this->controller->login($request, new Response()); + + $this->assertSame(401, $response->getStatusCode()); + } + + public function testLogoutClearsCookieAndReturns204(): void + { + $request = $this->createRequest() + ->withCookieParams([ + AuthMiddleware::COOKIE_NAME => 'session-token-1', + ]); + + $response = $this->controller->logout($request, new Response()); + + $this->assertSame(204, $response->getStatusCode()); + + $cookieHeader = $response->getHeaderLine('Set-Cookie'); + $this->assertStringContainsString( + AuthMiddleware::COOKIE_NAME . '=', + $cookieHeader, + ); + } + + public function testMeReturnsUserFromAttribute(): void + { + $request = $this->createRequest() + ->withAttribute('user', $this->user); + + $response = $this->controller->me($request, new Response()); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertStringContainsString( + 'user@example.com', + (string) $response->getBody(), + ); + } + + private function jsonRequest( + string $method, + string $path, + array $body, + ): ServerRequestInterface { + $request = $this->createRequest($method, $path) + ->withHeader('Content-Type', 'application/json'); + $request->getBody()->write(json_encode($body)); + $request->getBody()->rewind(); + + return $request; + } + + private function createRequest( + string $method = 'POST', + string $path = '/', + ): ServerRequestInterface { + $factory = new ServerRequestFactory(); + + return $factory->createServerRequest($method, $path); + } +} diff --git a/backend/tests/Unit/Middleware/AuthMiddlewareTest.php b/backend/tests/Unit/Middleware/AuthMiddlewareTest.php new file mode 100644 index 0000000..9d2ae69 --- /dev/null +++ b/backend/tests/Unit/Middleware/AuthMiddlewareTest.php @@ -0,0 +1,153 @@ +sessionRepo = new FakeSessionRepository(); + $this->clock = new FakeClock( + new DateTimeImmutable('2026-05-16 12:00:00'), + ); + $this->middleware = new AuthMiddleware( + $this->sessionRepo, + $this->clock, + ); + + $userRepo = new FakeUserRepository(); + $hasher = new FakePasswordHasher(); + $this->user = $userRepo->create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + passwordHash: $hasher->hash('password'), + )); + + $this->sessionRepo->create(new CreateSessionDto( + token: 'valid-token', + user: $this->user, + createdAt: new DateTimeImmutable('2026-05-16 12:00:00'), + expiresAt: new DateTimeImmutable('2026-05-23 12:00:00'), + )); + } + + public function testPassesRequestToHandlerWhenCookieIsValid(): void + { + $request = $this->createRequestWithCookie('valid-token'); + + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->expects($this->once()) + ->method('handle') + ->with($this->callback(function (ServerRequestInterface $request) { + $user = $request->getAttribute('user'); + return $user instanceof User + && $user->getId() === $this->user->getId(); + })) + ->willReturn(new Response(200)); + + $response = $this->middleware->process($request, $handler); + + $this->assertSame(200, $response->getStatusCode()); + } + + public function testReturns401WhenCookieIsMissing(): void + { + $request = self::createRequest(); + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->expects($this->never())->method('handle'); + + $response = $this->middleware->process($request, $handler); + + $this->assertSame(401, $response->getStatusCode()); + } + + public function testReturns401WhenCookieIsEmpty(): void + { + $request = $this->createRequestWithCookie(''); + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->expects($this->never())->method('handle'); + + $response = $this->middleware->process($request, $handler); + + $this->assertSame(401, $response->getStatusCode()); + } + + public function testReturns401WhenTokenIsUnknown(): void + { + $request = $this->createRequestWithCookie('unknown-token'); + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->expects($this->never())->method('handle'); + + $response = $this->middleware->process($request, $handler); + + $this->assertSame(401, $response->getStatusCode()); + } + + public function testReturns401WhenSessionIsExpired(): void + { + $this->clock->setTime( + new DateTimeImmutable('2026-05-30 12:00:00'), + ); + + $request = $this->createRequestWithCookie('valid-token'); + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->expects($this->never())->method('handle'); + + $response = $this->middleware->process($request, $handler); + + $this->assertSame(401, $response->getStatusCode()); + } + + public function testDeletesExpiredSession(): void + { + $this->clock->setTime( + new DateTimeImmutable('2026-05-30 12:00:00'), + ); + + $request = $this->createRequestWithCookie('valid-token'); + $handler = $this->createStub(RequestHandlerInterface::class); + + $this->middleware->process($request, $handler); + + $this->assertNull( + $this->sessionRepo->findByToken('valid-token'), + ); + } + + private static function createRequest(): ServerRequestInterface + { + $factory = new ServerRequestFactory(); + + return $factory->createServerRequest('GET', '/'); + } + + private function createRequestWithCookie( + string $token, + ): ServerRequestInterface { + $request = self::createRequest(); + + return $request->withCookieParams([ + AuthMiddleware::COOKIE_NAME => $token, + ]); + } +}