diff --git a/CHANGELOG.md b/CHANGELOG.md index 610824a..41c74b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ All notable changes to `phalcon/debugbar` are documented here. The format is bas disabled by default. Storage creation is serialized and capped across browser identities, with least-recently-updated eviction. - `ResponseListener::handle()` runs the injection step with a `ResponseInterface` and no event. The MVC listener calls it, so MVC behavior does not change. [#39](https://github.com/phalcon/debugbar/issues/39) +- `Core` boots the bar with no `Phalcon\Mvc\Application` and no `Phalcon\Di\DiInterface`. It takes an optional events manager, request, config source and history parts. `Provider` is now a wrapper over `Core`, so MVC behavior does not change. [#40](https://github.com/phalcon/debugbar/issues/40) ### Fixed diff --git a/src/DebugBar/Core.php b/src/DebugBar/Core.php new file mode 100644 index 0000000..ca445f6 --- /dev/null +++ b/src/DebugBar/Core.php @@ -0,0 +1,292 @@ + + * + * 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; + +use Phalcon\Config\ConfigInterface; +use Phalcon\DebugBar\Collector\CacheCollector; +use Phalcon\DebugBar\Collector\ConfigCollector; +use Phalcon\DebugBar\Collector\DatabaseCollector; +use Phalcon\DebugBar\Collector\ExceptionsCollector; +use Phalcon\DebugBar\Collector\HistoryCollector; +use Phalcon\DebugBar\Collector\LoggerCollector; +use Phalcon\DebugBar\Collector\MemoryCollector; +use Phalcon\DebugBar\Collector\MessagesCollector; +use Phalcon\DebugBar\Collector\RequestCollector; +use Phalcon\DebugBar\Collector\RouteCollector; +use Phalcon\DebugBar\Collector\SessionCollector; +use Phalcon\DebugBar\Collector\TimeCollector; +use Phalcon\DebugBar\Collector\VersionCollector; +use Phalcon\DebugBar\Collector\ViewCollector; +use Phalcon\DebugBar\Contracts\Collector; +use Phalcon\DebugBar\Contracts\History; +use Phalcon\DebugBar\Contracts\Subscriber; +use Phalcon\DebugBar\Exceptions\CannotUseInProduction; +use Phalcon\DebugBar\History\HistoryCookie; +use Phalcon\DebugBar\History\HistoryEndpoint; +use Phalcon\DebugBar\Security\AccessGate; +use Phalcon\DebugBar\Security\Redactor; +use Phalcon\Events\ManagerInterface; +use Phalcon\Http\RequestInterface; +use Phalcon\Http\ResponseInterface; + +use function getenv; +use function in_array; +use function is_string; +use function mb_strtolower; + +/** + * Boots the debug bar with no framework application. All its inputs are optional: + * an events manager, a request and a config source. An integration attaches the + * listener from `boot()` to its own response event. A host with no events manager + * calls `handle()` when it has the response. + * + * @phpstan-import-type provider_config from DebugBarTypes + */ +final class Core +{ + private AccessGate $accessGate; + + /** + * @var list + */ + private array $blocked; + + /** + * @var array + */ + private array $collectorsConfig; + + private bool $enabled; + + private string $envVar; + + private bool $headers; + + private ?ResponseListener $listener = null; + + private ?string $nonce; + + private Redactor $redactor; + + private bool $strict; + + /** + * @param provider_config $config + */ + public function __construct(array $config = []) + { + $env = $config['env'] ?? []; + $assets = $config['assets'] ?? []; + $access = $config['access'] ?? []; + $redact = $config['redact'] ?? []; + + $this->envVar = $env['var'] ?? 'APP_ENV'; + $this->blocked = $env['blocked'] ?? ['production', 'prod']; + $this->strict = $env['strict'] ?? false; + $this->enabled = $config['enabled'] ?? true; + $this->nonce = $assets['nonce'] ?? null; + $this->accessGate = new AccessGate($access['allow_ips'] ?? [], $access['callback'] ?? null); + $this->collectorsConfig = $config['collectors'] ?? []; + $this->headers = $config['headers'] ?? true; + $this->redactor = new Redactor( + [...Redactor::DEFAULT_KEYS, ...($redact['mask'] ?? [])], + $redact['hidden'] ?? [] + ); + } + + /** + * Creates the bar with its enabled collectors, points the `Debug` facade at + * it and subscribes the streamed collectors. It returns the listener, or null + * when the bar cannot boot. + * + * @throws CannotUseInProduction When `env.strict` is set and the environment is blocked or undefined. + */ + public function boot( + ?ManagerInterface $eventsManager = null, + ?RequestInterface $request = null, + ?ConfigInterface $config = null, + ?History $history = null, + ?HistoryEndpoint $historyEndpoint = null, + ?HistoryCookie $historyCookie = null + ): ?ResponseListener { + if (true !== $this->canBoot()) { + return null; + } + + $bar = new DebugBar(); + foreach ($this->buildCollectors($request, $config) as $collector) { + $bar->addCollector($collector); + } + if (null !== $historyEndpoint) { + $bar->addCollector(new HistoryCollector($historyEndpoint)); + } + + Debug::setBar($bar); + + if (null !== $eventsManager) { + foreach ($bar->getCollectors() as $collector) { + if ($collector instanceof Subscriber) { + $collector->subscribe($eventsManager); + } + } + } + + $this->listener = new ResponseListener( + $bar, + new Renderer(), + new Injector(), + $this->accessGate, + $request, + new BarOptions($this->headers, $this->nonce), + $history, + $historyEndpoint, + $historyCookie + ); + + return $this->listener; + } + + /** + * Whether the environment and the config permit the bar to boot. + * + * @throws CannotUseInProduction When `env.strict` is set and the environment is blocked or undefined. + */ + public function canBoot(): bool + { + if (true !== $this->isAllowed()) { + if (true === $this->strict) { + throw new CannotUseInProduction( + 'The debug bar cannot boot: the "' . $this->envVar + . '" environment is undefined or blocked.' + ); + } + + return false; + } + + return $this->enabled; + } + + public function getAccessGate(): AccessGate + { + return $this->accessGate; + } + + /** + * Runs the response step. It does nothing when the bar did not boot. + */ + public function handle(ResponseInterface $response): void + { + $this->listener?->handle($response); + } + + /** + * Whether the resolved environment permits the bar (present and not blocked). + */ + public function isAllowed(): bool + { + $value = $this->resolveEnv(); + if ('' === $value) { + return false; + } + + return !in_array(mb_strtolower($value), $this->blocked, true); + } + + public function isCollectorEnabled(string $name): bool + { + return $this->collectorsConfig[$name] ?? true; + } + + /** + * Builds the enabled collectors (per the config map; all on by default). + * + * @return list + */ + private function buildCollectors(?RequestInterface $request, ?ConfigInterface $config): array + { + $collectors = []; + + if ($this->isCollectorEnabled(VersionCollector::NAME)) { + $collectors[] = new VersionCollector(); + } + + if ($this->isCollectorEnabled(MessagesCollector::NAME)) { + $collectors[] = new MessagesCollector(); + } + + if ($this->isCollectorEnabled(LoggerCollector::NAME)) { + $collectors[] = new LoggerCollector(); + } + + if ($this->isCollectorEnabled(ExceptionsCollector::NAME)) { + $collectors[] = new ExceptionsCollector(); + } + + if ($this->isCollectorEnabled(TimeCollector::NAME)) { + $collectors[] = new TimeCollector(); + } + + if ($this->isCollectorEnabled(MemoryCollector::NAME)) { + $collectors[] = new MemoryCollector(); + } + + if ($this->isCollectorEnabled(DatabaseCollector::NAME)) { + $collectors[] = new DatabaseCollector(); + } + + if ($this->isCollectorEnabled(ViewCollector::NAME)) { + $collectors[] = new ViewCollector(); + } + + if ($this->isCollectorEnabled(RouteCollector::NAME)) { + $collectors[] = new RouteCollector(); + } + + if ($this->isCollectorEnabled(CacheCollector::NAME)) { + $collectors[] = new CacheCollector(); + } + + if ($this->isCollectorEnabled(RequestCollector::NAME)) { + $collectors[] = new RequestCollector($request, $this->redactor); + } + + if ($this->isCollectorEnabled(ConfigCollector::NAME)) { + $collectors[] = new ConfigCollector($config, $this->redactor); + } + + if ($this->isCollectorEnabled(SessionCollector::NAME)) { + $collectors[] = new SessionCollector($this->redactor); + } + + return $collectors; + } + + private function resolveEnv(): string + { + $candidates = [ + getenv($this->envVar), + $_ENV[$this->envVar] ?? null, + $_SERVER[$this->envVar] ?? null, + ]; + + foreach ($candidates as $candidate) { + if (is_string($candidate) && '' !== $candidate) { + return $candidate; + } + } + + return ''; + } +} diff --git a/src/DebugBar/Provider.php b/src/DebugBar/Provider.php index 74ec0fa..26aaacc 100644 --- a/src/DebugBar/Provider.php +++ b/src/DebugBar/Provider.php @@ -13,25 +13,9 @@ namespace Phalcon\DebugBar; -use Closure; use Phalcon\Config\ConfigInterface; -use Phalcon\DebugBar\Collector\CacheCollector; -use Phalcon\DebugBar\Collector\ConfigCollector; -use Phalcon\DebugBar\Collector\DatabaseCollector; -use Phalcon\DebugBar\Collector\ExceptionsCollector; use Phalcon\DebugBar\Collector\HistoryCollector; -use Phalcon\DebugBar\Collector\LoggerCollector; -use Phalcon\DebugBar\Collector\MemoryCollector; -use Phalcon\DebugBar\Collector\MessagesCollector; -use Phalcon\DebugBar\Collector\RequestCollector; -use Phalcon\DebugBar\Collector\RouteCollector; -use Phalcon\DebugBar\Collector\SessionCollector; -use Phalcon\DebugBar\Collector\TimeCollector; -use Phalcon\DebugBar\Collector\VersionCollector; -use Phalcon\DebugBar\Collector\ViewCollector; -use Phalcon\DebugBar\Contracts\Collector; use Phalcon\DebugBar\Contracts\History; -use Phalcon\DebugBar\Contracts\Subscriber; use Phalcon\DebugBar\Controllers\HistoryController; use Phalcon\DebugBar\Exceptions\CannotUseInProduction; use Phalcon\DebugBar\History\FilesystemHistory; @@ -39,21 +23,15 @@ use Phalcon\DebugBar\History\HistoryEndpoint; use Phalcon\DebugBar\History\HistoryOptions; use Phalcon\DebugBar\Security\AccessGate; -use Phalcon\DebugBar\Security\Redactor; use Phalcon\Di\DiInterface; use Phalcon\Http\Request; use Phalcon\Http\RequestInterface; use Phalcon\Http\Response; use Phalcon\Mvc\Application; -use function getenv; -use function in_array; -use function is_string; -use function mb_strtolower; - /** - * Boots the debug bar against an MVC application. Its whole coupling to the app - * is: hold the `Application`, reach its EventsManager, and attach listeners. + * Boots the debug bar against an MVC application. It reads the services from the + * `Application`, boots `Core` with them, and attaches the response listener. * When request history is enabled it registers its controller in the DI and * selects it through an application listener, leaving the router unchanged. * @@ -61,31 +39,7 @@ */ class Provider { - /** - * @var (Closure(): bool)|null - */ - private ?Closure $accessCallback; - - /** - * @var list - */ - private array $allowedIps; - - /** - * @var list - */ - private array $blocked; - - /** - * @var array - */ - private array $collectorsConfig; - - private bool $enabled; - - private string $envVar; - - private bool $headers; + private Core $core; private ?HistoryCookie $historyCookie = null; @@ -93,33 +47,15 @@ class Provider private HistoryOptions $historyOptions; - private ?string $nonce; - - private Redactor $redactor; - - private bool $strict; - /** * @param provider_config $config */ public function __construct(private readonly Application $app, array $config = []) { - $env = $config['env'] ?? []; - $assets = $config['assets'] ?? []; - $access = $config['access'] ?? []; - $redact = $config['redact'] ?? []; $history = $config['history'] ?? []; - $this->envVar = $env['var'] ?? 'APP_ENV'; - $this->blocked = $env['blocked'] ?? ['production', 'prod']; - $this->strict = $env['strict'] ?? false; - $this->enabled = $config['enabled'] ?? true; - $this->nonce = $assets['nonce'] ?? null; - $this->allowedIps = $access['allow_ips'] ?? []; - $this->accessCallback = $access['callback'] ?? null; - $this->collectorsConfig = $config['collectors'] ?? []; - $this->headers = $config['headers'] ?? true; - $this->historyOptions = new HistoryOptions( + $this->core = new Core($config); + $this->historyOptions = new HistoryOptions( $history['enabled'] ?? false, $history['url'] ?? '/_debugbar/open', $history['path'] ?? '', @@ -127,77 +63,39 @@ public function __construct(private readonly Application $app, array $config = [ $history['ttl_seconds'] ?? 86400, $history['max_browsers'] ?? 10 ); - $this->redactor = new Redactor( - [...Redactor::DEFAULT_KEYS, ...($redact['mask'] ?? [])], - $redact['hidden'] ?? [] - ); } /** - * Creates the bar with its enabled collectors, points the `Debug` facade at - * it, and - when the app has an EventsManager - subscribes the streamed - * collectors and attaches the response listener. + * Boots `Core` with the services of the application and - when the app has an + * EventsManager - attaches the response listener. * * @throws CannotUseInProduction When `env.strict` is set and the environment is blocked or undefined. */ public function boot(): void { - if (true !== $this->isAllowed()) { - if (true === $this->strict) { - throw new CannotUseInProduction( - 'The debug bar cannot boot: the "' . $this->envVar - . '" environment is undefined or blocked.' - ); - } - - return; - } - - if (true !== $this->enabled) { + if (true !== $this->core->canBoot()) { return; } /** @var DiInterface $container */ - $container = $this->app->getDI(); - $request = $this->resolveRequest($container); - $accessGate = new AccessGate($this->allowedIps, $this->accessCallback); - $history = $this->registerHistory($container, $accessGate); - - $bar = new DebugBar(); - foreach ($this->buildCollectors($container, $request) as $collector) { - $bar->addCollector($collector); - } - if (null !== $history && null !== $this->historyEndpoint) { - $bar->addCollector(new HistoryCollector($this->historyEndpoint)); - } - - Debug::setBar($bar); - + $container = $this->app->getDI(); + $request = $this->resolveRequest($container); + $history = $this->registerHistory($container, $this->core->getAccessGate()); $eventsManager = $this->app->getEventsManager(); - if (null === $eventsManager) { - return; - } + $listener = $this->core->boot( + $eventsManager, + $request, + $this->resolveConfig($container), + $history, + $this->historyEndpoint, + $this->historyCookie + ); - foreach ($bar->getCollectors() as $collector) { - if ($collector instanceof Subscriber) { - $collector->subscribe($eventsManager); - } + if (null === $eventsManager || null === $listener) { + return; } - $eventsManager->attach( - 'application:beforeSendResponse', - new ResponseListener( - $bar, - new Renderer(), - new Injector(), - $accessGate, - $request, - new BarOptions($this->headers, $this->nonce), - $history, - $this->historyEndpoint, - $this->historyCookie - ) - ); + $eventsManager->attach('application:beforeSendResponse', $listener); } /** @@ -205,81 +103,7 @@ public function boot(): void */ public function isAllowed(): bool { - $value = $this->resolveEnv(); - if ('' === $value) { - return false; - } - - return !in_array(mb_strtolower($value), $this->blocked, true); - } - - /** - * Builds the enabled collectors (per the config map; all on by default). - * - * @return list - */ - private function buildCollectors(?DiInterface $container, ?RequestInterface $request): array - { - $collectors = []; - - if ($this->isCollectorEnabled(VersionCollector::NAME)) { - $collectors[] = new VersionCollector(); - } - - if ($this->isCollectorEnabled(MessagesCollector::NAME)) { - $collectors[] = new MessagesCollector(); - } - - if ($this->isCollectorEnabled(LoggerCollector::NAME)) { - $collectors[] = new LoggerCollector(); - } - - if ($this->isCollectorEnabled(ExceptionsCollector::NAME)) { - $collectors[] = new ExceptionsCollector(); - } - - if ($this->isCollectorEnabled(TimeCollector::NAME)) { - $collectors[] = new TimeCollector(); - } - - if ($this->isCollectorEnabled(MemoryCollector::NAME)) { - $collectors[] = new MemoryCollector(); - } - - if ($this->isCollectorEnabled(DatabaseCollector::NAME)) { - $collectors[] = new DatabaseCollector(); - } - - if ($this->isCollectorEnabled(ViewCollector::NAME)) { - $collectors[] = new ViewCollector(); - } - - if ($this->isCollectorEnabled(RouteCollector::NAME)) { - $collectors[] = new RouteCollector(); - } - - if ($this->isCollectorEnabled(CacheCollector::NAME)) { - $collectors[] = new CacheCollector(); - } - - if ($this->isCollectorEnabled(RequestCollector::NAME)) { - $collectors[] = new RequestCollector($request, $this->redactor); - } - - if (null !== $container && $this->isCollectorEnabled(ConfigCollector::NAME)) { - $collectors[] = new ConfigCollector($this->resolveConfig($container), $this->redactor); - } - - if ($this->isCollectorEnabled(SessionCollector::NAME)) { - $collectors[] = new SessionCollector($this->redactor); - } - - return $collectors; - } - - private function isCollectorEnabled(string $name): bool - { - return $this->collectorsConfig[$name] ?? true; + return $this->core->isAllowed(); } /** @@ -291,7 +115,7 @@ private function registerHistory( ): ?History { if ( !$this->historyOptions->enabled - || !$this->isCollectorEnabled(HistoryCollector::NAME) + || !$this->core->isCollectorEnabled(HistoryCollector::NAME) ) { return null; } @@ -339,23 +163,6 @@ private function resolveConfig(DiInterface $container): ?ConfigInterface return $config instanceof ConfigInterface ? $config : null; } - private function resolveEnv(): string - { - $candidates = [ - getenv($this->envVar), - $_ENV[$this->envVar] ?? null, - $_SERVER[$this->envVar] ?? null, - ]; - - foreach ($candidates as $candidate) { - if (is_string($candidate) && '' !== $candidate) { - return $candidate; - } - } - - return ''; - } - private function resolveRequest(?DiInterface $container): ?RequestInterface { if (null === $container || !$container->has('request')) { diff --git a/tests/Unit/DebugBar/CoreTest.php b/tests/Unit/DebugBar/CoreTest.php new file mode 100644 index 0000000..cd6af42 --- /dev/null +++ b/tests/Unit/DebugBar/CoreTest.php @@ -0,0 +1,402 @@ + + * + * 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; + +use Phalcon\Config\Config; +use Phalcon\DebugBar\Contracts\History; +use Phalcon\DebugBar\Core; +use Phalcon\DebugBar\Debug; +use Phalcon\DebugBar\DebugBar; +use Phalcon\DebugBar\Exceptions\CannotUseInProduction; +use Phalcon\DebugBar\History\HistoryCookie; +use Phalcon\DebugBar\History\HistoryEndpoint; +use Phalcon\DebugBar\History\HistoryOptions; +use Phalcon\Di\Di; +use Phalcon\Events\Manager; +use Phalcon\Http\Request; +use Phalcon\Http\RequestInterface; +use Phalcon\Http\Response; +use Phalcon\Talon\PHPUnit\AbstractUnitTestCase; +use PHPUnit\Framework\Attributes\BackupGlobals; + +use function array_keys; +use function is_array; +use function sys_get_temp_dir; + +#[BackupGlobals(true)] +final class CoreTest extends AbstractUnitTestCase +{ + private const ENV_VAR = 'DEBUGBAR_TEST_ENV'; + + private const HTML = 'hi'; + + protected function setUp(): void + { + parent::setUp(); + + Debug::setBar(null); + } + + protected function tearDown(): void + { + Debug::setBar(null); + + parent::tearDown(); + } + + public function testAccessGateFollowsTheConfig(): void + { + $core = $this->core(['access' => ['allow_ips' => ['203.0.113.1']]]); + + $this->assertTrue($core->getAccessGate()->allows('203.0.113.1')); + $this->assertFalse($core->getAccessGate()->allows('203.0.113.9')); + } + + public function testAccessGateStopsInjection(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + $core = $this->core(['access' => ['allow_ips' => ['203.0.113.1']]]); + $core->boot(null, $this->request('203.0.113.9', '/orders')); + $response = $this->response(); + + $core->handle($response); + + $this->assertSame(self::HTML, $response->getContent()); + } + + public function testBlockedEnvironmentBootsNothing(): void + { + $_ENV[self::ENV_VAR] = 'production'; + $core = $this->core(); + $response = $this->response(); + + $this->assertFalse($core->canBoot()); + $this->assertNull($core->boot()); + $this->assertNull(Debug::getBar()); + + $core->handle($response); + + $this->assertSame(self::HTML, $response->getContent()); + } + + public function testBootInjectsWithNoFrameworkObjects(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + $core = $this->core(); + $response = $this->response(); + + $this->assertTrue($core->canBoot()); + + $core->boot(); + $core->handle($response); + + $this->assertStringContainsString('phalcon-debugbar-data', $response->getContent()); + $this->assertInstanceOf(DebugBar::class, Debug::getBar()); + } + + public function testBootReturnsAListenerForAnyResponseEvent(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + $manager = new Manager(); + $listener = $this->core()->boot($manager); + if (null === $listener) { + $this->fail('Expected a listener from the boot.'); + } + + $manager->attach('application:afterHandle', $listener); + + $response = $this->response(); + $manager->fire('application:afterHandle', $this, $response); + + $this->assertStringContainsString('phalcon-debugbar-data', $response->getContent()); + } + + public function testBootSubscribesCollectorsToTheEventsManager(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + $manager = new Manager(); + + $this->core()->boot($manager); + $manager->fire('cache:afterGet', $this, 'cache-key-42'); + + $this->assertSame(1, $this->bootedBar()->collect()['data']['cache']['badge']); + } + + public function testConfigSourceReachesTheCollectorRedacted(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + $config = new Config([ + 'section' => [ + 'password' => 'sekret', + 'maskme' => 'mask-value', + 'dropme' => 'drop-value', + 'plain' => 'keepme', + ], + ]); + + $this->core(['redact' => ['mask' => ['maskme'], 'hidden' => ['dropme']]]) + ->boot(null, null, $config); + + $panel = $this->bootedBar()->collect()['data']['config']['panel']; + if (!is_array($panel)) { + $this->fail('The config panel should be an array.'); + } + + $this->assertSame('keepme', $panel['section.plain']); + $this->assertSame('***', $panel['section.password']); + $this->assertSame('***', $panel['section.maskme']); + $this->assertArrayNotHasKey('section.dropme', $panel); + } + + public function testDefaultBootRegistersEveryCollectorInOrder(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + + $this->core()->boot(); + + $this->assertSame( + [ + 'version', + 'messages', + 'logger', + 'exceptions', + 'time', + 'memory', + 'database', + 'view', + 'route', + 'cache', + 'request', + 'config', + 'session', + ], + array_keys($this->bootedBar()->getCollectors()) + ); + } + + public function testDisabledBootsNothing(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + $core = $this->core(['enabled' => false]); + + $this->assertFalse($core->canBoot()); + $this->assertNull($core->boot()); + $this->assertNull(Debug::getBar()); + } + + public function testDisabledCollectorIsNotCreated(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + $core = $this->core(['collectors' => ['version' => false]]); + + $core->boot(); + + $this->assertFalse($core->isCollectorEnabled('version')); + $this->assertTrue($core->isCollectorEnabled('session')); + $this->assertFalse($this->bootedBar()->hasCollector('version')); + $this->assertTrue($this->bootedBar()->hasCollector('session')); + } + + public function testEnvironmentFollowsTheConfig(): void + { + $core = new Core(['env' => ['var' => self::ENV_VAR, 'blocked' => ['staging']]]); + + $this->assertFalse($core->isAllowed()); + + $_ENV[self::ENV_VAR] = 'staging'; + $this->assertFalse($core->isAllowed()); + + $_ENV[self::ENV_VAR] = 'production'; + $this->assertTrue($core->isAllowed()); + } + + public function testHandleBeforeBootDoesNothing(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + $response = $this->response(); + + $this->core()->handle($response); + + $this->assertSame(self::HTML, $response->getContent()); + } + + public function testHeadersOptionReachesTheListener(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + + $core = $this->core(['headers' => false]); + $core->boot(); + $off = $this->response(); + $core->handle($off); + + $core = $this->core(); + $core->boot(); + $on = $this->response(); + $core->handle($on); + + $this->assertFalse($off->getHeaders()->has('X-Debug-Bar')); + $this->assertTrue($on->getHeaders()->has('X-Debug-Bar')); + } + + public function testHistoryEndpointRequestIsNotInjected(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + $request = $this->request('127.0.0.1', '/_debugbar/open'); + $core = $this->core(); + $response = $this->response(); + + $core->boot(null, $request, null, null, $this->endpoint($request)); + $core->handle($response); + + $this->assertSame(self::HTML, $response->getContent()); + } + + public function testHistoryPartsAddTheCollectorAndRecordTheRequest(): void + { + unset($_COOKIE[HistoryCookie::NAME]); + $_ENV[self::ENV_VAR] = 'dev'; + $request = $this->request('127.0.0.1', '/orders'); + $history = $this->createMock(History::class); + $history->expects($this->once())->method('save'); + $queued = false; + $cookie = new HistoryCookie( + null, + function () use (&$queued): bool { + $queued = true; + + return true; + } + ); + $core = $this->core(); + + $core->boot(null, $request, null, $history, $this->endpoint($request), $cookie); + $core->handle($this->response()); + + $this->assertTrue($this->bootedBar()->hasCollector('history')); + $this->assertTrue($queued); + } + + public function testNonceOptionReachesTheListener(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + $core = $this->core(['assets' => ['nonce' => 'nonce-xyz-123']]); + $response = $this->response(); + + $core->boot(); + $core->handle($response); + + $this->assertStringContainsString('nonce="nonce-xyz-123"', $response->getContent()); + } + + public function testNoSourcesLeaveTheirPanelsEmpty(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + + $this->core()->boot(); + + $data = $this->bootedBar()->collect()['data']; + + $this->assertSame([], $data['config']['panel']); + $this->assertSame([], $data['request']['panel']); + } + + public function testRequestReachesTheCollector(): void + { + $_ENV[self::ENV_VAR] = 'dev'; + + $this->core()->boot(null, new Request()); + + $panel = $this->bootedBar()->collect()['data']['request']['panel']; + if (!is_array($panel)) { + $this->fail('The request panel should be an array.'); + } + + $this->assertArrayHasKey('Method', $panel); + $this->assertArrayHasKey('URI', $panel); + } + + public function testStrictBlockedEnvironmentThrows(): void + { + $_ENV[self::ENV_VAR] = 'production'; + $core = new Core(['env' => ['var' => self::ENV_VAR, 'strict' => true]]); + $message = ''; + + try { + $core->boot(); + } catch (CannotUseInProduction $exception) { + $message = $exception->getMessage(); + } + + $this->assertSame( + 'The debug bar cannot boot: the "' . self::ENV_VAR + . '" environment is undefined or blocked.', + $message + ); + $this->assertNull(Debug::getBar()); + } + + private function bootedBar(): DebugBar + { + $bar = Debug::getBar(); + if (!$bar instanceof DebugBar) { + $this->fail('Expected the debug bar to be booted.'); + } + + return $bar; + } + + /** + * @param array{ + * enabled?: bool, + * assets?: array{nonce?: string|null}, + * access?: array{allow_ips?: list}, + * collectors?: array, + * headers?: bool, + * redact?: array{mask?: list, hidden?: list} + * } $config + */ + private function core(array $config = []): Core + { + return new Core(['env' => ['var' => self::ENV_VAR]] + $config); + } + + private function endpoint(RequestInterface $request): HistoryEndpoint + { + $container = new Di(); + $container->setShared('request', $request); + + return new HistoryEndpoint( + new HistoryOptions(true, '/_debugbar/open', sys_get_temp_dir() . '/unused'), + $container + ); + } + + private function request(string $clientIp, string $uri): RequestInterface + { + $request = $this->createMock(RequestInterface::class); + $request->method('getClientAddress')->willReturn($clientIp); + $request->method('getURI')->willReturn($uri); + $request->method('getMethod')->willReturn('GET'); + + return $request; + } + + private function response(): Response + { + $response = new Response(); + $response->setContent(self::HTML); + + return $response; + } +}