diff --git a/src/Libs/Config.php b/src/Libs/Config.php index b40c6b12..a1527335 100644 --- a/src/Libs/Config.php +++ b/src/Libs/Config.php @@ -101,4 +101,12 @@ final class Config { self::$config = ag_delete(self::$config, $key); } + + /** + * Clear all configuration values. + */ + public static function reset(): void + { + self::$config = []; + } } diff --git a/src/Libs/Middlewares/ExceptionHandlerMiddleware.php b/src/Libs/Middlewares/ExceptionHandlerMiddleware.php index c87e1acc..ea7a8c60 100644 --- a/src/Libs/Middlewares/ExceptionHandlerMiddleware.php +++ b/src/Libs/Middlewares/ExceptionHandlerMiddleware.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Libs\Middlewares; +use App\Libs\Enums\Http\Status; use Psr\Http\Message\ResponseInterface as iResponse; use Psr\Http\Message\ServerRequestInterface as iRequest; use Psr\Http\Server\MiddlewareInterface as iMiddleware; @@ -16,7 +17,7 @@ final class ExceptionHandlerMiddleware implements iMiddleware try { return $handler->handle($request); } catch (\Throwable $e) { - return api_error($e->getMessage(), $e->getCode()); + return api_error($e->getMessage(), Status::tryFrom($e->getCode()) ?? Status::INTERNAL_SERVER_ERROR); } } } diff --git a/src/Libs/Middlewares/NoAccessLogMiddleware.php b/src/Libs/Middlewares/NoAccessLogMiddleware.php index 4ea73800..97ce9177 100644 --- a/src/Libs/Middlewares/NoAccessLogMiddleware.php +++ b/src/Libs/Middlewares/NoAccessLogMiddleware.php @@ -5,14 +5,14 @@ declare(strict_types=1); namespace App\Libs\Middlewares; use App\Libs\Config; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\MiddlewareInterface; -use Psr\Http\Server\RequestHandlerInterface; +use Psr\Http\Message\ResponseInterface as iResponse; +use Psr\Http\Message\ServerRequestInterface as iRequest; +use Psr\Http\Server\MiddlewareInterface as iMiddleware; +use Psr\Http\Server\RequestHandlerInterface as iHandler; -final class NoAccessLogMiddleware implements MiddlewareInterface +final class NoAccessLogMiddleware implements iMiddleware { - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + public function process(iRequest $request, iHandler $handler): iResponse { if (false === (bool)$request->getAttribute('INTERNAL_REQUEST', false)) { return $handler->handle($request); diff --git a/src/Libs/Middlewares/ParseJsonBodyMiddleware.php b/src/Libs/Middlewares/ParseJsonBodyMiddleware.php index e190bd78..4af60e0e 100644 --- a/src/Libs/Middlewares/ParseJsonBodyMiddleware.php +++ b/src/Libs/Middlewares/ParseJsonBodyMiddleware.php @@ -4,23 +4,26 @@ declare(strict_types=1); namespace App\Libs\Middlewares; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\MiddlewareInterface; -use Psr\Http\Server\RequestHandlerInterface; +use App\Libs\Enums\Http\Method; +use App\Libs\Enums\Http\Status; +use JsonException; +use Psr\Http\Message\ResponseInterface as iResponse; +use Psr\Http\Message\ServerRequestInterface as iRequest; +use Psr\Http\Server\MiddlewareInterface as iMiddleware; +use Psr\Http\Server\RequestHandlerInterface as iHandler; use RuntimeException; -class ParseJsonBodyMiddleware implements MiddlewareInterface +class ParseJsonBodyMiddleware implements iMiddleware { - private array $nonBodyRequests = [ - 'GET', - 'HEAD', - 'OPTIONS', - ]; - - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + public function process(iRequest $request, iHandler $handler): iResponse { - if (in_array($request->getMethod(), $this->nonBodyRequests)) { + if (null === ($method = Method::tryFrom($request->getMethod()))) { + throw new RuntimeException(r('Invalid HTTP method. "{method}".', [ + 'method' => $request->getMethod() + ]), Status::METHOD_NOT_ALLOWED->value); + } + + if (true === in_array($method, [Method::GET, Method::HEAD, Method::OPTIONS])) { return $handler->handle($request); } @@ -33,7 +36,7 @@ class ParseJsonBodyMiddleware implements MiddlewareInterface return $handler->handle($request); } - private function parse(ServerRequestInterface $request): ServerRequestInterface + private function parse(iRequest $request): iRequest { $body = (string)$request->getBody(); @@ -47,10 +50,10 @@ class ParseJsonBodyMiddleware implements MiddlewareInterface try { return $request->withParsedBody(json_decode($body, true, flags: JSON_THROW_ON_ERROR)); - } catch (\JsonException $e) { + } catch (JsonException $e) { throw new RuntimeException(r('Error when parsing JSON request body. {error}', [ 'error' => $e->getMessage() - ]), $e->getCode(), $e); + ]), Status::BAD_REQUEST->value, $e); } } } diff --git a/tests/Libs/ConfigTest.php b/tests/Libs/ConfigTest.php index 97a3c673..b378f44e 100644 --- a/tests/Libs/ConfigTest.php +++ b/tests/Libs/ConfigTest.php @@ -77,6 +77,21 @@ class ConfigTest extends TestCase ); } + public function test_config_reset(): void + { + $this->assertCount( + count($this->data), + Config::getAll(), + 'When config is initialized, getAll() returns all data' + ); + + Config::reset(); + $this->assertEmpty( + Config::getAll(), + 'When config is reset, getAll() returns empty array' + ); + } + public function test_config_has(): void { $this->assertTrue(Config::has('foo'), 'When key is set, has() returns true'); diff --git a/tests/Libs/Middlewares/APIKeyRequiredMiddlewareTest.php b/tests/Libs/Middlewares/APIKeyRequiredMiddlewareTest.php new file mode 100644 index 00000000..a2e30b78 --- /dev/null +++ b/tests/Libs/Middlewares/APIKeyRequiredMiddlewareTest.php @@ -0,0 +1,124 @@ +process( + request: $this->getRequest()->withAttribute('INTERNAL_REQUEST', true), + handler: $this->getHandler() + ); + $this->assertSame(200, $result->getStatusCode(), 'Internal request failed'); + } + + public function test_options_request() + { + $result = (new APIKeyRequiredMiddleware())->process( + request: $this->getRequest(method: Method::OPTIONS), + handler: $this->getHandler() + ); + + $this->assertSame(Status::OK->value, $result->getStatusCode(), 'Options request failed'); + } + + public function test_open_routes() + { + $routes = [ + HealthCheck::URL, + AutoConfig::URL, + AccessToken::URL, + ]; + + $routesSemiOpen = [ + '/webhook', + '%{api.prefix}/player/', + ]; + + foreach ($routes as $route) { + $uri = parseConfigValue($route); + $result = (new APIKeyRequiredMiddleware())->process( + request: $this->getRequest(uri: $uri), + handler: $this->getHandler() + ); + $this->assertSame(Status::OK->value, $result->getStatusCode(), "Open route '{$route}' failed"); + } + + foreach ($routesSemiOpen as $route) { + $uri = parseConfigValue($route); + $result = (new APIKeyRequiredMiddleware())->process( + request: $this->getRequest(uri: $uri), + handler: $this->getHandler() + ); + $this->assertSame(Status::OK->value, $result->getStatusCode(), "Open route '{$route}' failed"); + } + + Config::save('api.secure', true); + + foreach ($routesSemiOpen as $route) { + $uri = parseConfigValue($route); + $result = (new APIKeyRequiredMiddleware())->process( + request: $this->getRequest(uri: $uri)->withoutHeader('Authorization'), + handler: $this->getHandler() + ); + $this->assertSame( + Status::BAD_REQUEST->value, + $result->getStatusCode(), + "Route '{$route}' should fail without API key" + ); + } + + foreach ($routesSemiOpen as $route) { + $uri = parseConfigValue($route); + $result = (new APIKeyRequiredMiddleware())->process( + request: $this->getRequest(uri: $uri)->withHeader('Authorization', 'Bearer api'), + handler: $this->getHandler() + ); + $this->assertSame( + Status::FORBIDDEN->value, + $result->getStatusCode(), + "Route '{$route}' should fail without correct API key" + ); + } + + Config::save('api.key', 'api_test_token'); + foreach ($routesSemiOpen as $route) { + $uri = parseConfigValue($route); + $result = (new APIKeyRequiredMiddleware())->process( + request: $this->getRequest(uri: $uri, query: ['apikey' => 'api_test_token'])->withHeader( + 'X-apikey', + 'api_test_token' + ), + handler: $this->getHandler() + ); + $this->assertSame( + Status::OK->value, + $result->getStatusCode(), + "Route '{$route}' should pass with correct API key" + ); + } + + Config::reset(); + } + +} diff --git a/tests/Libs/Middlewares/AddCorsMiddlewareTest.php b/tests/Libs/Middlewares/AddCorsMiddlewareTest.php new file mode 100644 index 00000000..f375d5f4 --- /dev/null +++ b/tests/Libs/Middlewares/AddCorsMiddlewareTest.php @@ -0,0 +1,34 @@ +process( + request: $this->getRequest(), + handler: $this->getHandler(new Response(Status::OK)) + ); + + $this->assertTrue( + $result->hasHeader('Access-Control-Allow-Origin'), + 'Access-Control-Allow-Origin is not available' + ); + + $this->assertTrue( + $result->hasHeader('Access-Control-Allow-Credentials'), + 'Access-Control-Allow-Credentials is not available' + ); + } +} diff --git a/tests/Libs/Middlewares/ExceptionHandlerMiddlewareTest.php b/tests/Libs/Middlewares/ExceptionHandlerMiddlewareTest.php new file mode 100644 index 00000000..6f4befcd --- /dev/null +++ b/tests/Libs/Middlewares/ExceptionHandlerMiddlewareTest.php @@ -0,0 +1,34 @@ +process( + request: $this->getRequest(), + handler: $this->getHandler( + fn() => throw new \RuntimeException('Test Exception', 404) + ) + ); + + $json = json_decode($result->getBody()->getContents(), true, flags: JSON_THROW_ON_ERROR); + + $this->assertArrayHasKey('error', $json, 'Error key is not available'); + $this->assertArrayHasKey('message', $json['error'], 'Message key is not available'); + $this->assertArrayHasKey('code', $json['error'], 'Status key is not available'); + + $this->assertSame('Test Exception', $json['error']['message'], 'Message is not equal'); + $this->assertSame(404, $json['error']['code'], 'Status is not equal'); + } +} diff --git a/tests/Libs/Middlewares/NoAccessLogMiddlewareTest.php b/tests/Libs/Middlewares/NoAccessLogMiddlewareTest.php new file mode 100644 index 00000000..5e4347b8 --- /dev/null +++ b/tests/Libs/Middlewares/NoAccessLogMiddlewareTest.php @@ -0,0 +1,56 @@ +process( + request: $this->getRequest(), + handler: $this->getHandler((new Response(Status::OK))) + ); + + $this->assertFalse( + $result->hasHeader('X-No-AccessLog'), + 'If INTERNAL_REQUEST is not set, Logging should be enabled.' + ); + } + + public function test_response_internal_request() + { + Config::save('api.logInternal', true); + + $result = (new NoAccessLogMiddleware())->process( + request: $this->getRequest()->withAttribute('INTERNAL_REQUEST', true), + handler: $this->getHandler((new Response(Status::OK))) + ); + + $this->assertFalse( + $result->hasHeader('X-No-AccessLog'), + 'If INTERNAL_REQUEST is not set, Logging should be enabled.' + ); + + Config::save('api.logInternal', false); + $result = (new NoAccessLogMiddleware())->process( + request: $this->getRequest()->withAttribute('INTERNAL_REQUEST', true), + handler: $this->getHandler((new Response(Status::OK))) + ); + + $this->assertTrue( + $result->hasHeader('X-No-AccessLog'), + 'If INTERNAL_REQUEST is set and api.logInternal is true, Logging should be disabled.' + ); + } +} diff --git a/tests/Libs/Middlewares/ParseJsonBodyMiddlewareTest.php b/tests/Libs/Middlewares/ParseJsonBodyMiddlewareTest.php new file mode 100644 index 00000000..e02573fc --- /dev/null +++ b/tests/Libs/Middlewares/ParseJsonBodyMiddlewareTest.php @@ -0,0 +1,114 @@ +checkException( + closure: fn() => (new ParseJsonBodyMiddleware())->process( + request: $this->getRequest(method: 'NOT_OK')->withBody( + Stream::create(json_encode(['key' => 'test'])) + )->withHeader('Content-Type', 'application/json'), + handler: $this->getHandler(new Response(Status::OK)) + ), + reason: 'Should throw an exception when the method is not allowed', + exception: RuntimeException::class, + exceptionCode: Status::METHOD_NOT_ALLOWED->value + ); + + $this->checkException( + closure: fn() => (new ParseJsonBodyMiddleware())->process( + request: $this->getRequest(Method::POST)->withBody( + Stream::create(json_encode(['key' => 'test']) . 'invalid json') + )->withHeader('Content-Type', 'application/json'), + handler: $this->getHandler() + ), + reason: 'Should throw an exception when the body is not a valid JSON', + exception: RuntimeException::class, + exceptionCode: Status::BAD_REQUEST->value + ); + } + + public function test_empty_parsed_body() + { + $mutatedRequest = null; + + (new ParseJsonBodyMiddleware())->process( + request: $this->getRequest(Method::GET)->withBody( + Stream::create(json_encode(['key' => 'test'])) + )->withHeader('Content-Type', 'application/json'), + handler: $this->getHandler(function ($request) use (&$mutatedRequest) { + $mutatedRequest = $request; + return new Response(Status::OK); + }) + ); + + $this->assertCount(0, $mutatedRequest->getParsedBody(), 'Parsed body should be empty.'); + $this->assertSame([], $mutatedRequest->getParsedBody(), 'Parsed body should be null.'); + + $mutatedRequest = null; + + (new ParseJsonBodyMiddleware())->process( + request: $this->getRequest(Method::POST)->withBody( + Stream::create('') + )->withHeader('Content-Type', 'application/json'), + handler: $this->getHandler(function ($request) use (&$mutatedRequest) { + $mutatedRequest = $request; + return new Response(Status::OK); + }) + ); + + $this->assertCount(0, $mutatedRequest->getParsedBody(), 'Parsed body should be empty.'); + $this->assertSame([], $mutatedRequest->getParsedBody(), 'Parsed body should be null.'); + + (new ParseJsonBodyMiddleware())->process( + request: $this->getRequest(Method::POST)->withBody( + Stream::create(json_encode(['key' => 'test'])) + ), + handler: $this->getHandler(function ($request) use (&$mutatedRequest) { + $mutatedRequest = $request; + return new Response(Status::OK); + }) + ); + + $this->assertCount(0, $mutatedRequest->getParsedBody(), 'Parsed body should have one item.'); + $this->assertSame([], + $mutatedRequest->getParsedBody(), + 'Parsed body should be the same as the request body.' + ); + } + + public function test_correct_mutation() + { + (new ParseJsonBodyMiddleware())->process( + request: $this->getRequest(Method::POST)->withBody( + Stream::create(json_encode(['key' => 'test'])) + )->withHeader('Content-Type', 'application/json'), + handler: $this->getHandler(function ($request) use (&$mutatedRequest) { + $mutatedRequest = $request; + return new Response(Status::OK); + }) + ); + + $this->assertCount(1, $mutatedRequest->getParsedBody(), 'Parsed body should have one item.'); + $this->assertSame(['key' => 'test'], + $mutatedRequest->getParsedBody(), + 'Parsed body should be the same as the request body.' + ); + } +} diff --git a/tests/Libs/Traits/APITraitsTest.php b/tests/Libs/Traits/APITraitsTest.php index 9906c846..839eb63b 100644 --- a/tests/Libs/Traits/APITraitsTest.php +++ b/tests/Libs/Traits/APITraitsTest.php @@ -18,10 +18,8 @@ use App\Libs\Traits\APITraits; class APITraitsTest extends TestCase { - public function __construct(?string $name = null, array $data = [], $dataName = '') + protected function setUp(): void { - parent::__construct($name, $data, $dataName); - Container::init(); Config::init(require __DIR__ . '/../../../config/config.php'); foreach ((array)require __DIR__ . '/../../../config/services.php' as $name => $definition) { @@ -29,6 +27,14 @@ class APITraitsTest extends TestCase } Config::save('backends_file', __DIR__ . '/../../Fixtures/test_servers.yaml'); Config::save('api.secure', true); + + parent::setUp(); + } + + public function __destruct() + { + Config::reset(); + Container::reset(); } public function test_getClient() diff --git a/tests/Support/RequestResponseTrait.php b/tests/Support/RequestResponseTrait.php new file mode 100644 index 00000000..adbccb89 --- /dev/null +++ b/tests/Support/RequestResponseTrait.php @@ -0,0 +1,73 @@ +response = $response; + } + + public function handle(iRequest $request): iResponse + { + return is_callable($this->response) ? ($this->response)($request) : $this->response; + } + }; + } + + protected function getRequest( + Method|string $method = Method::GET, + string $uri = '/', + array $post = [], + array $query = [], + array $headers = [], + array $cookies = [], + array $files = [], + array $server = [], + iStream|null $body = null + + ): iRequest { + $factory = new Psr17Factory(); + $creator = new ServerRequestCreator($factory, $factory, $factory, $factory); + + return $creator->fromArrays( + server: array_replace_recursive([ + 'REQUEST_METHOD' => is_string($method) ? $method : $method->value, + 'SCRIPT_FILENAME' => realpath(__DIR__ . '/../../public/index.php'), + 'REMOTE_ADDR' => '127.0.0.1', + 'REQUEST_URI' => $uri, + 'SERVER_NAME' => 'localhost', + 'SERVER_PORT' => 80, + 'HTTP_USER_AGENT' => 'WatchState/0.0', + ], $server), + headers: array_replace_recursive($server, [ + 'Accept' => 'application/json', + 'Authorization' => 'Bearer api_test_token', + ], $headers), + cookie: $cookies, + get: $query, + post: $post, + files: $files, + body: $body, + ); + } + +}