diff --git a/CHANGELOG.md b/CHANGELOG.md index 458aea4..4d6f3cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ All notable changes to `phalcon/debugbar` are documented here. The format is bas - `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) +- Request history in ADR applications, with the `history.base_uri` option. [#43](https://github.com/phalcon/debugbar/issues/43) ### Fixed diff --git a/docs/index.md b/docs/index.md index c913b4a..6f201fa 100644 --- a/docs/index.md +++ b/docs/index.md @@ -191,12 +191,33 @@ $container->set( The closure calls `Debug::getBar()` when the application first uses the logger. This is after the provider boots the bar. The adapter does not change the response and does not keep the exception from the error responder. The panel also shows `RouteNotFound` for a URL with no route, because the error responder logs this too. If the error responder itself fails, the application returns a bare 500 response and logs nothing, so the panel does not show the exception. +The `history` keys turn on request history. You do not have to add a route. Set `history.base_uri` when the application is not at the root of the host, because an ADR application has no `url` service by default. + +```php + [ + 'enabled' => true, + 'path' => dirname(__DIR__) . '/runtime/debugbar', + 'base_uri' => '/', + ], +]))->provide($container); +``` + +The provider decorates the router (`Phalcon\Contracts\ADR\Router\Router`) with `extend()`. The history URL goes to `Phalcon\DebugBar\ADR\HistoryAction`, and all other URLs go to the router of the application. The history URL replaces an application route with the same path. The global middleware of the application also runs for this Action. Route middleware does not run. + +!!! warning "NOTE" + + Do not resolve the router during registration. `extend()` on a resolved service throws `CannotExtendResolved`. In a blocked environment, the provider does not change the router. + These limits apply in an ADR application: - **One request for each process.** `Phalcon\ADR\Application::handle()` supports a reused request in a long-lived worker, but collectors have no reset method. In a worker, their data grows from request to request. `HistoryCookie::fromGlobals()` also reads only at boot. - **Events manager.** The events manager must implement `Phalcon\Events\ManagerInterface`. The default manager does. A manager that implements only `Phalcon\Contracts\Events\Manager` gets no listener, so the bar is not injected. - **Empty panels.** The `database`, `view` and `cache` panels get data only when the application gives the events manager to these services. The `exceptions` panel gets data only when the application adds `ExceptionAdapter` to its logger. The provider reads no `config` service, so the `config` panel is empty. -- **History.** The provider does not read the `history` keys. Request history works only in MVC. ### A Host with No Events Manager @@ -231,7 +252,7 @@ if (null !== $listener) { ## Configuration -The second argument to `Provider` is a nested array. Every key is optional. `Phalcon\DebugBar\ADR\Provider` and `Phalcon\DebugBar\Core` take the same array as their only argument. They do not read the `history` keys. +The second argument to `Provider` is a nested array. Every key is optional. `Phalcon\DebugBar\ADR\Provider` and `Phalcon\DebugBar\Core` take the same array as their only argument. `Core` does not read the `history` keys. | Key | Type | Default | Purpose | |--------------------|---------------------------|--------------------------|------------------------------------------------------------------| @@ -244,6 +265,7 @@ The second argument to `Provider` is a nested array. Every key is optional. `Pha | `env.strict` | `bool` | `false` | When `true`, `boot()` throws in a blocked/undefined environment instead of returning silently. | | `env.var` | `string` | `APP_ENV` | Environment variable inspected by the gate. | | `headers` | `bool` | `true` | Emit the `X-Debug-Bar` diagnostic header. | +| `history.base_uri` | `string` | `/` | Base URI of an ADR application. The MVC provider reads the `url` service. | | `history.enabled` | `bool` | `false` | Store and browse recent requests for the active browser. | | `history.url` | `string` | `/_debugbar/open` | Internal GET/DELETE endpoint path, relative to the application's base URI. | | `history.path` | `string` | required when enabled | Absolute writable storage directory; keep it outside the document root. | diff --git a/src/DebugBar/ADR/HistoryAction.php b/src/DebugBar/ADR/HistoryAction.php new file mode 100644 index 0000000..9767064 --- /dev/null +++ b/src/DebugBar/ADR/HistoryAction.php @@ -0,0 +1,40 @@ + + * + * 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\ADR; + +use Phalcon\Contracts\ADR\Action; +use Phalcon\Contracts\Http\AttributeRequest; +use Phalcon\DebugBar\Contracts\History; +use Phalcon\DebugBar\History\HistoryHandler; +use Phalcon\DebugBar\Security\AccessGate; +use Phalcon\Http\Response; +use Phalcon\Http\ResponseInterface; + +/** + * Serves the history endpoint in an ADR application. It creates its own response, + * so that it does not change a response of the application. + */ +final class HistoryAction implements Action +{ + public function __construct( + private readonly History $history, + private readonly AccessGate $accessGate + ) { + } + + public function __invoke(AttributeRequest $request): ResponseInterface + { + return (new HistoryHandler($this->history, $this->accessGate, $request, new Response()))->open(); + } +} diff --git a/src/DebugBar/ADR/HistoryRouter.php b/src/DebugBar/ADR/HistoryRouter.php new file mode 100644 index 0000000..7e8bf3f --- /dev/null +++ b/src/DebugBar/ADR/HistoryRouter.php @@ -0,0 +1,101 @@ + + * + * 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\ADR; + +use Phalcon\ADR\Router\RouterMatch; +use Phalcon\Contracts\ADR\ADRTypes; +use Phalcon\Contracts\ADR\Router\Router; +use Phalcon\Contracts\ADR\Router\RouterMatch as RouterMatchContract; +use Phalcon\DebugBar\History\HistoryEndpoint; +use Phalcon\Http\RequestInterface; + +/** + * Sends the history URL to `HistoryAction` and all other requests to the router + * of the application. A listener cannot do this: `application:beforeHandle` cannot + * stop `handle()`, and `application:afterHandle` runs after the routing. The + * application router is `final`, so this class decorates it. + * + * @phpstan-import-type adr_middleware_map from ADRTypes + */ +final class HistoryRouter implements Router +{ + public function __construct( + private readonly Router $router, + private readonly HistoryEndpoint $endpoint + ) { + } + + /** + * @return list + */ + public function candidatesFor(string $method, string $path): array + { + return $this->router->candidatesFor($method, $path); + } + + public function classFor(string $method, string $path): string + { + return $this->router->classFor($method, $path); + } + + public function match(RequestInterface $request): ?RouterMatchContract + { + if ($this->endpoint->matches()) { + return new RouterMatch(HistoryAction::class); + } + + return $this->router->match($request); + } + + public function methodFor(string $className): ?string + { + return $this->router->methodFor($className); + } + + public function pathFor(string $className): ?string + { + return $this->router->pathFor($className); + } + + public function setActionDirectory(string $actionDirectory): Router + { + $this->router->setActionDirectory($actionDirectory); + + return $this; + } + + public function setBaseNamespace(string $baseNamespace): Router + { + $this->router->setBaseNamespace($baseNamespace); + + return $this; + } + + /** + * @phpstan-param adr_middleware_map $middlewareMap + */ + public function setMiddlewareMap(array $middlewareMap): Router + { + $this->router->setMiddlewareMap($middlewareMap); + + return $this; + } + + public function setWordSeparator(string $wordSeparator): Router + { + $this->router->setWordSeparator($wordSeparator); + + return $this; + } +} diff --git a/src/DebugBar/ADR/Provider.php b/src/DebugBar/ADR/Provider.php index 5ff9d8f..f50dab5 100644 --- a/src/DebugBar/ADR/Provider.php +++ b/src/DebugBar/ADR/Provider.php @@ -13,14 +13,21 @@ namespace Phalcon\DebugBar\ADR; +use Phalcon\Contracts\ADR\Router\Router; use Phalcon\Contracts\Container\Service\Collection; use Phalcon\Contracts\Container\Service\Provider as ProviderContract; use Phalcon\Contracts\Events\Manager as ManagerContract; use Phalcon\Contracts\Http\AttributeRequest; +use Phalcon\DebugBar\Collector\HistoryCollector; +use Phalcon\DebugBar\Contracts\History; use Phalcon\DebugBar\Core; use Phalcon\DebugBar\Debug; use Phalcon\DebugBar\DebugBarTypes; use Phalcon\DebugBar\Exceptions\CannotUseInProduction; +use Phalcon\DebugBar\History\FilesystemHistory; +use Phalcon\DebugBar\History\HistoryCookie; +use Phalcon\DebugBar\History\HistoryEndpoint; +use Phalcon\DebugBar\History\HistoryOptions; use Phalcon\Events\ManagerInterface; use Phalcon\Http\RequestInterface; @@ -29,7 +36,9 @@ * providers: it resolves the events manager and the request, and the container * cannot change a service after that. The ADR application emits the response * after `application:afterHandle`, so the bar is injected on this event. `Bridge` - * feeds the collectors from the other events of the application. + * feeds the collectors from the other events of the application. When request + * history is enabled, `HistoryRouter` sends the history URL to `HistoryAction`, + * and the application routes do not change. * * @phpstan-import-type provider_config from DebugBarTypes */ @@ -37,12 +46,19 @@ final class Provider implements ProviderContract { private Core $core; + private ?HistoryCookie $historyCookie = null; + + private ?HistoryEndpoint $historyEndpoint = null; + + private HistoryOptions $historyOptions; + /** * @param provider_config $config */ public function __construct(array $config = []) { - $this->core = new Core($config); + $this->core = new Core($config); + $this->historyOptions = HistoryOptions::fromConfig($config['history'] ?? []); } /** @@ -57,9 +73,15 @@ public function provide(Collection $services): void $eventsManager = $this->resolve($services, ManagerContract::class); $eventsManager = $eventsManager instanceof ManagerInterface ? $eventsManager : null; $request = $this->resolve($services, AttributeRequest::class); + $request = $request instanceof RequestInterface ? $request : null; + $history = $this->registerHistory($services, $eventsManager, $request); $listener = $this->core->boot( $eventsManager, - $request instanceof RequestInterface ? $request : null + $request, + null, + $history, + $this->historyEndpoint, + $this->historyCookie ); $bar = Debug::getBar(); @@ -71,6 +93,52 @@ public function provide(Collection $services): void (new Bridge($bar))->subscribe($eventsManager); } + /** + * Registers the Action and decorates the router. The router must not be + * resolved yet: `extend()` throws for a resolved service. + */ + private function registerHistory( + Collection $services, + ?ManagerInterface $eventsManager, + ?RequestInterface $request + ): ?History { + if ( + !$this->historyOptions->enabled + || !$this->core->isCollectorEnabled(HistoryCollector::NAME) + ) { + return null; + } + + $this->historyOptions->validate(); + + if (null === $eventsManager || !$services->has(Router::class)) { + return null; + } + + $this->historyCookie = HistoryCookie::fromGlobals(); + $history = new FilesystemHistory($this->historyOptions, null, $this->historyCookie); + $history->validateStorage(); + $accessGate = $this->core->getAccessGate(); + $baseUri = $this->historyOptions->baseUri; + $endpoint = new HistoryEndpoint( + $this->historyOptions, + fn (): ?RequestInterface => $request, + fn (): string => $baseUri + ); + $this->historyEndpoint = $endpoint; + + $services->set( + HistoryAction::class, + fn (): HistoryAction => new HistoryAction($history, $accessGate) + ); + $services->extend( + Router::class, + fn (Router $router): Router => new HistoryRouter($router, $endpoint) + ); + + return $history; + } + private function resolve(Collection $services, string $name): mixed { return $services->has($name) ? $services->get($name) : null; diff --git a/src/DebugBar/DebugBarTypes.php b/src/DebugBar/DebugBarTypes.php index 8d7a3b3..f2b0f2e 100644 --- a/src/DebugBar/DebugBarTypes.php +++ b/src/DebugBar/DebugBarTypes.php @@ -60,6 +60,15 @@ * @phpstan-type view_pending array{path: string, start: int|float} * @phpstan-type view_render array{path: string, time: int|float} * @phpstan-type request_context array{0: string|null, 1: bool} + * @phpstan-type history_config array{ + * enabled?: bool, + * url?: string, + * path?: string, + * max_requests?: int, + * ttl_seconds?: int, + * max_browsers?: int, + * base_uri?: string + * } * @phpstan-type provider_config array{ * env?: array{var?: string, blocked?: list, strict?: bool}, * enabled?: bool, @@ -67,14 +76,7 @@ * access?: array{allow_ips?: list, callback?: (\Closure(): bool)|null}, * collectors?: array, * headers?: bool, - * history?: array{ - * enabled?: bool, - * url?: string, - * path?: string, - * max_requests?: int, - * ttl_seconds?: int, - * max_browsers?: int - * }, + * history?: history_config, * redact?: array{mask?: list, hidden?: list} * } */ diff --git a/src/DebugBar/History/HistoryOptions.php b/src/DebugBar/History/HistoryOptions.php index e4953fe..7c053a5 100644 --- a/src/DebugBar/History/HistoryOptions.php +++ b/src/DebugBar/History/HistoryOptions.php @@ -14,6 +14,7 @@ namespace Phalcon\DebugBar\History; use InvalidArgumentException; +use Phalcon\DebugBar\DebugBarTypes; use function max; use function parse_url; @@ -30,6 +31,8 @@ /** * Immutable request-history configuration shared by the provider and storage. + * + * @phpstan-import-type history_config from DebugBarTypes */ final class HistoryOptions { @@ -47,7 +50,8 @@ public function __construct( string $path = '', int $maxRequests = 100, int $ttlSeconds = 86400, - int $maxBrowsers = 10 + int $maxBrowsers = 10, + public readonly string $baseUri = '/' ) { $path = trim($path); $this->path = $this->trimTrailingSeparators($path); @@ -56,6 +60,22 @@ public function __construct( $this->maxBrowsers = max(1, $maxBrowsers); } + /** + * @param history_config $config + */ + public static function fromConfig(array $config): self + { + return new self( + $config['enabled'] ?? false, + $config['url'] ?? '/_debugbar/open', + $config['path'] ?? '', + $config['max_requests'] ?? 100, + $config['ttl_seconds'] ?? 86400, + $config['max_browsers'] ?? 10, + $config['base_uri'] ?? '/' + ); + } + public function validate(): void { if (!$this->enabled) { diff --git a/src/DebugBar/Provider.php b/src/DebugBar/Provider.php index b89762b..baae211 100644 --- a/src/DebugBar/Provider.php +++ b/src/DebugBar/Provider.php @@ -54,17 +54,8 @@ class Provider */ public function __construct(private readonly Application $app, array $config = []) { - $history = $config['history'] ?? []; - $this->core = new Core($config); - $this->historyOptions = new HistoryOptions( - $history['enabled'] ?? false, - $history['url'] ?? '/_debugbar/open', - $history['path'] ?? '', - $history['max_requests'] ?? 100, - $history['ttl_seconds'] ?? 86400, - $history['max_browsers'] ?? 10 - ); + $this->historyOptions = HistoryOptions::fromConfig($config['history'] ?? []); } /** diff --git a/tests/Unit/DebugBar/ADR/HistoryActionTest.php b/tests/Unit/DebugBar/ADR/HistoryActionTest.php new file mode 100644 index 0000000..cf9fe06 --- /dev/null +++ b/tests/Unit/DebugBar/ADR/HistoryActionTest.php @@ -0,0 +1,74 @@ + + * + * 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\ADR; + +use Phalcon\Contracts\Http\AttributeRequest; +use Phalcon\DebugBar\ADR\HistoryAction; +use Phalcon\DebugBar\Contracts\History; +use Phalcon\DebugBar\Security\AccessGate; +use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; + +use function json_decode; + +final class HistoryActionTest extends AbstractUnitTestCase +{ + public function testDeleteClearsTheHistory(): void + { + $history = $this->createMock(History::class); + $history->expects($this->once())->method('clear')->willReturn(2); + + $response = (new HistoryAction($history, new AccessGate()))($this->request('DELETE')); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(['cleared' => 2], json_decode($response->getContent(), true)); + } + + public function testDeniedClientGetsNotFound(): void + { + $history = $this->createMock(History::class); + $history->expects($this->never())->method('find'); + + $response = (new HistoryAction($history, new AccessGate(['127.0.0.1'])))($this->request('GET')); + + $this->assertSame(404, $response->getStatusCode()); + $this->assertSame(['error' => 'Not found.'], json_decode($response->getContent(), true)); + } + + public function testEachRequestGetsANewResponse(): void + { + $action = new HistoryAction($this->createMock(History::class), new AccessGate()); + + $this->assertNotSame($action($this->request('GET')), $action($this->request('GET'))); + } + + public function testGetListsTheRequests(): void + { + $history = $this->createMock(History::class); + $history->expects($this->once())->method('find')->willReturn([['id' => 'stored-request']]); + + $response = (new HistoryAction($history, new AccessGate()))($this->request('GET')); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(['requests' => [['id' => 'stored-request']]], json_decode($response->getContent(), true)); + } + + private function request(string $method): AttributeRequest + { + $request = $this->createMock(AttributeRequest::class); + $request->method('getServer')->with('REQUEST_METHOD')->willReturn($method); + $request->method('getClientAddress')->willReturn('203.0.113.10'); + + return $request; + } +} diff --git a/tests/Unit/DebugBar/ADR/HistoryRouterTest.php b/tests/Unit/DebugBar/ADR/HistoryRouterTest.php new file mode 100644 index 0000000..301e7d3 --- /dev/null +++ b/tests/Unit/DebugBar/ADR/HistoryRouterTest.php @@ -0,0 +1,109 @@ + + * + * 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\ADR; + +use Phalcon\ADR\Router\RouterMatch; +use Phalcon\Contracts\ADR\Router\Router; +use Phalcon\DebugBar\ADR\HistoryAction; +use Phalcon\DebugBar\ADR\HistoryRouter; +use Phalcon\DebugBar\History\HistoryEndpoint; +use Phalcon\DebugBar\History\HistoryOptions; +use Phalcon\Http\RequestInterface; +use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; + +final class HistoryRouterTest extends AbstractUnitTestCase +{ + public function testHistoryUrlMatchesTheAction(): void + { + $inner = $this->createMock(Router::class); + $inner->expects($this->never())->method('match'); + $request = $this->request('/_debugbar/open?id=entry'); + + $match = $this->router($inner, $request)->match($request); + + $this->assertNotNull($match); + $this->assertSame(HistoryAction::class, $match->getAction()); + $this->assertSame([], $match->getAttributes()); + $this->assertSame([], $match->getMiddleware()); + } + + public function testLookupsGoToTheInnerRouter(): void + { + $inner = $this->createMock(Router::class); + $inner->expects($this->once())->method('candidatesFor')->with('GET', '/a')->willReturn(['A']); + $inner->expects($this->once())->method('classFor')->with('GET', '/a')->willReturn('A'); + $inner->expects($this->once())->method('methodFor')->with('A')->willReturn('GET'); + $inner->expects($this->once())->method('pathFor')->with('A')->willReturn('/a'); + $router = $this->router($inner, $this->request('/a')); + + $this->assertSame(['A'], $router->candidatesFor('GET', '/a')); + $this->assertSame('A', $router->classFor('GET', '/a')); + $this->assertSame('GET', $router->methodFor('A')); + $this->assertSame('/a', $router->pathFor('A')); + } + + public function testOtherUrlsGoToTheInnerRouter(): void + { + $match = new RouterMatch(self::class); + $request = $this->request('/orders?next=/_debugbar/open'); + $inner = $this->createMock(Router::class); + $inner->expects($this->once())->method('match')->with($request)->willReturn($match); + + $this->assertSame($match, $this->router($inner, $request)->match($request)); + } + + public function testSettersGoToTheInnerRouterAndReturnTheDecorator(): void + { + $inner = $this->createMock(Router::class); + $inner->expects($this->once())->method('setActionDirectory')->with('/actions')->willReturnSelf(); + $inner->expects($this->once())->method('setBaseNamespace')->with('App')->willReturnSelf(); + $inner->expects($this->once())->method('setMiddlewareMap')->with(['\\A\\' => [self::class]])->willReturnSelf(); + $inner->expects($this->once())->method('setWordSeparator')->with('_')->willReturnSelf(); + $router = $this->router($inner, $this->request('/a')); + + $this->assertSame($router, $router->setActionDirectory('/actions')); + $this->assertSame($router, $router->setBaseNamespace('App')); + $this->assertSame($router, $router->setMiddlewareMap(['\\A\\' => [self::class]])); + $this->assertSame($router, $router->setWordSeparator('_')); + } + + public function testUrlWithNoMatchReturnsNull(): void + { + $request = $this->request('/missing'); + $inner = $this->createMock(Router::class); + $inner->method('match')->willReturn(null); + + $this->assertNull($this->router($inner, $request)->match($request)); + } + + private function request(string $uri): RequestInterface + { + $request = $this->createMock(RequestInterface::class); + $request->method('getURI')->willReturn($uri); + + return $request; + } + + private function router(Router $inner, RequestInterface $request): HistoryRouter + { + return new HistoryRouter( + $inner, + new HistoryEndpoint( + new HistoryOptions(true), + fn (): RequestInterface => $request, + fn (): string => '/' + ) + ); + } +} diff --git a/tests/Unit/DebugBar/ADR/ProviderHistoryTest.php b/tests/Unit/DebugBar/ADR/ProviderHistoryTest.php new file mode 100644 index 0000000..1c9bf9f --- /dev/null +++ b/tests/Unit/DebugBar/ADR/ProviderHistoryTest.php @@ -0,0 +1,361 @@ + + * + * 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\ADR; + +use InvalidArgumentException; +use Phalcon\ADR\Container\AdrProvider; +use Phalcon\Container\Container; +use Phalcon\Container\Exceptions\CannotExtendResolved; +use Phalcon\Contracts\ADR\Router\Router; +use Phalcon\Contracts\Events\Manager as ManagerContract; +use Phalcon\DebugBar\ADR\HistoryRouter; +use Phalcon\DebugBar\ADR\Provider; +use Phalcon\DebugBar\Core; +use Phalcon\DebugBar\Debug; +use Phalcon\DebugBar\DebugBar; +use Phalcon\DebugBar\DebugBarTypes; +use Phalcon\DebugBar\History\HistoryCookie; +use Phalcon\DebugBar\ResponseListener; +use Phalcon\Events\Manager; +use Phalcon\Http\ResponseInterface; +use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; +use Phalcon\Tests\Support\DebugBar\ADR\Front; +use Phalcon\Tests\Support\DebugBar\ADR\RecordingEmitter; +use PHPUnit\Framework\Attributes\BackupGlobals; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use ReflectionProperty; +use RuntimeException; +use SplFileInfo; + +use function bin2hex; +use function dirname; +use function is_array; +use function is_dir; +use function json_decode; +use function random_bytes; +use function rmdir; +use function str_repeat; +use function unlink; + +/** + * Real requests through a real ADR front controller, with request history on. + * + * @phpstan-import-type provider_config from DebugBarTypes + */ +#[BackupGlobals(true)] +final class ProviderHistoryTest extends AbstractUnitTestCase +{ + private const ENV_VAR = 'DEBUGBAR_TEST_ENV'; + + private const URL = '/_debugbar/open'; + + private string $path; + + protected function setUp(): void + { + parent::setUp(); + + Debug::setBar(null); + + $this->path = dirname(__DIR__, 3) . '/_output/adr-history-' . bin2hex(random_bytes(8)); + $_ENV[self::ENV_VAR] = 'dev'; + $_COOKIE[HistoryCookie::NAME] = str_repeat('a', 64); + $_GET = []; + } + + protected function tearDown(): void + { + Debug::setBar(null); + + if (is_dir($this->path)) { + $items = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($this->path, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + + /** @var SplFileInfo $item */ + foreach ($items as $item) { + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + + rmdir($this->path); + } + + parent::tearDown(); + } + + public function testBaseUriPrefixesTheEndpoint(): void + { + $config = $this->config(['base_uri' => '/app1/']); + + $this->assertSame( + ['requests' => []], + $this->body($this->request('/app1' . self::URL, 'GET', $config), 200) + ); + + $this->assertSame(404, $this->request(self::URL, 'GET', $config)->getStatusCode()); + $this->assertSame('/app1' . self::URL, $this->panelUrl()); + } + + public function testBlockedEnvironmentDoesNotValidateTheHistoryOptions(): void + { + $_ENV[self::ENV_VAR] = 'production'; + + (new Provider(['env' => ['var' => self::ENV_VAR], 'history' => ['enabled' => true]]))->provide(new Container()); + + $this->assertNull(Debug::getBar()); + } + + public function testBlockedEnvironmentLeavesTheRouterUnchanged(): void + { + $_ENV[self::ENV_VAR] = 'production'; + + $this->assertNotInstanceOf(HistoryRouter::class, $this->container($this->config())->get(Router::class)); + } + + public function testClearRemovesTheStoredRequests(): void + { + $this->request('/page', 'GET', $this->config()); + + $this->assertSame(['cleared' => 1], $this->body($this->request(self::URL, 'DELETE', $this->config()), 200)); + $this->assertSame(['requests' => []], $this->body($this->request(self::URL, 'GET', $this->config()), 200)); + } + + public function testDeniedClientGetsNotFound(): void + { + $_SERVER['REMOTE_ADDR'] = '203.0.113.10'; + $config = $this->config() + ['access' => ['allow_ips' => ['127.0.0.1']]]; + + $this->assertSame(['error' => 'Not found.'], $this->body($this->request(self::URL, 'GET', $config), 404)); + } + + public function testDisabledCollectorRegistersNoEndpoint(): void + { + $response = $this->request(self::URL, 'GET', $this->config() + ['collectors' => ['history' => false]]); + + $this->assertSame(404, $response->getStatusCode()); + $this->assertFalse($this->bar()->hasCollector('history')); + } + + public function testDisabledHistoryLeavesTheRouterUnchanged(): void + { + $container = $this->container(['env' => ['var' => self::ENV_VAR]]); + + $this->assertNotInstanceOf(HistoryRouter::class, $container->get(Router::class)); + $this->assertFalse($this->bar()->hasCollector('history')); + } + + public function testEnabledHistoryDecoratesTheRouter(): void + { + $this->assertInstanceOf(HistoryRouter::class, $this->container($this->config())->get(Router::class)); + } + + public function testEnabledHistoryWithNoPathThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('history.path is required'); + + (new Provider(['env' => ['var' => self::ENV_VAR], 'history' => ['enabled' => true]]))->provide(new Container()); + } + + public function testHistoryRequestIsNotRecorded(): void + { + $this->request(self::URL, 'GET', $this->config()); + + $this->assertSame(['requests' => []], $this->body($this->request(self::URL, 'GET', $this->config()), 200)); + } + + public function testHistoryResponseIsJsonWithNoBar(): void + { + $response = $this->request(self::URL, 'GET', $this->config()); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('{"requests":[]}', $response->getContent()); + $this->assertSame('application/json; charset=UTF-8', $response->getHeaders()->get('Content-Type')); + $this->assertFalse($response->getHeaders()->has('X-Debug-Bar')); + } + + public function testListenerReceivesTheHistoryCookie(): void + { + $provider = new Provider($this->config()); + $container = new Container(); + (new AdrProvider())->provide($container); + $provider->provide($container); + + // The CLI discards response headers, so a test cannot read the `Set-Cookie` header. + $core = (new ReflectionProperty(Provider::class, 'core'))->getValue($provider); + $this->assertInstanceOf(Core::class, $core); + $listener = (new ReflectionProperty(Core::class, 'listener'))->getValue($core); + $this->assertInstanceOf(ResponseListener::class, $listener); + + $this->assertInstanceOf( + HistoryCookie::class, + (new ReflectionProperty(ResponseListener::class, 'historyCookie'))->getValue($listener) + ); + } + + public function testListsAndReadsAStoredRequest(): void + { + $this->request('/page', 'GET', $this->config()); + + $list = $this->body($this->request(self::URL, 'GET', $this->config()), 200); + $requests = $list['requests'] ?? null; + if (!is_array($requests) || !is_array($requests[0] ?? null)) { + $this->fail('The list should have a request.'); + } + + $this->assertCount(1, $requests); + $this->assertSame('/page', $requests[0]['uri']); + + $_GET = ['id' => $requests[0]['id']]; + $detail = $this->body($this->request(self::URL, 'GET', $this->config()), 200); + + $this->assertArrayHasKey('request', $detail); + } + + public function testManagerOutsideTheEventsManagerInterfaceRegistersNoEndpoint(): void + { + $manager = $this->createMock(ManagerContract::class); + $container = new Container(); + (new AdrProvider())->provide($container); + $container->set(ManagerContract::class, fn (): ManagerContract => $manager); + + (new Provider($this->config()))->provide($container); + + $this->assertFalse($this->bar()->hasCollector('history')); + $this->assertNotInstanceOf(HistoryRouter::class, $container->get(Router::class)); + } + + public function testOtherUrlsReachTheApplicationAndShowThePanel(): void + { + $response = $this->request('/page', 'GET', $this->config()); + + $this->assertStringStartsWith('page', $response->getContent()); + $this->assertStringContainsString('phalcon-debugbar-data', $response->getContent()); + $this->assertSame(self::URL, $this->panelUrl()); + } + + public function testPostIsRejected(): void + { + $this->assertSame( + ['error' => 'Method not allowed.'], + $this->body($this->request(self::URL, 'POST', $this->config()), 405) + ); + } + + public function testResolvedRouterCannotBeDecorated(): void + { + $container = new Container(); + (new AdrProvider())->provide($container); + $container->get(Router::class); + + $this->expectException(CannotExtendResolved::class); + + (new Provider($this->config()))->provide($container); + } + + public function testRouterMissingFromTheContainerRegistersNoEndpoint(): void + { + $container = new Container(); + $container->set(ManagerContract::class, fn (): ManagerContract => new Manager()); + + (new Provider($this->config()))->provide($container); + + $this->assertFalse($this->bar()->hasCollector('history')); + } + + public function testUnwritableStorageThrows(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('history.path must be writable'); + + $this->container(['env' => ['var' => self::ENV_VAR], 'history' => ['enabled' => true, 'path' => __FILE__]]); + } + + private function bar(): DebugBar + { + $bar = Debug::getBar(); + if (!$bar instanceof DebugBar) { + $this->fail('Expected the debug bar to be booted.'); + } + + return $bar; + } + + /** + * @return array + */ + private function body(ResponseInterface $response, int $status): array + { + $this->assertSame($status, $response->getStatusCode()); + $this->assertSame('nosniff', $response->getHeaders()->get('X-Content-Type-Options')); + + $body = json_decode($response->getContent(), true); + $this->assertIsArray($body); + + return $body; + } + + /** + * @param array{base_uri?: string} $history + * + * @return array{env: array{var: string}, history: array{enabled: true, path: string, base_uri?: string}} + */ + private function config(array $history = []): array + { + return [ + 'env' => ['var' => self::ENV_VAR], + 'history' => ['enabled' => true, 'path' => $this->path] + $history, + ]; + } + + /** + * @param provider_config $config + */ + private function container(array $config): Container + { + $container = new Container(); + (new AdrProvider())->provide($container); + (new Provider($config))->provide($container); + + return $container; + } + + private function panelUrl(): mixed + { + $panel = $this->bar()->collect()['data']['history']['panel']; + $this->assertIsArray($panel); + + return $panel['url']; + } + + /** + * @param provider_config $config + */ + private function request(string $uri, string $method, array $config): ResponseInterface + { + $_SERVER['REQUEST_METHOD'] = $method; + $_SERVER['REQUEST_URI'] = $uri; + + $emitter = new RecordingEmitter(); + + $this->assertSame(0, (new Front($emitter, $config))->run()); + if (null === $emitter->response) { + $this->fail('Expected the front controller to emit a response.'); + } + + return $emitter->response; + } +} diff --git a/tests/Unit/DebugBar/History/HistoryOptionsTest.php b/tests/Unit/DebugBar/History/HistoryOptionsTest.php index 61fbea6..7b2ebb0 100644 --- a/tests/Unit/DebugBar/History/HistoryOptionsTest.php +++ b/tests/Unit/DebugBar/History/HistoryOptionsTest.php @@ -87,6 +87,42 @@ public function testEnabledHistoryRequiresAnInternalPathUrl(string $url): void (new HistoryOptions(true, $url, '/var/debugbar'))->validate(); } + public function testFromConfigReadsEveryKey(): void + { + $options = HistoryOptions::fromConfig( + [ + 'enabled' => true, + 'url' => '/history', + 'path' => '/var/debugbar', + 'max_requests' => 5, + 'ttl_seconds' => 6, + 'max_browsers' => 7, + 'base_uri' => '/app1/', + ] + ); + + $this->assertTrue($options->enabled); + $this->assertSame('/history', $options->url); + $this->assertSame('/var/debugbar', $options->path); + $this->assertSame(5, $options->maxRequests); + $this->assertSame(6, $options->ttlSeconds); + $this->assertSame(7, $options->maxBrowsers); + $this->assertSame('/app1/', $options->baseUri); + } + + public function testFromConfigUsesTheDefaults(): void + { + $options = HistoryOptions::fromConfig([]); + + $this->assertFalse($options->enabled); + $this->assertSame('/_debugbar/open', $options->url); + $this->assertSame('', $options->path); + $this->assertSame(100, $options->maxRequests); + $this->assertSame(86400, $options->ttlSeconds); + $this->assertSame(10, $options->maxBrowsers); + $this->assertSame('/', $options->baseUri); + } + public function testHistoryLimitsAreAtLeastOne(): void { $options = new HistoryOptions(true, '/_debugbar/open', '/var/debugbar', 0, 0, 0);