From 206e6f0f1fc19da1a3a18a9926d6ce340685c431 Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 4 Nov 2026 18:09:09 -0600 Subject: [PATCH 1/2] [#42] - moving logic outside the controller Assisted-by: Claude Code --- .../Controllers/HistoryController.php | 90 +------ src/DebugBar/History/HistoryHandler.php | 116 ++++++++ .../DebugBar/History/HistoryHandlerTest.php | 248 ++++++++++++++++++ 3 files changed, 376 insertions(+), 78 deletions(-) create mode 100644 src/DebugBar/History/HistoryHandler.php create mode 100644 tests/Unit/DebugBar/History/HistoryHandlerTest.php diff --git a/src/DebugBar/Controllers/HistoryController.php b/src/DebugBar/Controllers/HistoryController.php index b598e97..705cb65 100644 --- a/src/DebugBar/Controllers/HistoryController.php +++ b/src/DebugBar/Controllers/HistoryController.php @@ -14,106 +14,40 @@ namespace Phalcon\DebugBar\Controllers; use Phalcon\DebugBar\Contracts\History; +use Phalcon\DebugBar\History\HistoryHandler; use Phalcon\DebugBar\Security\AccessGate; use Phalcon\Http\RequestInterface; use Phalcon\Http\ResponseInterface; use Phalcon\Mvc\ControllerInterface; -use function is_string; -use function json_encode; -use function strtoupper; - -use const JSON_UNESCAPED_SLASHES; -use const JSON_UNESCAPED_UNICODE; - /** - * Internal MVC adapter for /_debugbar/open. GET returns the current browser's - * request list or one stored entry; DELETE clears that browser's history. + * Internal MVC adapter for /_debugbar/open. `HistoryHandler` does the work. */ final class HistoryController implements ControllerInterface { + private HistoryHandler $handler; + public function __construct( - private readonly History $history, - private readonly AccessGate $accessGate, - private readonly RequestInterface $historyRequest, - private readonly ResponseInterface $historyResponse + History $history, + AccessGate $accessGate, + RequestInterface $historyRequest, + ResponseInterface $historyResponse ) { + $this->handler = new HistoryHandler($history, $accessGate, $historyRequest, $historyResponse); } public function clearAction(): ResponseInterface { - return $this->handle( - 'DELETE', - fn (): ResponseInterface => $this->json( - $this->historyResponse, - ['cleared' => $this->history->clear()] - ) - ); + return $this->handler->clear(); } public function indexAction(): ResponseInterface { - return $this->handle( - 'GET', - function (): ResponseInterface { - $id = $this->historyRequest->getQuery('id'); - if (null === $id) { - return $this->json($this->historyResponse, ['requests' => $this->history->find()]); - } - if (!is_string($id)) { - return $this->json($this->historyResponse, ['error' => 'Request not found.'], 404); - } - - $entry = $this->history->get($id); - if (null === $entry) { - return $this->json($this->historyResponse, ['error' => 'Request not found.'], 404); - } - - return $this->json($this->historyResponse, ['request' => $entry]); - } - ); + return $this->handler->index(); } public function openAction(): ResponseInterface { - return 'DELETE' === $this->transportMethod() ? $this->clearAction() : $this->indexAction(); - } - - /** - * @param callable(): ResponseInterface $action - */ - private function handle(string $expectedMethod, callable $action): ResponseInterface - { - $clientIp = $this->historyRequest->getClientAddress(); - if (!$this->accessGate->allows(is_string($clientIp) ? $clientIp : null)) { - return $this->json($this->historyResponse, ['error' => 'Not found.'], 404); - } - - if ($expectedMethod !== $this->transportMethod()) { - return $this->json($this->historyResponse, ['error' => 'Method not allowed.'], 405); - } - - return $action(); - } - - /** - * @param array $body - */ - private function json(ResponseInterface $response, array $body, int $status = 200): ResponseInterface - { - $json = json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - - $response->setStatusCode($status); - $response->setContentType('application/json', 'UTF-8'); - $response->setHeader('Cache-Control', 'no-store, private'); - $response->setHeader('X-Content-Type-Options', 'nosniff'); - $response->setContent(false === $json ? '{}' : $json); - - return $response; - } - - private function transportMethod(): string - { - return strtoupper($this->historyRequest->getServer('REQUEST_METHOD') ?? ''); + return $this->handler->open(); } } diff --git a/src/DebugBar/History/HistoryHandler.php b/src/DebugBar/History/HistoryHandler.php new file mode 100644 index 0000000..56a5920 --- /dev/null +++ b/src/DebugBar/History/HistoryHandler.php @@ -0,0 +1,116 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\DebugBar\History; + +use Phalcon\DebugBar\Contracts\History; +use Phalcon\DebugBar\Security\AccessGate; +use Phalcon\Http\RequestInterface; +use Phalcon\Http\ResponseInterface; + +use function is_string; +use function json_encode; +use function strtoupper; + +use const JSON_UNESCAPED_SLASHES; +use const JSON_UNESCAPED_UNICODE; + +/** + * Serves the history endpoint with no framework application. GET returns the + * request list of the current browser or one stored entry. DELETE clears the + * history of that browser. + */ +final class HistoryHandler +{ + public function __construct( + private readonly History $history, + private readonly AccessGate $accessGate, + private readonly RequestInterface $request, + private readonly ResponseInterface $response + ) { + } + + public function clear(): ResponseInterface + { + return $this->handle( + 'DELETE', + fn (): ResponseInterface => $this->json(['cleared' => $this->history->clear()]) + ); + } + + public function index(): ResponseInterface + { + return $this->handle( + 'GET', + function (): ResponseInterface { + $id = $this->request->getQuery('id'); + if (null === $id) { + return $this->json(['requests' => $this->history->find()]); + } + if (!is_string($id)) { + return $this->json(['error' => 'Request not found.'], 404); + } + + $entry = $this->history->get($id); + if (null === $entry) { + return $this->json(['error' => 'Request not found.'], 404); + } + + return $this->json(['request' => $entry]); + } + ); + } + + public function open(): ResponseInterface + { + return 'DELETE' === $this->transportMethod() ? $this->clear() : $this->index(); + } + + /** + * @param callable(): ResponseInterface $action + */ + private function handle(string $expectedMethod, callable $action): ResponseInterface + { + $clientIp = $this->request->getClientAddress(); + if (!$this->accessGate->allows(is_string($clientIp) ? $clientIp : null)) { + return $this->json(['error' => 'Not found.'], 404); + } + + if ($expectedMethod !== $this->transportMethod()) { + return $this->json(['error' => 'Method not allowed.'], 405); + } + + return $action(); + } + + /** + * @param array $body + */ + private function json(array $body, int $status = 200): ResponseInterface + { + $json = json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + $this->response->setStatusCode($status); + $this->response->setContentType('application/json', 'UTF-8'); + $this->response->setHeader('Cache-Control', 'no-store, private'); + $this->response->setHeader('X-Content-Type-Options', 'nosniff'); + $this->response->setContent(false === $json ? '{}' : $json); + + return $this->response; + } + + private function transportMethod(): string + { + return strtoupper($this->request->getServer('REQUEST_METHOD') ?? ''); + } +} diff --git a/tests/Unit/DebugBar/History/HistoryHandlerTest.php b/tests/Unit/DebugBar/History/HistoryHandlerTest.php new file mode 100644 index 0000000..0dabbb1 --- /dev/null +++ b/tests/Unit/DebugBar/History/HistoryHandlerTest.php @@ -0,0 +1,248 @@ + + * + * For the full copyright and license information, please view the LICENSE.txt + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Phalcon\Tests\Unit\DebugBar\History; + +use Phalcon\DebugBar\Contracts\History; +use Phalcon\DebugBar\History\HistoryHandler; +use Phalcon\DebugBar\Security\AccessGate; +use Phalcon\Http\RequestInterface; +use Phalcon\Http\Response; +use Phalcon\Http\ResponseInterface; +use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\MockObject\MockObject; + +use function json_decode; + +final class HistoryHandlerTest extends AbstractUnitTestCase +{ + /** + * @return array + */ + public static function deniedClients(): array + { + return [ + 'other address' => ['203.0.113.10'], + 'no address' => [false], + ]; + } + + /** + * @return array + */ + public static function wrongMethods(): array + { + return [ + 'index with POST' => ['index', 'POST'], + 'clear with GET' => ['clear', 'GET'], + 'open with POST' => ['open', 'POST'], + 'index with nothing' => ['index', null], + 'clear with nothing' => ['clear', null], + ]; + } + + public function testClearClearsTheHistory(): void + { + $history = $this->createMock(History::class); + $history->expects($this->once())->method('clear')->willReturn(3); + + $this->assertJsonResponse( + $this->handler($history, $this->request('DELETE'))->clear(), + 200, + ['cleared' => 3] + ); + } + + #[DataProvider('deniedClients')] + public function testDeniedClientGetsNotFoundAndTheHistoryIsNotRead(false | string $clientIp): void + { + $history = $this->createMock(History::class); + $history->expects($this->never())->method('find'); + $history->expects($this->never())->method('clear'); + + $this->assertJsonResponse( + $this->handler($history, $this->request('GET', null, $clientIp), ['127.0.0.1'])->index(), + 404, + ['error' => 'Not found.'] + ); + $this->assertJsonResponse( + $this->handler($history, $this->request('DELETE', null, $clientIp), ['127.0.0.1'])->clear(), + 404, + ['error' => 'Not found.'] + ); + } + + public function testIndexListsTheRequests(): void + { + $history = $this->createMock(History::class); + $history->expects($this->once())->method('find')->willReturn([['id' => 'stored-request']]); + + $this->assertJsonResponse( + $this->handler($history, $this->request('GET'))->index(), + 200, + ['requests' => [['id' => 'stored-request']]] + ); + } + + public function testIndexReadsOneRequest(): void + { + $history = $this->createMock(History::class); + $history->expects($this->once())->method('get')->with('stored-request')->willReturn(['payload' => []]); + + $this->assertJsonResponse( + $this->handler($history, $this->request('GET', 'stored-request'))->index(), + 200, + ['request' => ['payload' => []]] + ); + } + + public function testIndexWithANonStringIdReturnsNotFound(): void + { + $history = $this->createMock(History::class); + $history->expects($this->never())->method('get'); + + $this->assertJsonResponse( + $this->handler($history, $this->request('GET', ['invalid']))->index(), + 404, + ['error' => 'Request not found.'] + ); + } + + public function testIndexWithAnUnknownIdReturnsNotFound(): void + { + $history = $this->createMock(History::class); + $history->expects($this->once())->method('get')->with('unknown-id')->willReturn(null); + + $this->assertJsonResponse( + $this->handler($history, $this->request('GET', 'unknown-id'))->index(), + 404, + ['error' => 'Request not found.'] + ); + } + + public function testMethodOverrideCannotClearTheHistory(): void + { + $history = $this->createMock(History::class); + $history->expects($this->never())->method('clear'); + $request = $this->request('POST'); + $request->method('getMethod')->willReturn('DELETE'); + + $this->assertJsonResponse( + $this->handler($history, $request)->open(), + 405, + ['error' => 'Method not allowed.'] + ); + } + + public function testOpenClearsForADeleteInAnyCase(): void + { + $history = $this->createMock(History::class); + $history->expects($this->once())->method('clear')->willReturn(1); + + $this->assertJsonResponse( + $this->handler($history, $this->request('delete'))->open(), + 200, + ['cleared' => 1] + ); + } + + public function testOpenListsForAGet(): void + { + $history = $this->createMock(History::class); + $history->expects($this->once())->method('find')->willReturn([]); + $history->expects($this->never())->method('clear'); + + $this->assertJsonResponse( + $this->handler($history, $this->request('GET'))->open(), + 200, + ['requests' => []] + ); + } + + public function testResponseIsPrivateJson(): void + { + $response = $this->handler($this->createMock(History::class), $this->request('GET'))->index(); + + $this->assertSame('application/json; charset=UTF-8', $response->getHeaders()->get('Content-Type')); + $this->assertSame('no-store, private', $response->getHeaders()->get('Cache-Control')); + } + + public function testResponseIsTheOneTheHandlerReceived(): void + { + $response = new Response(); + $handler = new HistoryHandler( + $this->createMock(History::class), + new AccessGate([], null), + $this->request('GET'), + $response + ); + + $this->assertSame($response, $handler->index()); + } + + public function testUnencodableBodyGivesAnEmptyObject(): void + { + $history = $this->createMock(History::class); + $history->method('find')->willReturn([['uri' => "\xB1"]]); + + $response = $this->handler($history, $this->request('GET'))->index(); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('{}', $response->getContent()); + } + + #[DataProvider('wrongMethods')] + public function testWrongMethodIsRejected(string $method, ?string $transport): void + { + $history = $this->createMock(History::class); + $history->expects($this->never())->method('find'); + $history->expects($this->never())->method('clear'); + + $response = $this->handler($history, $this->request($transport))->{$method}(); + $this->assertInstanceOf(ResponseInterface::class, $response); + + $this->assertJsonResponse($response, 405, ['error' => 'Method not allowed.']); + } + + /** + * @param array $expectedBody + */ + private function assertJsonResponse(ResponseInterface $response, int $status, array $expectedBody): void + { + $this->assertSame($status, $response->getStatusCode()); + $this->assertSame('nosniff', $response->getHeaders()->get('X-Content-Type-Options')); + $this->assertSame($expectedBody, json_decode($response->getContent(), true)); + } + + /** + * @param list $allowedIps + */ + private function handler(History $history, RequestInterface $request, array $allowedIps = []): HistoryHandler + { + return new HistoryHandler($history, new AccessGate($allowedIps, null), $request, new Response()); + } + + private function request( + ?string $method, + mixed $id = null, + false | string $clientIp = '127.0.0.1' + ): MockObject & RequestInterface { + $request = $this->createMock(RequestInterface::class); + $request->method('getServer')->with('REQUEST_METHOD')->willReturn($method); + $request->method('getQuery')->with('id')->willReturn($id); + $request->method('getClientAddress')->willReturn($clientIp); + + return $request; + } +} From 1c1355292c7c011a0584504ae0ffadda64815f5e Mon Sep 17 00:00:00 2001 From: Nikolaos Dimopoulos Date: Wed, 4 Nov 2026 18:09:22 -0600 Subject: [PATCH 2/2] [#42] - updating changelog Assisted-by: Claude Code --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb34536..458aea4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ All notable changes to `phalcon/debugbar` are documented here. The format is bas - Documentation and tests for the View and Cache panels in ADR applications. [#46](https://github.com/phalcon/debugbar/issues/46) - `Logger\ExceptionAdapter` feeds the Exceptions panel from the logger in ADR applications. [#47](https://github.com/phalcon/debugbar/issues/47) - `History\HistoryEndpoint` takes the request and the base URI with no DI container, and `Mvc\HistoryListener` selects the MVC controller. [#41](https://github.com/phalcon/debugbar/issues/41) +- `History\HistoryHandler` lists, reads and clears the request history with no MVC types. [#42](https://github.com/phalcon/debugbar/issues/42) ### Fixed