From 7713e62c8d75562c50bb242556a72b5835b43028 Mon Sep 17 00:00:00 2001 From: bidi Date: Thu, 12 Nov 2026 00:01:05 +0200 Subject: [PATCH 1/2] code refactor, updated composer, docs, workflow, tests Signed-off-by: bidi --- .github/workflows/codecov.yml | 6 +- .github/workflows/static-analysis.yml | 10 +- .gitignore | 1 + .laminas-ci.json | 5 +- CHANGELOG.md | 652 ++++++++++++++++-- README.md | 24 +- SECURITY.md | 17 +- composer.json | 30 +- config/error-handling.global.php.dist | 4 + docs/book/v6/configuration.md | 80 +++ docs/book/v6/extra/cookie.md | 124 ++++ docs/book/v6/extra/header.md | 152 ++++ docs/book/v6/extra/introduction.md | 43 ++ docs/book/v6/extra/request.md | 130 ++++ docs/book/v6/extra/server.md | 163 +++++ docs/book/v6/extra/session.md | 116 ++++ docs/book/v6/extra/trace.md | 98 +++ docs/book/v6/installation.md | 24 + docs/book/v6/log-files.md | 30 + docs/book/v6/overview.md | 40 ++ docs/book/v6/upgrading.md | 66 ++ mkdocs.yml | 19 +- phpstan.neon | 2 +- phpunit.xml | 17 +- src/ConfigProvider.php | 8 + src/ErrorHandler.php | 21 +- src/ErrorHandlerFactory.php | 25 +- src/ErrorHandlerInterface.php | 2 +- src/Extra/ExtraProvider.php | 75 +- src/Extra/Processor/AbstractProcessor.php | 47 +- src/Extra/Processor/CookieProcessor.php | 6 +- src/Extra/Processor/HeaderProcessor.php | 16 +- src/Extra/Processor/ProcessorInterface.php | 7 + src/Extra/Processor/RequestProcessor.php | 9 +- src/Extra/Processor/ServerProcessor.php | 34 +- src/Extra/Processor/SessionProcessor.php | 7 +- src/Extra/Processor/TraceProcessor.php | 6 +- src/Extra/Provider/AbstractProvider.php | 4 + src/Extra/Provider/CookieProvider.php | 4 + src/Extra/Provider/HeaderProvider.php | 4 + src/Extra/Provider/ProviderInterface.php | 4 + src/Extra/Provider/RequestProvider.php | 4 + src/Extra/Provider/ServerProvider.php | 4 + src/Extra/Provider/SessionProvider.php | 4 + src/Extra/Provider/TraceProvider.php | 4 + src/LogErrorHandler.php | 121 +--- src/LogErrorHandlerFactory.php | 76 +- test/ConfigProviderTest.php | 1 + test/DistConfigTest.php | 38 + test/ErrorHandlerFactoryTest.php | 112 ++- test/ErrorHandlerTest.php | 166 ++--- test/Extra/ExtraProviderTest.php | 93 +++ test/Extra/Processor/CookieProcessorTest.php | 53 ++ test/Extra/Processor/HeaderProcessorTest.php | 39 ++ test/Extra/Processor/RequestProcessorTest.php | 73 ++ test/Extra/Processor/ServerProcessorTest.php | 66 ++ test/Extra/Processor/SessionProcessorTest.php | 25 + test/Fixture/InMemoryContainer.php | 38 + test/Fixture/ResponseHandler.php | 25 + test/Fixture/ServiceNotFoundException.php | 12 + test/Fixture/TestLogger.php | 29 + test/Fixture/ThrowingHandler.php | 26 + test/Fixture/ThrowingLogger.php | 23 + test/Fixture/ThrowingProcessor.php | 22 + test/LogErrorHandlerFactoryTest.php | 363 +++++----- test/LogErrorHandlerTest.php | 317 ++++----- 66 files changed, 3166 insertions(+), 700 deletions(-) create mode 100644 docs/book/v6/configuration.md create mode 100644 docs/book/v6/extra/cookie.md create mode 100644 docs/book/v6/extra/header.md create mode 100644 docs/book/v6/extra/introduction.md create mode 100644 docs/book/v6/extra/request.md create mode 100644 docs/book/v6/extra/server.md create mode 100644 docs/book/v6/extra/session.md create mode 100644 docs/book/v6/extra/trace.md create mode 100644 docs/book/v6/installation.md create mode 100644 docs/book/v6/log-files.md create mode 100644 docs/book/v6/overview.md create mode 100644 docs/book/v6/upgrading.md create mode 100644 test/DistConfigTest.php create mode 100644 test/Extra/Processor/CookieProcessorTest.php create mode 100644 test/Extra/Processor/HeaderProcessorTest.php create mode 100644 test/Extra/Processor/RequestProcessorTest.php create mode 100644 test/Extra/Processor/ServerProcessorTest.php create mode 100644 test/Extra/Processor/SessionProcessorTest.php create mode 100644 test/Fixture/InMemoryContainer.php create mode 100644 test/Fixture/ResponseHandler.php create mode 100644 test/Fixture/ServiceNotFoundException.php create mode 100644 test/Fixture/TestLogger.php create mode 100644 test/Fixture/ThrowingHandler.php create mode 100644 test/Fixture/ThrowingLogger.php create mode 100644 test/Fixture/ThrowingProcessor.php diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 912df7a..b246e58 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -15,10 +15,10 @@ jobs: - ubuntu-latest php: - - "8.2" - "8.3" - "8.4" - "8.5" + - "8.6" steps: - name: Checkout @@ -43,8 +43,10 @@ jobs: restore-keys: | php${{ matrix.php }}-composer- + # dot-log, laminas-servicemanager, laminas-validator and laminas-stratigility do not declare PHP 8.6 yet. + # Remove the conditional flag once they do. - name: Install dependencies with composer - run: composer install --prefer-dist --no-interaction --no-progress --optimize-autoloader --ansi + run: composer install --prefer-dist --no-interaction --no-progress --optimize-autoloader --ansi ${{ matrix.php == '8.6' && '--ignore-platform-req=php+' || '' }} - name: Collect code coverage with PHPUnit run: vendor/bin/phpunit --colors=always --coverage-clover clover.xml diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index ac29fc6..f11d1f8 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -4,7 +4,7 @@ on: name: Run PHPStan checks jobs: - mutation: + phpstan: name: PHPStan ${{ matrix.php }}-${{ matrix.os }} runs-on: ${{ matrix.os }} @@ -15,10 +15,10 @@ jobs: - ubuntu-latest php: - - "8.2" - "8.3" - "8.4" - "8.5" + - "8.6" steps: - name: Checkout @@ -28,7 +28,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: "${{ matrix.php }}" - coverage: pcov + coverage: none ini-values: assert.exception=1, zend.assertions=1, error_reporting=-1, log_errors_max_len=0, display_errors=On tools: composer:v2, cs2pr @@ -43,8 +43,10 @@ jobs: restore-keys: | php${{ matrix.php }}-composer- + # dot-log, laminas-servicemanager, laminas-validator and laminas-stratigility do not declare PHP 8.6 yet. + # Remove the conditional flag once they do. - name: Install dependencies with composer - run: composer install --prefer-dist --no-interaction --no-progress --optimize-autoloader --ansi + run: composer install --prefer-dist --no-interaction --no-progress --optimize-autoloader --ansi ${{ matrix.php == '8.6' && '--ignore-platform-req=php+' || '' }} - name: Run static analysis with PHPStan run: vendor/bin/phpstan analyse diff --git a/.gitignore b/.gitignore index d0c6610..e4fd7b9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ clover.xml coveralls-upload.json +.phpunit.cache .phpunit.result.cache .phpcs-cache config/log.global.php diff --git a/.laminas-ci.json b/.laminas-ci.json index 29216fa..efdd72e 100644 --- a/.laminas-ci.json +++ b/.laminas-ci.json @@ -1,3 +1,6 @@ { - "backwardCompatibilityCheck": true + "ignore_php_platform_requirements": { + "8.6": true + }, + "backwardCompatibilityCheck": false } diff --git a/CHANGELOG.md b/CHANGELOG.md index be8cbce..41eef31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,139 +1,707 @@ +# Changelog + +## 6.0.0 - TBD + +### Changed + +- Requires PHP 8.3, 8.4, 8.5 or 8.6 +- Depends on `psr/log`, `psr/container` and `psr/http-server-handler` instead of `dotkernel/dot-log`, `laminas/laminas-diactoros` and `mezzio/mezzio`, which are now suggested +- `laminas/laminas-stratigility` requirement narrowed to `^4.3.1` +- `LogErrorHandler` extends `ErrorHandler` +- `LogErrorHandlerFactory::__invoke()` returns `LogErrorHandler` +- Extra providers are enabled only by `'enabled' => true` +- `loggerEnabled` must be a boolean; a missing key means `false` +- Factories validate the logger, response factory and error response generator services and throw `InvalidArgumentException` +- Any callable registered as `Mezzio\Middleware\ErrorResponseGenerator` is accepted +- Masking counts characters instead of bytes +- Logging failures are written to PHP's `error_log()` instead of breaking the error response +- `config/error-handling.global.php.dist` masks `authorization` and `proxy-authorization` headers +- PHPUnit 12, PHPStan level 8, StructArmed architecture check in `composer check` + +### Added + +- `ext-mbstring` requirement +- v6 documentation, including an upgrade guide + +### Deprecated + +- Nothing + +### Removed + +- PHP 8.2 support +- `dotkernel/dot-log`, `laminas/laminas-diactoros` and `mezzio/mezzio` as runtime requirements + +### Fixed + +- Error handling no longer throws when the logger or an extra processor fails +- PHP error handler is restored when error handling throws +- `RequestProcessor` handles list arrays, integer keys and non-string values +- Numeric cookie, header and server names no longer throw +- `ServerProcessor` handles array values such as `argv` +- `SessionProcessor` handles scalar session values +- `Cookie` header and `HTTP_COOKIE` values are masked regardless of case and separator spacing +- Partial masking keeps strings valid UTF-8, so the log record is no longer dropped +- Invalid extra provider configuration is ignored instead of throwing +- `LogErrorHandlerFactory` no longer warns when `loggerEnabled` is missing +- Service checks no longer depend on `zend.assertions` + +## 5.0.0 - 2026-07-17 + +### Changed + +- Requires `dotkernel/dot-log ^5.3`, `mezzio/mezzio ^3.28.1` and `laminas/laminas-stratigility ^3.0 || ^4.3.1` + +### Added + +- v5 documentation (#69) + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 4.4.2 - 2025-11-13 + +### Changed + +- README, SECURITY and documentation updates + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 4.4.1 - 2025-11-06 + +### Changed + +- Allow installing with `laminas/laminas-stratigility` 3.x or 4.x (#61) + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 4.4.0 - 2025-10-30 + +### Changed + +- Nothing + +### Added + +- PHP 8.4 and 8.5 support (#56) + +### Deprecated + +- Nothing + +### Removed + +- PHP 8.1 support (#56) + +### Fixed + +- Nothing + +## 4.3.2 - 2025-10-14 + +### Changed + +- `log-files.md` documentation update + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 4.3.1 - 2025-10-14 + +### Changed + +- Nothing + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Zend Expressive references (#52) + +### Fixed + +- Nothing + +## 4.3.0 - 2025-03-04 + +### Changed + +- Psalm replaced with PHPStan (#50) + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 4.2.1 - 2025-02-28 + +### Changed + +- Nothing + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Server value replacement error caused by non-string values (#48) + +## 4.2.0 - 2025-02-24 + +### Changed + +- Dependency updates + +### Added + +- Control of the `extra` log contents through extra providers and processors (#43) + +### Deprecated + +- Nothing + +### Removed + +- Duplicate `MiddlewareInterface` from `ErrorHandler` + +### Fixed + +- Nothing + +## 4.1.1 - 2025-01-28 + +### Changed + +- Psalm 6.x (#41) + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 4.1.0 - 2025-01-17 + +### Changed + +- Nothing + +### Added + +- Support for `laminas/laminas-servicemanager` 4.x (#39) + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 4.0.2 - 2025-11-06 + +### Changed + +- Allow installing with `laminas/laminas-stratigility` 3.x or 4.x (#60) + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 4.0.1 - 2025-10-30 + +### Changed + +- Nothing + +### Added + +- PHP 8.4 and 8.5 support (#55) + +### Deprecated + +- Nothing + +### Removed + +- PHP 8.1 support (#55) + +### Fixed + +- Nothing + +## 4.0.0 - 2024-08-30 + +### Changed + +- Upgraded `dotkernel/dot-log` to 4.x (#36) + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 3.4.1 - 2024-05-21 + +### Changed + +- Dependency updates + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 3.4.0 - 2024-05-03 + +### Changed + +- Workflow updates + +### Added + +- Documentation + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + ## 3.3.3 - 2024-02-28 ### Changed -* Updated dependency versions + +- Updated dependency versions + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 3.3.2 - 2023-12-06 + +### Changed + +- Dependency updates (#27) + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 3.3.1 - 2023-08-16 + +### Changed + +- `psr/http-message ^1.0 || ^2.0` (#25) + +### Added + +- Codecov workflow and badge + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 3.3.0 - 2023-07-19 + +### Changed + +- Code quality improvements (#13) ### Added -* Nothing + +- GitHub Actions workflows ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Nothing + +- Nothing + +## 3.2.1 - 2022-10-28 + +### Changed + +- Dependency updates + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 3.2.0 - 2022-03-22 + +### Changed + +- Dependency and badge updates + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 3.1.1 - 2021-04-26 + +### Changed + +- Nothing + +### Added + +- Date-based custom log file names (#3) + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing ## 3.1.0 - 2020-11-02 ### Changed -* Composer 2 compatibility. -* Update composer.json to require version 2 of laminas/laminas-dependency-plugin to be Composer 2 compatible. + +- Composer 2 compatibility. +- Update composer.json to require version 2 of laminas/laminas-dependency-plugin to be Composer 2 compatible. ### Added -* Nothing + +- Nothing ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Nothing + +- Nothing ## 3.0.2 - 2020-04-23 ### Changed -* Nothing + +- Nothing ### Added -* Keywords in composer.json -* Requirement for PHP v7.4 or higher + +- Keywords in composer.json +- Requirement for PHP v7.4 or higher ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Nothing + +- Nothing ## 3.0.1 - 2020-04-22 ### Changed -* Updated 3rd party dependencies to latest version -* Updated License -* Updated .gitignore + +- Updated 3rd party dependencies to latest version +- Updated License +- Updated .gitignore ### Added -* Nothing + +- Nothing ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Nothing + +- Nothing ## 3.0.0 - 2020-04-09 ### Changed -* Nothing + +- Nothing ### Added -* Missing dependency + +- Missing dependency ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Nothing + +- Nothing ## 2.0.0 - 2020-01-30 ### Changed -* Laminas and Mezzio migration. + +- Laminas and Mezzio migration. ### Added -* Nothing + +- Nothing ### Deprecated -* Nothing + +- Nothing ### Removed -* Support + +- Support ### Fixed -* Nothing +- Nothing ## 1.0.1 - 2018-11-27 Corrected factory and config provider. ### Added -* Factory for plain `ErrorHandler` + +- Factory for plain `ErrorHandler` ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Nothing +- Nothing ## 1.0.0 - 2018-11-14 Initial release ### Added -* Everything + +- Everything ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Nothing + +- Nothing diff --git a/README.md b/README.md index eaeb466..fac6226 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ # dot-errorhandler -dot-errorhandler is Dotkernel's PSR-15 compliant error handler. +dot-errorhandler is Dotkernel's PSR-15 compliant error handler, with optional logging through any PSR-3 logger. ## Version History | Branch | Release | PSR-11 | Log Style Implementation | OSS Lifecycle | PHP Version | |--------|----------|--------|--------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------| +| 6.0 | `>= 6.0` | 1, 2 | PSR-Log (any logger) | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-errorhandler%2Fblob%2F6.0%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-errorhandler/6.0.0) | | 5.0 | `>= 5.0` | 2 | PSR-Log | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-errorhandler%2Fblob%2F5.0%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-errorhandler/5.0.0) | | 4.1 | `>= 4.2` | 2 | PSR-Log | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-errorhandler%2Fblob%2F4.1%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-errorhandler/4.4.1) | | 4.1 | `< 4.2` | 2 | Laminas Log | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-errorhandler%2Fblob%2F4.1%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-errorhandler/4.1.1) | @@ -19,16 +20,16 @@ Documentation is available at: https://docs.dotkernel.org/dot-errorhandler/ ## Badges ![OSS Lifecycle](https://img.shields.io/osslifecycle/dotkernel/dot-errorhandler) -![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-errorhandler/5.0.0) +![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-errorhandler/6.0.0) [![GitHub issues](https://img.shields.io/github/issues/dotkernel/dot-errorhandler)](https://github.com/dotkernel/dot-errorhandler/issues) [![GitHub forks](https://img.shields.io/github/forks/dotkernel/dot-errorhandler)](https://github.com/dotkernel/dot-errorhandler/network) [![GitHub stars](https://img.shields.io/github/stars/dotkernel/dot-errorhandler)](https://github.com/dotkernel/dot-errorhandler/stargazers) -[![GitHub license](https://img.shields.io/github/license/dotkernel/dot-errorhandler)](https://github.com/dotkernel/dot-errorhandler/blob/5.0/LICENSE) +[![GitHub license](https://img.shields.io/github/license/dotkernel/dot-errorhandler)](https://github.com/dotkernel/dot-errorhandler/blob/6.0/LICENSE) -[![Build Static](https://github.com/dotkernel/dot-errorhandler/actions/workflows/continuous-integration.yml/badge.svg?branch=5.0)](https://github.com/dotkernel/dot-errorhandler/actions/workflows/continuous-integration.yml) -[![codecov](https://codecov.io/gh/dotkernel/dot-errorhandler/branch/5.0/graph/badge.svg?token=0KIJARS5RS)](https://codecov.io/gh/dotkernel/dot-errorhandler) -[![PHPStan](https://github.com/dotkernel/dot-errorhandler/actions/workflows/static-analysis.yml/badge.svg?branch=5.0)](https://github.com/dotkernel/dot-errorhandler/actions/workflows/static-analysis.yml) +[![Build Static](https://github.com/dotkernel/dot-errorhandler/actions/workflows/continuous-integration.yml/badge.svg?branch=6.0)](https://github.com/dotkernel/dot-errorhandler/actions/workflows/continuous-integration.yml) +[![codecov](https://codecov.io/gh/dotkernel/dot-errorhandler/branch/6.0/graph/badge.svg?token=0KIJARS5RS)](https://codecov.io/gh/dotkernel/dot-errorhandler) +[![PHPStan](https://github.com/dotkernel/dot-errorhandler/actions/workflows/static-analysis.yml/badge.svg?branch=6.0)](https://github.com/dotkernel/dot-errorhandler/actions/workflows/static-analysis.yml) ## Adding the error handler @@ -38,6 +39,12 @@ Documentation is available at: https://docs.dotkernel.org/dot-errorhandler/ composer require dotkernel/dot-errorhandler ``` +- If you use the logger from `config/log.global.php.dist`, also add `dotkernel/dot-log`; since version 6 it is no longer installed with this package: + +```shell +composer require dotkernel/dot-log +``` + - Add the config provider: - in `config/config.php` add `\Dot\ErrorHandler\ConfigProvider` - in `config/pipeline.php` add `\Dot\ErrorHandler\ErrorHandlerInterface::class` @@ -69,7 +76,12 @@ return [ ]; ``` +`loggerEnabled` must be `true` or `false`, and `logger` must name a container service that implements `Psr\Log\LoggerInterface`. + A configuration example for the default logger can be found in `config/log.global.php.dist`. +`config/error-handling.global.php.dist` shows every option, including the extra request data you can log; its header settings mask `authorization` and `proxy-authorization` by default. + +Upgrading from an earlier version? See the [upgrade guide](https://docs.dotkernel.org/dot-errorhandler/v6/upgrading/). When configuring the error handler in your application, you can choose between two classes: diff --git a/SECURITY.md b/SECURITY.md index 30ac4d8..dcef0c9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,14 +2,15 @@ ## Supported Versions -| Version | Supported | PHP Version | -|---------|---------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------| -| 5.0 | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-errorhandler%2Fblob%2F5.0%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-errorhandler/5.0.0) | -| 4.1 | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-errorhandler%2Fblob%2F4.1%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-errorhandler/4.4.1) | -| 4.1 | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-errorhandler%2Fblob%2F4.1%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-errorhandler/4.1.1) | -| 4.0 | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-errorhandler%2Fblob%2F4.0%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-errorhandler/4.0.2) | -| 3.x | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-errorhandler%2Fblob%2F3.0%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-errorhandler/3.4.1) | -| <= 2.x | :x: | | +| Version | Supported | PHP Version | +|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------| +| 6.0 | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-errorhandler%2Fblob%2F6.0%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-errorhandler/6.0.0) | +| 5.0 | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-errorhandler%2Fblob%2F5.0%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-errorhandler/5.0.0) | +| 4.1 (>= 4.2) | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-errorhandler%2Fblob%2F4.1%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-errorhandler/4.4.1) | +| 4.1 (< 4.2) | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-errorhandler%2Fblob%2F4.1%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-errorhandler/4.1.1) | +| 4.0 | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-errorhandler%2Fblob%2F4.0%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-errorhandler/4.0.2) | +| 3.x | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-errorhandler%2Fblob%2F3.0%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-errorhandler/3.4.1) | +| <= 2.x | :x: | | ## Reporting Potential Security Issues diff --git a/composer.json b/composer.json index 84c5fbf..fa9f715 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "dotkernel/dot-errorhandler", "type": "library", - "description": "Logging Error Handler for Middleware Applications", + "description": "PSR-15 error handler middleware with optional PSR-3 logging", "license": "MIT", "homepage": "https://github.com/dotkernel/dot-errorhandler", "authors": [ @@ -22,22 +22,30 @@ } }, "require": { - "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", - "dotkernel/dot-log": "^5.3", - "laminas/laminas-diactoros": "^3.8.0", + "php": "~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0", + "ext-mbstring": "*", "laminas/laminas-stdlib": "^3.21", - "laminas/laminas-stratigility": "^3.0 || ^4.3.1", - "mezzio/mezzio": "^3.28.1", + "laminas/laminas-stratigility": "^4.3.1", + "psr/container": "^1.1 || ^2.0", "psr/http-message": "^1.0 || ^2.0", - "psr/http-server-middleware": "^1.0.2" + "psr/http-server-handler": "^1.0.2", + "psr/http-server-middleware": "^1.0.2", + "psr/log": "^2.0 || ^3.0" }, "require-dev": { "boundwize/structarmed": "^0.18.10", + "dotkernel/dot-log": "^5.3", "laminas/laminas-coding-standard": "^3.1.0", + "laminas/laminas-diactoros": "^3.8.0", + "mezzio/mezzio": "^3.28.1", "mikey179/vfsstream": "^1.6.12", "phpstan/phpstan": "^2.2.5", "phpstan/phpstan-phpunit": "^2.0.18", - "phpunit/phpunit": "^10.5.64" + "phpunit/phpunit": "^12.5" + }, + "suggest": { + "dotkernel/dot-log": "PSR-3 logger that the shipped config/log.global.php.dist configures", + "mezzio/mezzio": "Its ErrorResponseGenerator is used automatically when registered in the container" }, "autoload": { "psr-4": { @@ -54,11 +62,13 @@ "check": [ "@cs-check", "@test", - "@static-analysis" + "@static-analysis", + "@arch" ], "cs-check": "phpcs", "cs-fix": "phpcbf", "test": "phpunit --colors=always", - "static-analysis": "phpstan analyse --memory-limit 1G" + "static-analysis": "phpstan analyse --memory-limit 1G", + "arch": "structarmed analyse --no-progress" } } diff --git a/config/error-handling.global.php.dist b/config/error-handling.global.php.dist index ac7908a..1dda657 100644 --- a/config/error-handling.global.php.dist +++ b/config/error-handling.global.php.dist @@ -45,6 +45,10 @@ return [ 'processor' => [ 'class' => HeaderProcessor::class, 'replacementStrategy' => ReplacementStrategy::Full, + 'sensitiveParameters' => [ + 'authorization', + 'proxy-authorization', + ], ], ], RequestProvider::class => [ diff --git a/docs/book/v6/configuration.md b/docs/book/v6/configuration.md new file mode 100644 index 0000000..5e6725b --- /dev/null +++ b/docs/book/v6/configuration.md @@ -0,0 +1,80 @@ +# Configuration + +Register `dot-errorhandler` in your project by adding `Dot\ErrorHandler\ConfigProvider::class` to your configuration aggregator (to `config/config.php` for example), and add `\Dot\ErrorHandler\ErrorHandlerInterface::class` (to `config/pipeline.php` for example) **as the outermost layer of the middleware** to catch all exceptions. + +Configure the error handler as shown below. + +In **config/autoload/error-handling.global.php**: + +```php + [ + 'aliases' => [ + ErrorHandlerInterface::class => LogErrorHandler::class, + ], + ], + 'dot-errorhandler' => [ + 'loggerEnabled' => true, + 'logger' => 'dot-log.default_logger', + ], +]; +``` + +The `dot-errorhandler` keys are: + +- **loggerEnabled**: `true` or `false`. A missing key means `false`. Any other value, such as the string `'true'` read from an environment variable, is rejected, so that logging is never switched off by accident. +- **logger**: the name of a container service that implements `Psr\Log\LoggerInterface`. Required when **loggerEnabled** is `true`. +- **extraProvider**: optional extra request data to log; see [Extra data](extra/introduction.md). + +A configuration example for the default logger, which uses `dotkernel/dot-log`, can be found in `config/log.global.php.dist`. +Any other PSR-3 logger works too: register it in your container and put its service name in **logger**. + +`LogErrorHandlerFactory` checks this configuration when the container builds the error handler. +It throws `InvalidArgumentException` with a descriptive message when: + +- **loggerEnabled** is not a boolean +- **loggerEnabled** is `true` but **logger** is missing or not a string +- the **logger** service does not implement `Psr\Log\LoggerInterface` +- the `Psr\Http\Message\ResponseInterface` service is not a callable response factory +- a registered `Mezzio\Middleware\ErrorResponseGenerator` service is not callable + +When configuring the error handler in your application, you can choose between two classes: + +- `Dot\ErrorHandler\LogErrorHandler`: for logging and displaying errors +- `Dot\ErrorHandler\ErrorHandler`: for displaying errors only + +> If logging fails, for example because the log file cannot be written or a custom processor throws, the error response is still returned. +> The original error is then written to PHP's `error_log()`, followed by "(logging failed: ...)". + +> Both `LogErrorHandler` and `ErrorHandler` have factories declared in the package's `ConfigProvider`. +> If you need a custom ErrorHandler, it must have a factory declared in the config, as in the below example: + +```php + [ + 'factories' => [ + \App\CustomErrorHandler::class => \App\CustomHandlerFactory::class, + ], + 'aliases' => [ + \Dot\ErrorHandler\ErrorHandlerInterface::class => \App\CustomErrorHandler::class, + ], + ], + 'dot-errorhandler' => [ + 'loggerEnabled' => true, + 'logger' => 'dot-log.default_logger', + ], +]; +``` + +Config examples can be found in this project's `config` directory. diff --git a/docs/book/v6/extra/cookie.md b/docs/book/v6/extra/cookie.md new file mode 100644 index 0000000..878885e --- /dev/null +++ b/docs/book/v6/extra/cookie.md @@ -0,0 +1,124 @@ +# Log cookie data + +Looking at `dot-errorhandler`'s config file, the array found at `CookieProvider::class` allows you to configure the behavior of this provider: + +- **enabled**: enabled/disable this provider +- **processor**: an array configuring the data processor to be used by the **CookieProvider**: + - **class**: data processor class implementing `Dot\ErrorHandler\Extra\Processor\ProcessorInterface` + - **replacementStrategy**: whether to replace specific cookie values completely or partially + - **sensitiveParameters**: an array of cookie names that may contain sensitive information so their value should be masked partially/completely + +## Configure provider + +By default, **CookieProvider** is disabled. +It can be enabled only by setting **enabled** to **true**. + +If **enabled** is set to **true**, your log file will contain an additional field under the log record's `context` key, called `cookie`. +If **enabled** is set to **false**, no additional field is added under the `context` key. + +## Configure processor + +From here, we assume that **enabled** is set to **true**. + +If **processor** is missing/empty, the processor is ignored; the provider will log the raw data available. +If **processor** is specified, but **class** is missing/invalid, the processor is ignored and the provider will log the raw data available. + +From here, we assume that **processor**.**class** is valid. + +### Replacement strategy + +This value should be an instance of `Dot\ErrorHandler\Extra\ReplacementStrategy`. + +If **replacementStrategy** is missing/invalid, the default **replacementStrategy** is used, which is `ReplacementStrategy::Full`. +Else, the value used should be one of: + +- `ReplacementStrategy::Partial` for half-string replacements (e.g.: "abcdef" becomes "abc***") +- `ReplacementStrategy::Full` for full-string replacements (e.g.: "abcdef" becomes "******") + +### Sensitive parameters + +If **sensitiveParameters** is missing/empty, the processor is ignored; the provider will log the raw data available. +This is because without a set of **sensitiveParameters**, the processor is unable to determine which key needs to be processed or left untouched. +When specifying the array of **sensitiveParameters**, there are two possibilities: + +- use the constant `ProcessorInterface::ALL`, meaning alter all cookie values using the strategy specified by the **replacementStrategy** + +```php +'sensitiveParameters' => [ + Dot\ErrorHandler\Extra\Processor\ProcessorInterface::ALL, +], +``` + +- use exact strings to list the cookies for which the values should be altered using the strategy specified by the **replacementStrategy** + +```php +'sensitiveParameters' => [ + 'rememberMe', +], +``` + +> **CookieProcessor** uses EXACT cookie name lookups. +> To alter the value of a cookie, you need to specify the exact cookie name. + +> The config `sensitiveParameters` is case-insensitive. + +> Numeric cookie names, such as `123`, are supported, both in the request and in `sensitiveParameters`. + +> Masking counts characters, not bytes: "Brașov" becomes "******" with `ReplacementStrategy::Full`, and "Bra***" with `ReplacementStrategy::Partial`. + +## Why should I use a processor + +Consider the following request cookies: + +```text +[ + "sessionId" => "feb21b39f9c54e3a49af1f862acc8300", + "language" => "en", +] +``` + +Without a **CookieProcessor**, the plain text session cookie identifier would end up saved in the log file: + +```text +..."context":{"file":"/path/to/some/class.php","line":314,"cookie":{"sessionId":"feb21b39f9c54e3a49af1f862acc8300","language":"en"},... +``` + +But with a properly configured **CookieProcessor**: + +```php +'processor' => [ + 'class' => CookieProcessor::class, + 'replacementStrategy' => ReplacementStrategy::Full, + 'sensitiveParameters' => [ + 'sessionId', + ], +], +``` + +the logged cookie data becomes: + +```text +..."context":{"file":"/path/to/some/class.php","line":314,"cookie":{"sessionId":"********************************","language":"en"},... +``` + +## Custom processor + +If the existing processor does not offer enough features, you can create a custom processor. +The custom processor must implement `Dot\ErrorHandler\Extra\Processor\ProcessorInterface` or extend `Dot\ErrorHandler\Extra\Processor\AbstractProcessor`, which already implements `Dot\ErrorHandler\Extra\Processor\ProcessorInterface`. +Once the custom processor is ready, you need to configure **CookieProvider** to use it. +For this, open `dot-errorhandler`'s config file and - under **CookieProvider::class** - set **processor**.**class** to the class string of your custom processor: + +```php +CookieProvider::class => [ + 'enabled' => true, + 'processor' => [ + 'class' => CustomCookieProcessor::class, + 'replacementStrategy' => ReplacementStrategy::Full, + 'sensitiveParameters' => [ + ProcessorInterface::ALL, + ], + ], +], +``` + +Using this, cookie data will be processed by `CustomCookieProcessor` and logged as provided by this new processor. diff --git a/docs/book/v6/extra/header.md b/docs/book/v6/extra/header.md new file mode 100644 index 0000000..a8b1744 --- /dev/null +++ b/docs/book/v6/extra/header.md @@ -0,0 +1,152 @@ +# Log header data + +Looking at `dot-errorhandler`'s config file, the array found at `HeaderProvider::class` allows you to configure the behavior of this provider: + +- **enabled**: enabled/disable this provider +- **processor**: an array configuring the data processor to be used by the **HeaderProvider**: + - **class**: data processor class implementing `Dot\ErrorHandler\Extra\Processor\ProcessorInterface` + - **replacementStrategy**: whether to replace specific header values completely or partially + - **sensitiveParameters**: an array of header names that may contain sensitive information so their value should be masked partially/completely + +## Configure provider + +By default, **HeaderProvider** is disabled. +It can be enabled only by setting **enabled** to **true**. + +If **enabled** is set to **true**, your log file will contain an additional field under the log record's `context` key, called `header`. +If **enabled** is set to **false**, no additional field is added under the `context` key. + +## Configure processor + +From here, we assume that **enabled** is set to **true**. + +If **processor** is missing/empty, the processor is ignored; the provider will log the raw data available. +If **processor** is specified, but **class** is missing/invalid, the processor is ignored and the provider will log the raw data available. + +From here, we assume that **processor**.**class** is valid. + +### Replacement strategy + +This value should be an instance of `Dot\ErrorHandler\Extra\ReplacementStrategy`. + +If **replacementStrategy** is missing/invalid, the default **replacementStrategy** is used, which is `ReplacementStrategy::Full`. +Else, the value used should be one of: + +- `ReplacementStrategy::Partial` for half-string replacements (e.g.: "abcdef" becomes "abc***") +- `ReplacementStrategy::Full` for full-string replacements (e.g.: "abcdef" becomes "******") + +### Sensitive parameters + +If **sensitiveParameters** is missing/empty, the provider logs the raw value of every header **except** the `cookie` header, whose cookie values are always masked (see [Special case](#special-case)). + +`config/error-handling.global.php.dist` lists `authorization` and `proxy-authorization` by default, so credentials are masked as soon as you enable the provider. +If you copied the config file from an earlier version, add these two names to your copy. + +When specifying the array of **sensitiveParameters**, there are two possibilities: + +- use the constant `ProcessorInterface::ALL`, meaning alter all header values using the strategy specified by the **replacementStrategy** + +```php +'sensitiveParameters' => [ + Dot\ErrorHandler\Extra\Processor\ProcessorInterface::ALL, +], +``` + +- use exact strings to list the headers for which the values should be altered using the strategy specified by the **replacementStrategy** + +```php +'sensitiveParameters' => [ + 'Authorization', +], +``` + +> **HeaderProcessor** uses EXACT header name lookups. +> To alter the value of a header, you need to specify the exact header name. + +> Header names are matched case-insensitively, both in the config `sensitiveParameters` and in the request: `Authorization`, `authorization` and `AUTHORIZATION` are the same header. + +## Why should I use a processor + +Consider the following request headers: + +```text +[ + "Authorization" => "Bearer 63560eb4398d21024b32f2fb45dacca512db0bc725149e1371f493063a03e687", + "Content-Type" => "application/json", +] +``` + +Without a **HeaderProcessor**, the plain text auth token would end up saved in the log file: + +```text +..."context":{"file":"/path/to/some/class.php","line":314,"header":{"Authorization":"Bearer 63560eb4398d21024b32f2fb45dacca512db0bc725149e1371f493063a03e687","Content-Type":"application/json"},... +``` + +But with a properly configured **HeaderProcessor**: + +```php +'processor' => [ + 'class' => HeaderProcessor::class, + 'replacementStrategy' => ReplacementStrategy::Full, + 'sensitiveParameters' => [ + 'Authorization', + ], +], +``` + +the logged header data becomes: + +```text +..."context":{"file":"/path/to/some/class.php","line":314,"header":{"Authorization":"***********************************************************************","Content-Type":"application/json"},... +``` + +## Special case + +There is a special case, the `cookie` header, which is handled differently than the rest of the headers. +Its cookie values are always masked, whatever **sensitiveParameters** contains, and whatever the case of the header name (`cookie` or `Cookie`). + +Let's take an example of a cookie header: + +```text +FRONTEND_SESSID=feb21b39f9c54e3a49af1f862acc8300; rememberMe=63560eb4398d21024b32f2fb45dacca512db0bc725149e1371f493063a03e687 +``` + +If the existing **HeaderProcessor** is not used, then the log file will contain dangerous data that may compromise user security – in this case exposing the value of both cookies. + +> To avoid this issue, the developer should never use **HeaderProvider** without a **HeaderProcessor** in a production environment. + +Depending on **HeaderProvider**'s configuration, **HeaderProcessor** will partially mask the cookie values: + +```text +FRONTEND_SESSID=feb21b39f9c54e3a****************; rememberMe=63560eb4398d21024b32f2****************************************** +``` + +when using `ReplacementStrategy::Partial` or completely mask the cookie values: + +```text +FRONTEND_SESSID=********************************; rememberMe=**************************************************************** +``` + +when using `ReplacementStrategy::Full`. + +## Custom processor + +If the existing processor does not offer enough features, you can create a custom processor. +The custom processor must implement `Dot\ErrorHandler\Extra\Processor\ProcessorInterface` or extend `Dot\ErrorHandler\Extra\Processor\AbstractProcessor`, which already implements `Dot\ErrorHandler\Extra\Processor\ProcessorInterface`. +Once the custom processor is ready, you need to configure **HeaderProvider** to use it. +For this, open `dot-errorhandler`'s config file and - under **HeaderProvider::class** - set **processor**.**class** to the class string of your custom processor: + +```php +HeaderProvider::class => [ + 'enabled' => true, + 'processor' => [ + 'class' => CustomHeaderProcessor::class, + 'replacementStrategy' => ReplacementStrategy::Full, + 'sensitiveParameters' => [ + ProcessorInterface::ALL, + ], + ], +], +``` + +Using this, header data will be processed by `CustomHeaderProcessor` and logged as provided by this new processor. diff --git a/docs/book/v6/extra/introduction.md b/docs/book/v6/extra/introduction.md new file mode 100644 index 0000000..a9defcf --- /dev/null +++ b/docs/book/v6/extra/introduction.md @@ -0,0 +1,43 @@ +# Extra data + +`dot-errorhandler` provides the following data: + +- **cookie**: an array containing the request cookies +- **header**: an array containing the request headers +- **request**: an array containing the parsed request body, for example form fields or a decoded JSON body +- **server**: an array containing the server parameters (**\$_SERVER**) +- **session**: an array containing the **\$_SESSION** values +- **trace**: an array containing the full stack trace of the request + +## Configuring extra data + +At this point, you should already have `dot-errorhandler` configured. +If not, proceed to the [Configuration](../configuration.md) page, and when you're done, return to this page. + +To start logging one or more of the above data, we first need to enable them from the package's config file. +Open the config file and under `dot-errorhandler` locate the `ExtraProvider::CONFIG_KEY` key. +There you will find six associative arrays, each array representing a set of data this package can provide: + +- **CookieProvider::class**: request cookie information – [how to use](cookie.md) +- **HeaderProvider::class**: request header information – [how to use](header.md) +- **RequestProvider::class**: request body contents – [how to use](request.md) +- **ServerProvider::class**: server information – [how to use](server.md) +- **SessionProvider::class**: session contents – [how to use](session.md) +- **TraceProvider::class**: trace route – [how to use](trace.md) + +These providers and their configuration values are all optional. +If any of them is missing from the config file, the provider simply stays disabled and code execution continues normally. + +A provider is enabled only when its **enabled** value is exactly `true`. +Any other value, including `1`, `'yes'` or `null`, leaves it disabled. + +Invalid configuration values are ignored, because the purpose of these providers is to log the extra data if possible but not to interfere with the app logic: + +- provider options that are not an array leave the provider disabled +- a **processor**.**class** that does not implement `Dot\ErrorHandler\Extra\Processor\ProcessorInterface` is ignored, and the provider logs the raw data +- **sensitiveParameters** entries that are not strings or integers are ignored +- a **replacementStrategy** that is not a `Dot\ErrorHandler\Extra\ReplacementStrategy` falls back to `ReplacementStrategy::Full` + +> By default, `*Provider` classes are used in fall-through mode; without a processor, they just return the unaltered data they were given. + +> Masking counts characters, not bytes, so masked values stay valid UTF-8. diff --git a/docs/book/v6/extra/request.md b/docs/book/v6/extra/request.md new file mode 100644 index 0000000..f95d683 --- /dev/null +++ b/docs/book/v6/extra/request.md @@ -0,0 +1,130 @@ +# Log request data + +Looking at `dot-errorhandler`'s config file, the array found at `RequestProvider::class` allows you to configure the behavior of this provider: + +- **enabled**: enabled/disable this provider +- **processor**: an array configuring the data processor to be used by the **RequestProvider**: + - **class**: data processor class implementing `Dot\ErrorHandler\Extra\Processor\ProcessorInterface` + - **replacementStrategy**: whether to replace specific request field values completely or partially + - **sensitiveParameters**: an array of request field names that may contain sensitive information so their value should be masked partially/completely + +## Configure provider + +By default, **RequestProvider** is disabled. +It can be enabled only by setting **enabled** to **true**. + +If **enabled** is set to **true**, your log file will contain an additional field under the log record's `context` key, called `request`. +If **enabled** is set to **false**, no additional field is added under the `context` key. + +## Configure processor + +From here, we assume that **enabled** is set to **true**. + +If **processor** is missing/empty, the processor is ignored; the provider will log the raw data available. +If **processor** is specified, but **class** is missing/invalid, the processor is ignored and the provider will log the raw data available. + +From here, we assume that **processor**.**class** is valid. + +### Replacement strategy + +This value should be an instance of `Dot\ErrorHandler\Extra\ReplacementStrategy`. + +If **replacementStrategy** is missing/invalid, the default **replacementStrategy** is used, which is `ReplacementStrategy::Full`. +Else, the value used should be one of: + +- `ReplacementStrategy::Partial` for half-string replacements (e.g.: "abcdef" becomes "abc***") +- `ReplacementStrategy::Full` for full-string replacements (e.g.: "abcdef" becomes "******") + +### Sensitive parameters + +If **sensitiveParameters** is missing/empty, the processor is ignored; the provider will log the raw data available. +This is because without a set of **sensitiveParameters**, the processor is unable to determine which key needs to be processed or left untouched. +When specifying the array of **sensitiveParameters**, there are two possibilities: + +- use the constant `ProcessorInterface::ALL`, meaning alter all request field values using the strategy specified by the **replacementStrategy** + +```php +'sensitiveParameters' => [ + Dot\ErrorHandler\Extra\Processor\ProcessorInterface::ALL, +], +``` + +- use strings to list the request fields (or parts of their names) for which the values should be altered using the strategy specified by the **replacementStrategy** + +```php +'sensitiveParameters' => [ + 'password', +], +``` + +> **RequestProcessor** uses recursive search to locate all array keys in a multidimensional array. +> This includes lists, such as the values of a `tags[]` form field. + +> Values that are not strings are masked from their string form; objects that cannot be converted to a string are masked from their type name. + +> **RequestProcessor** uses PARTIAL field name lookups. +> To alter the value of a request field, it is enough to specify only part of the field name. + +> The config `sensitiveParameters` is case-insensitive. + +## Why should I use a processor + +Consider the following request body sent via **$_POST**: + +```text +[ + "identity" => "myIdentity", + "password" => "p4$$w0rd", + "passwordConfirm" => "p4$$w0rd", + "details" => [ + "secret" => "s3cr3t", + ] +] +``` + +Without a **RequestProcessor**, the plain text passwords would end up saved in the log file: + +```text +..."context":{"file":"/path/to/some/class.php","line":314,"request":{"identity":"myIdentity","password":"p4$$w0rd","passwordConfirm":"p4$$w0rd","details":{"secret":"s3cr3t"}}},... +``` + +But with a properly configured **RequestProcessor**: + +```php +'processor' => [ + 'class' => RequestProcessor::class, + 'replacementStrategy' => ReplacementStrategy::Full, + 'sensitiveParameters' => [ + 'password', + 'secret', + ], +], +``` + +the logged request data becomes: + +```text +..."context":{"file":"/path/to/some/class.php","line":314,"request":{"identity":"myIdentity","password":"********","passwordConfirm":"********","details":{"secret":"******"}}},... +``` + +## Custom processor + +If the existing processor does not offer enough features, you can create a custom processor. +The custom processor must implement `Dot\ErrorHandler\Extra\Processor\ProcessorInterface` or extend `Dot\ErrorHandler\Extra\Processor\AbstractProcessor`, which already implements `Dot\ErrorHandler\Extra\Processor\ProcessorInterface`. +Once the custom processor is ready, you need to configure **RequestProvider** to use it. +For this, open `dot-errorhandler`'s config file and - under **RequestProvider::class** - set **processor**.**class** to the class string of your custom processor: + +```php +RequestProvider::class => [ + 'enabled' => true, + 'processor' => [ + 'class' => CustomRequestProcessor::class, + 'replacementStrategy' => ReplacementStrategy::Full, + 'sensitiveParameters' => [ + ProcessorInterface::ALL, + ], + ], +], +``` + +Using this, request data will be processed by `CustomRequestProcessor` and logged as provided by this new processor. diff --git a/docs/book/v6/extra/server.md b/docs/book/v6/extra/server.md new file mode 100644 index 0000000..93602af --- /dev/null +++ b/docs/book/v6/extra/server.md @@ -0,0 +1,163 @@ +# Log server data + +Looking at `dot-errorhandler`'s config file, the array found at `ServerProvider::class` allows you to configure the behavior of this provider: + +- **enabled**: enabled/disable this provider +- **processor**: an array configuring the data processor to be used by the **ServerProvider**: + - **class**: data processor class implementing `Dot\ErrorHandler\Extra\Processor\ProcessorInterface` + - **replacementStrategy**: whether to replace specific server config values completely or partially + - **sensitiveParameters**: an array of server config names that may contain sensitive information so their value should be masked partially/completely + +## Configure provider + +By default, **ServerProvider** is disabled. +It can be enabled only by setting **enabled** to **true**. + +If **enabled** is set to **true**, your log file will contain an additional field under the log record's `context` key, called `server`. +If **enabled** is set to **false**, no additional field is added under the `context` key. + +## Configure processor + +From here, we assume that **enabled** is set to **true**. + +If **processor** is missing/empty, the processor is ignored; the provider will log the raw data available. +If **processor** is specified, but **class** is missing/invalid, the processor is ignored and the provider will log the raw data available. + +From here, we assume that **processor**.**class** is valid. + +### Replacement strategy + +This value should be an instance of `Dot\ErrorHandler\Extra\ReplacementStrategy`. + +If **replacementStrategy** is missing/invalid, the default **replacementStrategy** is used, which is `ReplacementStrategy::Full`. +Else, the value used should be one of: + +- `ReplacementStrategy::Partial` for half-string replacements (e.g.: "abcdef" becomes "abc***") +- `ReplacementStrategy::Full` for full-string replacements (e.g.: "abcdef" becomes "******") + +### Sensitive parameters + +If **sensitiveParameters** is missing/empty, the processor is ignored; the provider will log the raw data available. +This is because without a set of **sensitiveParameters**, the processor is unable to determine which key needs to be processed or left untouched. +When specifying the array of **sensitiveParameters**, there are two possibilities: + +- use the constant `ProcessorInterface::ALL`, meaning alter all server config values using the strategy specified by the **replacementStrategy** + +```php +'sensitiveParameters' => [ + Dot\ErrorHandler\Extra\Processor\ProcessorInterface::ALL, +], +``` + +- use exact strings to list the server configs for which the values should be altered using the strategy specified by the **replacementStrategy** + +```php +'sensitiveParameters' => [ + 'SERVER_ADMIN', +], +``` + +> **ServerProcessor** uses EXACT server config name lookups. +> To alter the value of a server config, you need to specify the exact server config name. + +> The config `sensitiveParameters` is case-insensitive. + +## Why should I use a processor + +Consider the following request server configs: + +```text +[ + "SERVER_ADMIN" => "webmaster@localhost", + "SERVER_SOFTWARE" => "Apache/2.4.62 (AlmaLinux)", +] +``` + +Without a **ServerProcessor**, (for this example) the key **SERVER_ADMIN** would end up saved in the log file: + +```text +..."context":{"file":"/path/to/some/class.php","line":314,"server":{"SERVER_ADMIN":"webmaster@localhost","SERVER_SOFTWARE":"Apache/2.4.62 (AlmaLinux)"},... +``` + +But with a properly configured **ServerProcessor**: + +```php +'processor' => [ + 'class' => ServerProcessor::class, + 'replacementStrategy' => ReplacementStrategy::Full, + 'sensitiveParameters' => [ + 'SERVER_ADMIN', + ], +], +``` + +the logged server config data becomes: + +```text +..."context":{"file":"/path/to/some/class.php","line":314,"server":{"SERVER_ADMIN":"*******************","SERVER_SOFTWARE":"Apache/2.4.62 (AlmaLinux)"},... +``` + +## Special case + +There is a special case, the `HTTP_COOKIE` server config value, which is handled differently than the rest of the values. + +Let's take an example of an **HTTP_COOKIE** value: + +```text +FRONTEND_SESSID=feb21b39f9c54e3a49af1f862acc8300; rememberMe=63560eb4398d21024b32f2fb45dacca512db0bc725149e1371f493063a03e687 +``` + +If the existing **ServerProcessor** is not used, then the log file will contain dangerous data that may compromise user security – in this case exposing the value of both cookies. + +> To avoid this issue, the developer should never use **ServerProvider** without a **ServerProcessor** in a production environment. + +**ServerProcessor** splits **HTTP_COOKIE** into its cookies, with or without spaces after each `;`, and applies the same **sensitiveParameters** to the cookie names. +A cookie value is therefore masked only when its cookie name is listed in **sensitiveParameters**, or when **sensitiveParameters** contains `ProcessorInterface::ALL`, which is what `config/error-handling.global.php.dist` uses. + +> With `'sensitiveParameters' => ['SERVER_ADMIN']` only, **HTTP_COOKIE** is logged unmasked. +> List the cookie names as well (for example `'frontend_sessid'` and `'rememberme'`), or use `ProcessorInterface::ALL`. + +With `ProcessorInterface::ALL`, **ServerProcessor** will partially mask the cookie values: + +```text +FRONTEND_SESSID=feb21b39f9c54e3a****************; rememberMe=63560eb4398d21024b32f2****************************************** +``` + +when using `ReplacementStrategy::Partial` or completely mask the cookie values: + +```text +FRONTEND_SESSID=********************************; rememberMe=**************************************************************** +``` + +when using `ReplacementStrategy::Full`. + +## Array values + +Some server values are arrays, for example `argv` when PHP runs from the command line or under an application server such as RoadRunner or Swoole. +When such a value is sensitive, each of its elements is masked separately: + +```text +"argv":["***","****"] +``` + +## Custom processor + +If the existing processor does not offer enough features, you can create a custom processor. +The custom processor must implement `Dot\ErrorHandler\Extra\Processor\ProcessorInterface` or extend `Dot\ErrorHandler\Extra\Processor\AbstractProcessor`, which already implements `Dot\ErrorHandler\Extra\Processor\ProcessorInterface`. +Once the custom processor is ready, you need to configure **ServerProvider** to use it. +For this, open `dot-errorhandler`'s config file and - under **ServerProvider::class** - set **processor**.**class** to the class string of your custom processor: + +```php +ServerProvider::class => [ + 'enabled' => true, + 'processor' => [ + 'class' => CustomServerProcessor::class, + 'replacementStrategy' => ReplacementStrategy::Full, + 'sensitiveParameters' => [ + ProcessorInterface::ALL, + ], + ], +], +``` + +Using this, server config data will be processed by `CustomServerProcessor` and logged as provided by this new processor. diff --git a/docs/book/v6/extra/session.md b/docs/book/v6/extra/session.md new file mode 100644 index 0000000..523aab4 --- /dev/null +++ b/docs/book/v6/extra/session.md @@ -0,0 +1,116 @@ +# Log session data + +Looking at `dot-errorhandler`'s config file, the array found at `SessionProvider::class` allows you to configure the behavior of this provider: + +- **enabled**: enabled/disable this provider +- **processor**: an array configuring the data processor to be used by the **SessionProvider**: + - **class**: data processor class implementing `Dot\ErrorHandler\Extra\Processor\ProcessorInterface` + +## Configure provider + +By default, **SessionProvider** is disabled. +It can be enabled only by setting **enabled** to **true**. + +If **enabled** is set to **true**, your log file will contain an additional field under the log record's `context` key, called `session`. +If **enabled** is set to **false**, no additional field is added under the `context` key. + +## Configure processor + +From here, we assume that **enabled** is set to **true**. + +If **processor** is missing/empty, the processor is ignored; the provider will log the raw data available. +If **processor** is specified, but **class** is missing/invalid, the processor is ignored and the provider will log the raw data available. + +From here, we assume that **processor**.**class** is valid. + +### Replacement strategy + +**SessionProcessor** does not use **replacementStrategy**. + +> If you create a custom session processor, you may add **replacementStrategy** to the config file; it will get passed to your processor. + +### Sensitive parameters + +**SessionProcessor** does not use **sensitiveParameters**, so it does not mask anything: session values are logged as they are. +If your session holds secrets, write a custom processor that masks them, or keep this provider disabled. + +> If you create a custom session processor, you may add **sensitiveParameters** to the config file; it will get passed to your processor. + +## Why should I use a processor + +The only advantage of using **SessionProcessor** is to reduce the size of the session data that you log. +In the below example, **$_SESSION** contains an **array** in _SessionContainer1_ and an **ArrayObject** in _SessionContainer2_. + +```text + "SessionContainer1" => array:3 [▼ + "_REQUEST_ACCESS_TIME" => 1739973274.7284 + "_VALID" => array:1 [▼ + "Laminas\Session\Validator\Id" => "feb21b39f9c54e3a49af1f862acc8300" + ] + "Default" => array:1 [▼ + "EXPIRE" => 1739969278 + ] + ] + "SessionContainer2" => Laminas\Stdlib\ArrayObject {#795 ▼ + #storage: array:2 [▼ + "tokenList" => array:1 [▼ + "b222251fb72d49ae0643bff11e11057d" => "389b5e5b415409abb61cfe718e7841bf" + ] + "hash" => "389b5e5b415409abb61cfe718e7841bf-b222251fb72d49ae0643bff11e11057d" + ] + #flag: 2 + #iteratorClass: "ArrayIterator" + #protectedProperties: array:4 [▼ + 0 => "storage" + 1 => "flag" + 2 => "iteratorClass" + 3 => "protectedProperties" + ] + } +``` + +By using **SessionProcessor**, the two items are reduced to: + +```text + "SessionContainer1" => array:3 [▼ + "_REQUEST_ACCESS_TIME" => 1739973274.7284 + "_VALID" => array:1 [▼ + "Laminas\Session\Validator\Id" => "feb21b39f9c54e3a49af1f862acc8300" + ] + "Default" => array:1 [▼ + "EXPIRE" => 1739969278 + ] + ] + "SessionContainer2" => array:2 [▼ + "tokenList" => array:1 [▼ + "b222251fb72d49ae0643bff11e11057d" => "389b5e5b415409abb61cfe718e7841bf" + ] + "hash" => "389b5e5b415409abb61cfe718e7841bf-b222251fb72d49ae0643bff11e11057d" + ] +``` + +As you can see, with arrays the difference is almost ignorable, but with ArrayObjects, the difference is significant. + +Values that are not arrays or objects you can iterate, such as a `user_id` integer stored directly in **$_SESSION**, are logged unchanged. + +## Custom processor + +If the existing processor does not offer enough features, you can create a custom processor. +The custom processor must implement `Dot\ErrorHandler\Extra\Processor\ProcessorInterface` or extend `Dot\ErrorHandler\Extra\Processor\AbstractProcessor`, which already implements `Dot\ErrorHandler\Extra\Processor\ProcessorInterface`. +Once the custom processor is ready, you need to configure **SessionProvider** to use it. +For this, open `dot-errorhandler`'s config file and - under **SessionProvider::class** - set **processor**.**class** to the class string of your custom processor: + +```php +SessionProvider::class => [ + 'enabled' => true, + 'processor' => [ + 'class' => CustomSessionProcessor::class, + 'replacementStrategy' => ReplacementStrategy::Full, + 'sensitiveParameters' => [ + ProcessorInterface::ALL, + ], + ], +], +``` + +Using this, session data will be processed by `CustomSessionProcessor` and logged as provided by this new processor. diff --git a/docs/book/v6/extra/trace.md b/docs/book/v6/extra/trace.md new file mode 100644 index 0000000..4eac434 --- /dev/null +++ b/docs/book/v6/extra/trace.md @@ -0,0 +1,98 @@ +# Log trace data + +Looking at `dot-errorhandler`'s config file, the array found at `TraceProvider::class` allows you to configure the behavior of this provider: + +- **enabled**: enabled/disable this provider +- **processor**: an array configuring the data processor to be used by the **TraceProvider**: + - **class**: data processor class implementing `Dot\ErrorHandler\Extra\Processor\ProcessorInterface` + +## Configure provider + +**TraceProvider** is disabled unless configured, like every provider. +`config/error-handling.global.php.dist` enables it by setting **enabled** to **true**, so it is on when you start from that file. + +If **enabled** is set to **true**, your log file will contain an additional field under the log record's `context` key, called `trace`. +If **enabled** is set to **false**, no additional field is added under the `context` key. + +## Configure processor + +From here, we assume that **enabled** is set to **true**. + +If **processor** is missing/empty, the processor is ignored; the provider will log the raw data available. +If **processor** is specified, but **class** is missing/invalid, the processor is ignored and the provider will log the raw data available. + +From here, we assume that **processor**.**class** is valid. + +### Replacement strategy + +**TraceProcessor** does not use **replacementStrategy**. + +> If you create a custom trace processor, you may add **replacementStrategy** to the config file; it will get passed to your processor. + +### Sensitive parameters + +**TraceProcessor** does not use **sensitiveParameters**. + +> If you create a custom trace processor, you may add **sensitiveParameters** to the config file; it will get passed to your processor. + +## Why should I use a processor + +The only advantage of using **TraceProcessor** is to reduce the size of the trace data that you log. +In the below example, the trace route contains an array with dozens of items, each with the same structure (except for the last one). + +```text + 0 => array:5 [▼ + "file" => "/path/to/some/file.php" + "line" => 66 + "function" => "someFunction" + "class" => "Path\To\Some\File" + "type" => "->" + ] + 1 => array:5 [▼ + "file" => "/path/to/some/other/file.php" + "line" => 33 + "function" => "someOtherFunction" + "class" => "Path\To\Some\Other\File" + "type" => "->" + ] + ... + 58 => array:3 [▼ + "file" => "/path/to/index.php" + "line" => 36 + "function" => "{closure}" + ] +``` + +By using **TraceProcessor**, the array is reduced to: + +```text + 0 => "Path\To\Some\File->someFunction:66" + 1 => "Path\To\Some\Other\File->someOtherFunction:33" + ... + 58 => "/path/to/index.php->{closure}:36" +``` + +Yet it maintains the relevant information, but in a compact form. +This works because, since using namespaced classes, it's easy to determine file paths just by looking at a FQCN. + +## Custom processor + +If the existing processor does not offer enough features, you can create a custom processor. +The custom processor must implement `Dot\ErrorHandler\Extra\Processor\ProcessorInterface` or extend `Dot\ErrorHandler\Extra\Processor\AbstractProcessor`, which already implements `Dot\ErrorHandler\Extra\Processor\ProcessorInterface`. +Once the custom processor is ready, you need to configure **TraceProvider** to use it. +For this, open `dot-errorhandler`'s config file and - under **TraceProvider::class** - set **processor**.**class** to the class string of your custom processor: + +```php +TraceProvider::class => [ + 'enabled' => true, + 'processor' => [ + 'class' => CustomTraceProcessor::class, + 'replacementStrategy' => ReplacementStrategy::Full, + 'sensitiveParameters' => [ + ProcessorInterface::ALL, + ], + ], +], +``` + +Using this, trace data will be processed by `CustomTraceProcessor` and logged as provided by this new processor. diff --git a/docs/book/v6/installation.md b/docs/book/v6/installation.md new file mode 100644 index 0000000..b69a008 --- /dev/null +++ b/docs/book/v6/installation.md @@ -0,0 +1,24 @@ +# Installation + +`dotkernel/dot-errorhandler` 6 requires PHP 8.3, 8.4, 8.5 or 8.6, and the `mbstring` extension. + +Install it by executing the following Composer command: + +```shell +composer require dotkernel/dot-errorhandler +``` + +## Other packages you may need + +Version 6 depends only on PSR interfaces, Laminas Stratigility and Laminas Stdlib. +Depending on your application, you may need to require the following packages yourself: + +- `dotkernel/dot-log`: needed if you use the logger configured in this package's `config/log.global.php.dist` (the `dot-log.default_logger` service) +- `laminas/laminas-diactoros`, or another PSR-7 implementation: needed to create the error response +- `mezzio/mezzio`: if your container registers `Mezzio\Middleware\ErrorResponseGenerator`, the error handlers use it automatically + +Dotkernel and Mezzio application skeletons already require these packages. + +```shell +composer require dotkernel/dot-log +``` diff --git a/docs/book/v6/log-files.md b/docs/book/v6/log-files.md new file mode 100644 index 0000000..7a7bb90 --- /dev/null +++ b/docs/book/v6/log-files.md @@ -0,0 +1,30 @@ +# Log files + +## What is a log file + +Log files are plain text files, containing rows of log activity, each row being a standalone activity formatted as specified in the logger's configuration. +With the logger configured in `config/log.global.php.dist`, log activities are formatted with JSON, so each row should be a decodable JSON string. + +## What is in a log file + +With that configuration, each row in a log file contains the following values: + +- **timestamp**: string representation of the date and time when the error occurred +- **level**: numeric representation of the error level +- **levelName**: string representation of the error level, `error` for errors logged by this package +- **message**: error message describing the error +- **context**: an array of extra information that may help the developer debug the error: + - **file**: the file in which the error occurred + - **line**: the line from **file** where the error occurred + - **trace**: the stack trace, because `config/error-handling.global.php.dist` enables the trace provider + +For example: + +```text +{"timestamp":"2026-11-11T16:05:23+00:00","level":3,"levelName":"error","message":"Something went wrong","context":{"file":"/path/to/some/class.php","line":24,"trace":["Path\\To\\Some\\Class->someMethod:24"]}} +``` + +By leveraging `dot-errorhandler`'s extra providers, you can also log additional request parameters. +Learn more about what additional parameters are available on the [extra data](extra/introduction.md) page. + +> For a clear separation between different types of logs, we recommend that you store files from each type in their own directory. diff --git a/docs/book/v6/overview.md b/docs/book/v6/overview.md new file mode 100644 index 0000000..9c53b24 --- /dev/null +++ b/docs/book/v6/overview.md @@ -0,0 +1,40 @@ +# Overview + +`dot-errorhandler` is Dotkernel's PSR-15 error handler middleware, with optional logging through any PSR-3 logger. + +## Version History + +| Branch | Release | PSR-11 | Log Style Implementation | OSS Lifecycle | PHP Version | +|--------|----------|--------|--------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------| +| 6.0 | `>= 6.0` | 1, 2 | PSR-Log (any logger) | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-errorhandler%2Fblob%2F6.0%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-errorhandler/6.0.0) | +| 5.0 | `>= 5.0` | 2 | PSR-Log | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-errorhandler%2Fblob%2F5.0%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-errorhandler/5.0.0) | +| 4.1 | `>= 4.2` | 2 | PSR-Log | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-errorhandler%2Fblob%2F4.1%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-errorhandler/4.4.1) | +| 4.1 | `< 4.2` | 2 | Laminas Log | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-errorhandler%2Fblob%2F4.1%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-errorhandler/4.1.1) | +| 4.0 | `< 4.1` | 1 | Laminas Log | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-errorhandler%2Fblob%2F4.0%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-errorhandler/4.0.2) | +| 3.0 | `< 4.0` | 1 | Laminas Log | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-errorhandler%2Fblob%2F3.0%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-errorhandler/3.4.1) | + +## Badges + +![OSS Lifecycle](https://img.shields.io/osslifecycle/dotkernel/dot-errorhandler) +![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-errorhandler/6.0.0) + +[![GitHub issues](https://img.shields.io/github/issues/dotkernel/dot-errorhandler)](https://github.com/dotkernel/dot-errorhandler/issues) +[![GitHub forks](https://img.shields.io/github/forks/dotkernel/dot-errorhandler)](https://github.com/dotkernel/dot-errorhandler/network) +[![GitHub stars](https://img.shields.io/github/stars/dotkernel/dot-errorhandler)](https://github.com/dotkernel/dot-errorhandler/stargazers) +[![GitHub license](https://img.shields.io/github/license/dotkernel/dot-errorhandler)](https://github.com/dotkernel/dot-errorhandler/blob/6.0/LICENSE) + +[![Build Static](https://github.com/dotkernel/dot-errorhandler/actions/workflows/continuous-integration.yml/badge.svg?branch=6.0)](https://github.com/dotkernel/dot-errorhandler/actions/workflows/continuous-integration.yml) +[![codecov](https://codecov.io/gh/dotkernel/dot-errorhandler/branch/6.0/graph/badge.svg?token=0KIJARS5RS)](https://codecov.io/gh/dotkernel/dot-errorhandler) +[![PHPStan](https://github.com/dotkernel/dot-errorhandler/actions/workflows/static-analysis.yml/badge.svg?branch=6.0)](https://github.com/dotkernel/dot-errorhandler/actions/workflows/static-analysis.yml) + +## Features + +This package provides two middleware classes: + +- `Dot\ErrorHandler\ErrorHandler`, the same as Laminas Stratigility's `ErrorHandler` middleware, which is marked `@final`, but open for extension +- `Dot\ErrorHandler\LogErrorHandler`, which extends `ErrorHandler` and logs every error through a PSR-3 logger, optionally with [extra request data](extra/introduction.md) + +Version 6 works with any PSR-11 container, any PSR-3 logger and any PSR-15 middleware pipeline. +It no longer installs `dotkernel/dot-log`, `laminas/laminas-diactoros` or `mezzio/mezzio` for you; see [Installation](installation.md). + +Upgrading from an earlier version? Read [Upgrading](upgrading.md). diff --git a/docs/book/v6/upgrading.md b/docs/book/v6/upgrading.md new file mode 100644 index 0000000..f0f68b3 --- /dev/null +++ b/docs/book/v6/upgrading.md @@ -0,0 +1,66 @@ +# Upgrading + +## From 5.x to 6.0 + +The table lists every change in 6.0 that can affect an application, what you would notice, and what to do. + +| Change | Symptom | Fix | +| --- | --- | --- | +| PHP 8.2 support dropped | Composer refuses to install | Use PHP 8.3 or later | +| `ext-mbstring` is required | Composer refuses to install | Enable the `mbstring` extension | +| `dotkernel/dot-log` is no longer installed with this package | `Class "Dot\Log\Logger" not found`, or the `dot-log.default_logger` service is missing | `composer require dotkernel/dot-log` | +| `laminas/laminas-diactoros` and `mezzio/mezzio` are no longer installed with this package | Missing PSR-7 or Mezzio classes, in applications that relied on this package to install them | Require them directly | +| Providers are enabled only by `'enabled' => true` | A provider configured with `1`, `'yes'` or `'true'` stops logging | Use `true` | +| `loggerEnabled` must be a boolean; a missing key means `false` | `InvalidArgumentException: Logger: '[loggerEnabled'] must be a boolean` | Use `true` or `false`, and cast values read from environment variables | +| The logger service must implement `Psr\Log\LoggerInterface` | `InvalidArgumentException` when the container builds the error handler | Point `logger` at a PSR-3 service | +| A non-callable `ResponseInterface` or `ErrorResponseGenerator` service is rejected | `InvalidArgumentException` instead of a `TypeError` | Register a callable | +| `config/error-handling.global.php.dist` masks `authorization` and `proxy-authorization` headers | Nothing: a config file copied from an earlier version keeps the old behaviour | Add both names to the `HeaderProvider` `sensitiveParameters` in your copy | +| Masking counts characters, not bytes | Masked non-ASCII values have a different length in new log lines | None | +| `LogErrorHandlerFactory::__invoke()` returns `LogErrorHandler` | Fatal error in a subclass that overrides it with return type `MiddlewareInterface` | Change the override's return type to `LogErrorHandler` | +| `LogErrorHandler` extends `ErrorHandler` | `instanceof ErrorHandler` is now also true for `LogErrorHandler` | Check for `LogErrorHandler` first | +| A logger or extra-data processor that throws no longer breaks the error response | The original error appears in PHP's `error_log` with "(logging failed: ...)" | Fix the logger or processor; nothing to change in the application | + +### Example: requiring the logger + +If your configuration uses `dot-log.default_logger`, as `config/log.global.php.dist` does, require dot-log next to this package: + +```shell +composer require dotkernel/dot-errorhandler:^6.0 dotkernel/dot-log +``` + +### Example: masking credentials in headers + +Before, in a copy of the 5.x config file: + +```php +HeaderProvider::class => [ + 'enabled' => true, + 'processor' => [ + 'class' => HeaderProcessor::class, + 'replacementStrategy' => ReplacementStrategy::Full, + ], +], +``` + +After: + +```php +HeaderProvider::class => [ + 'enabled' => true, + 'processor' => [ + 'class' => HeaderProcessor::class, + 'replacementStrategy' => ReplacementStrategy::Full, + 'sensitiveParameters' => [ + 'authorization', + 'proxy-authorization', + ], + ], +], +``` + +## From 4.x to 6.0 + +Start with the steps for your release, then apply every row of the [5.x to 6.0](#from-5x-to-60) table. + +- **4.2 to 4.4 (branch 4.1, PSR-Log):** 5.0 changed only the minimum versions of its dependencies (`dotkernel/dot-log ^5.3`, `mezzio/mezzio ^3.28.1`, `laminas/laminas-stratigility ^4.3.1`); in the code, only two comments changed. No extra steps. +- **4.0 to 4.1 (branches 4.0 and 4.1, Laminas Log):** these releases require `dotkernel/dot-log` 4, which is based on Laminas Log. Version 6 needs a PSR-3 logger. Move your logger configuration to `dotkernel/dot-log` 5 or later, following the [dot-log documentation](https://docs.dotkernel.org/dot-log/), or register any other PSR-3 logger. diff --git a/mkdocs.yml b/mkdocs.yml index f1bcfa7..da3524c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -2,13 +2,28 @@ docs_dir: docs/book site_dir: docs/html extra: project: Packages - current_version: v5 + current_version: v6 versions: + - v6 - v5 - v4 - v3 nav: - Home: index.md + - v6: + - Overview: v6/overview.md + - Installation: v6/installation.md + - Configuration: v6/configuration.md + - "Log files": v6/log-files.md + - "Extra data": + - Introduction: v6/extra/introduction.md + - Cookie: v6/extra/cookie.md + - Header: v6/extra/header.md + - Request: v6/extra/request.md + - Server: v6/extra/server.md + - Session: v6/extra/session.md + - "Trace route": v6/extra/trace.md + - Upgrading: v6/upgrading.md - v5: - Overview: v5/overview.md - Installation: v5/installation.md @@ -40,7 +55,7 @@ nav: - Installation: v3/installation.md - Configuration: v3/configuration.md site_name: dot-errorhandler -site_description: "Dotkernel's PSR-15 compliant error handler" +site_description: "Dotkernel's PSR-15 compliant error handler, with optional PSR-3 logging" repo_url: "https://github.com/dotkernel/dot-errorhandler" plugins: - search diff --git a/phpstan.neon b/phpstan.neon index 349be25..08d6acd 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,7 +1,7 @@ includes: - vendor/phpstan/phpstan-phpunit/extension.neon parameters: - level: 5 + level: 8 paths: - src - test diff --git a/phpunit.xml b/phpunit.xml index ef7dc50..ba02cdf 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,12 +1,23 @@ - + ./test - - + ./src diff --git a/src/ConfigProvider.php b/src/ConfigProvider.php index a0e68de..b22b1e8 100644 --- a/src/ConfigProvider.php +++ b/src/ConfigProvider.php @@ -6,6 +6,14 @@ class ConfigProvider { + /** + * @return array{ + * dependencies: array{ + * aliases: array, + * factories: array, + * }, + * } + */ public function __invoke(): array { return [ diff --git a/src/ErrorHandler.php b/src/ErrorHandler.php index d50a87c..0c34c09 100644 --- a/src/ErrorHandler.php +++ b/src/ErrorHandler.php @@ -4,6 +4,7 @@ namespace Dot\ErrorHandler; +use Closure; use ErrorException; use Laminas\Stratigility\Middleware\ErrorResponseGenerator; use Psr\Http\Message\ResponseInterface; @@ -18,12 +19,11 @@ class ErrorHandler implements ErrorHandlerInterface { - /** @var callable[] */ + /** @var list */ private array $listeners = []; - /** @var callable Routine that will generate the error response. */ - private $responseGenerator; - /** @var callable */ - private $responseFactory; + /** Routine that will generate the error response. */ + private Closure $responseGenerator; + private Closure $responseFactory; /** * @param callable $responseFactory A factory capable of returning an @@ -34,10 +34,8 @@ class ErrorHandler implements ErrorHandlerInterface */ public function __construct(callable $responseFactory, ?callable $responseGenerator = null) { - $this->responseFactory = static function () use ($responseFactory): ResponseInterface { - return $responseFactory(); - }; - $this->responseGenerator = $responseGenerator ?: new ErrorResponseGenerator(); + $this->responseFactory = static fn (): ResponseInterface => $responseFactory(); + $this->responseGenerator = Closure::fromCallable($responseGenerator ?? new ErrorResponseGenerator()); } public function attachListener(callable $listener): void @@ -56,7 +54,10 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface try { $response = $handler->handle($request); } catch (Throwable $e) { - $response = $this->handleThrowable($e, $request); + // Restore first: error handling must neither leak this handler nor have its own warnings converted. + restore_error_handler(); + + return $this->handleThrowable($e, $request); } restore_error_handler(); diff --git a/src/ErrorHandlerFactory.php b/src/ErrorHandlerFactory.php index 916e5a9..f3088bc 100644 --- a/src/ErrorHandlerFactory.php +++ b/src/ErrorHandlerFactory.php @@ -4,32 +4,41 @@ namespace Dot\ErrorHandler; +use InvalidArgumentException; use Mezzio\Middleware\ErrorResponseGenerator; use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; use Psr\Container\NotFoundExceptionInterface; use Psr\Http\Message\ResponseInterface; -use function assert; use function is_callable; +use function sprintf; class ErrorHandlerFactory { /** * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface + * @throws InvalidArgumentException When a configured service is not callable. */ public function __invoke(ContainerInterface $container): ErrorHandler { - $generator = null; - if ($container->has(ErrorResponseGenerator::class)) { - $generator = $container->get(ErrorResponseGenerator::class); - assert($generator instanceof ErrorResponseGenerator); + $generator = $container->has(ErrorResponseGenerator::class) + ? $container->get(ErrorResponseGenerator::class) + : null; + if ($generator !== null && ! is_callable($generator)) { + throw new InvalidArgumentException( + sprintf('Service "%s" must be callable', ErrorResponseGenerator::class) + ); } - $responseInterface = $container->get(ResponseInterface::class); - assert(is_callable($responseInterface)); + $responseFactory = $container->get(ResponseInterface::class); + if (! is_callable($responseFactory)) { + throw new InvalidArgumentException( + sprintf('Service "%s" must be a callable response factory', ResponseInterface::class) + ); + } - return new ErrorHandler($responseInterface, $generator); + return new ErrorHandler($responseFactory, $generator); } } diff --git a/src/ErrorHandlerInterface.php b/src/ErrorHandlerInterface.php index 7542e5c..656af5f 100644 --- a/src/ErrorHandlerInterface.php +++ b/src/ErrorHandlerInterface.php @@ -34,7 +34,7 @@ * provided is the response prototype provided to the ErrorHandler instance * itself, and can be used as the basis for returning an error response. * - * An error response generator must be provided as a constructor argument; + * An error response generator may be provided as a constructor argument; * if not provided, an instance of Laminas\Stratigility\Middleware\ErrorResponseGenerator * will be used. * diff --git a/src/Extra/ExtraProvider.php b/src/Extra/ExtraProvider.php index 290ce91..5ad5573 100644 --- a/src/Extra/ExtraProvider.php +++ b/src/Extra/ExtraProvider.php @@ -13,13 +13,11 @@ use Dot\ErrorHandler\Extra\Provider\TraceProvider; use function array_flip; -use function array_key_exists; -use function array_map; -use function assert; -use function class_exists; -use function count; +use function is_a; use function is_array; +use function is_int; use function is_string; +use function strtolower; class ExtraProvider { @@ -32,6 +30,9 @@ class ExtraProvider private SessionProvider $session; private TraceProvider $trace; + /** + * @param array $options provider class => provider options; validated here + */ public function __construct(array $options = []) { $extras = [ @@ -44,52 +45,58 @@ public function __construct(array $options = []) ]; foreach ($extras as $extraKey => $extraClass) { - if ( - ! array_key_exists($extraClass, $options) - || ! array_key_exists('enabled', $options[$extraClass]) - || $options[$extraClass]['enabled'] === false - ) { + $providerOptions = $options[$extraClass] ?? null; + if (! is_array($providerOptions) || ($providerOptions['enabled'] ?? false) !== true) { $this->$extraKey = new $extraClass(false); continue; } + $processorOptions = $providerOptions['processor'] ?? null; + $processorClass = is_array($processorOptions) ? ($processorOptions['class'] ?? null) : null; if ( - ! array_key_exists('processor', $options[$extraClass]) - || ! is_array($options[$extraClass]['processor']) - || ! array_key_exists('class', $options[$extraClass]['processor']) - || ! is_string($options[$extraClass]['processor']['class']) - || ! class_exists($options[$extraClass]['processor']['class']) + ! is_array($processorOptions) + || ! is_string($processorClass) + || ! is_a($processorClass, ProcessorInterface::class, true) ) { $this->$extraKey = new $extraClass(true); continue; } - $sensitiveParameters = []; - if ( - array_key_exists('sensitiveParameters', $options[$extraClass]['processor']) - && is_array($options[$extraClass]['processor']['sensitiveParameters']) - && count($options[$extraClass]['processor']['sensitiveParameters']) > 0 - ) { - $sensitiveParameters = $options[$extraClass]['processor']['sensitiveParameters']; - $sensitiveParameters = array_map('strtolower', $sensitiveParameters); - $sensitiveParameters = array_flip($sensitiveParameters); - } - - $replacementStrategy = ReplacementStrategy::Full; - if ( - array_key_exists('replacementStrategy', $options[$extraClass]['processor']) - && $options[$extraClass]['processor']['replacementStrategy'] instanceof ReplacementStrategy - ) { - $replacementStrategy = $options[$extraClass]['processor']['replacementStrategy']; + $replacementStrategy = $processorOptions['replacementStrategy'] ?? null; + if (! $replacementStrategy instanceof ReplacementStrategy) { + $replacementStrategy = ReplacementStrategy::Full; } - $processor = new $options[$extraClass]['processor']['class']($sensitiveParameters, $replacementStrategy); - assert($processor instanceof ProcessorInterface); + $processor = new $processorClass( + $this->normalizeSensitiveParameters($processorOptions['sensitiveParameters'] ?? null), + $replacementStrategy + ); $this->$extraKey = new $extraClass(true, $processor); } } + /** + * Keeps string and integer names only, lower-cased, as keys. + * + * @return array + */ + private function normalizeSensitiveParameters(mixed $sensitiveParameters): array + { + if (! is_array($sensitiveParameters)) { + return []; + } + + $names = []; + foreach ($sensitiveParameters as $name) { + if (is_string($name) || is_int($name)) { + $names[] = strtolower((string) $name); + } + } + + return array_flip($names); + } + public function getCookie(): CookieProvider { return $this->cookie; diff --git a/src/Extra/Processor/AbstractProcessor.php b/src/Extra/Processor/AbstractProcessor.php index 1ff5353..1d3f067 100644 --- a/src/Extra/Processor/AbstractProcessor.php +++ b/src/Extra/Processor/AbstractProcessor.php @@ -5,25 +5,37 @@ namespace Dot\ErrorHandler\Extra\Processor; use Dot\ErrorHandler\Extra\ReplacementStrategy; +use Stringable; +use function get_debug_type; +use function is_scalar; +use function mb_strlen; +use function mb_substr; use function preg_replace_callback; use function round; use function sprintf; -use function str_pad; use function str_repeat; -use function strlen; -use function substr; abstract class AbstractProcessor implements ProcessorInterface { + /** + * @param array $sensitiveParameters sensitive names, lower-cased, as keys + */ public function __construct( protected array $sensitiveParameters = [], protected ReplacementStrategy $replacementStrategy = ReplacementStrategy::Full, ) { } + /** + * @param array $data + * @return array + */ abstract public function process(array $data): array; + /** + * @return array + */ public function getSensitiveParameters(): array { return $this->sensitiveParameters; @@ -39,20 +51,39 @@ public function replace( string $subject, string $replacement = ProcessorInterface::ALL ): string { - return match ($replacementStrategy->name) { - ReplacementStrategy::Full->name => $this->replaceFull($subject, $replacement), - ReplacementStrategy::Partial->name => $this->replacePartial($subject, $replacement), + return match ($replacementStrategy) { + ReplacementStrategy::Full => $this->replaceFull($subject, $replacement), + ReplacementStrategy::Partial => $this->replacePartial($subject, $replacement), }; } + /** + * Lengths are counted in characters, so that masking never splits a multibyte character. + */ private function replaceFull(string $subject, string $replacement = ProcessorInterface::ALL): string { - return str_repeat($replacement, strlen($subject)); + return str_repeat($replacement, mb_strlen($subject)); } private function replacePartial(string $subject, string $replacement = ProcessorInterface::ALL): string { - return str_pad(substr($subject, 0, (int) round(strlen($subject) / 2)), strlen($subject), $replacement); + $length = mb_strlen($subject); + $keep = (int) round($length / 2); + + return mb_substr($subject, 0, $keep) . str_repeat($replacement, $length - $keep); + } + + /** + * String form of any value, for masking: scalars and Stringable objects as themselves, + * anything else as its type name, so that processing never throws or warns. + */ + protected function stringify(mixed $value): string + { + return match (true) { + $value === null, is_scalar($value) => (string) $value, + $value instanceof Stringable => (string) $value, + default => get_debug_type($value), + }; } public function replaceInlineCookieValues(ReplacementStrategy $replacementStrategy, string $header): string diff --git a/src/Extra/Processor/CookieProcessor.php b/src/Extra/Processor/CookieProcessor.php index b4d858f..30f6871 100644 --- a/src/Extra/Processor/CookieProcessor.php +++ b/src/Extra/Processor/CookieProcessor.php @@ -9,6 +9,10 @@ class CookieProcessor extends AbstractProcessor { + /** + * @param array $data + * @return array + */ public function process(array $data): array { if (count($this->sensitiveParameters) === 0 || count($data) === 0) { @@ -20,7 +24,7 @@ public function process(array $data): array foreach ($data as $cookieName => $cookieValue) { if ( ! isset($this->sensitiveParameters[ProcessorInterface::ALL]) - && ! isset($this->sensitiveParameters[strtolower($cookieName)]) + && ! isset($this->sensitiveParameters[strtolower((string) $cookieName)]) ) { $return[$cookieName] = $cookieValue; continue; diff --git a/src/Extra/Processor/HeaderProcessor.php b/src/Extra/Processor/HeaderProcessor.php index cd65020..4de8149 100644 --- a/src/Extra/Processor/HeaderProcessor.php +++ b/src/Extra/Processor/HeaderProcessor.php @@ -11,6 +11,10 @@ class HeaderProcessor extends AbstractProcessor { + /** + * @param array $data + * @return array + */ public function process(array $data): array { if (count($data) === 0) { @@ -23,18 +27,20 @@ public function process(array $data): array if (is_array($headerValue)) { $headerValue = implode('; ', $headerValue); } + $name = strtolower((string) $headerName); + $isCookie = $name === 'cookie'; if ( ! isset($this->sensitiveParameters[ProcessorInterface::ALL]) - && ! isset($this->sensitiveParameters[strtolower($headerName)]) - && $headerName !== 'cookie' + && ! isset($this->sensitiveParameters[$name]) + && ! $isCookie ) { $return[$headerName] = $headerValue; continue; } - $return[$headerName] = $headerName === 'cookie' - ? $this->replaceInlineCookieValues($this->replacementStrategy, $headerValue) - : $this->replace($this->replacementStrategy, (string) $headerValue); + $return[$headerName] = $isCookie + ? $this->replaceInlineCookieValues($this->replacementStrategy, $this->stringify($headerValue)) + : $this->replace($this->replacementStrategy, $this->stringify($headerValue)); } return $return; diff --git a/src/Extra/Processor/ProcessorInterface.php b/src/Extra/Processor/ProcessorInterface.php index e6e4bf7..5730521 100644 --- a/src/Extra/Processor/ProcessorInterface.php +++ b/src/Extra/Processor/ProcessorInterface.php @@ -10,8 +10,15 @@ interface ProcessorInterface { public const ALL = '*'; + /** + * @param array $data + * @return array + */ public function process(array $data): array; + /** + * @return array sensitive names, lower-cased, as keys + */ public function getSensitiveParameters(): array; public function getReplacementStrategy(): ReplacementStrategy; diff --git a/src/Extra/Processor/RequestProcessor.php b/src/Extra/Processor/RequestProcessor.php index e3ad559..c02c0b8 100644 --- a/src/Extra/Processor/RequestProcessor.php +++ b/src/Extra/Processor/RequestProcessor.php @@ -13,6 +13,10 @@ class RequestProcessor extends AbstractProcessor { + /** + * @param array $data + * @return array + */ public function process(array $data): array { if (count($this->sensitiveParameters) === 0 || count($data) === 0) { @@ -26,9 +30,10 @@ public function process(array $data): array if (is_array($value)) { $return[$key] = $this->process($value); } else { + $name = strtolower((string) $key); $matches = array_filter( $sensitiveParameters, - static fn (string $sensitiveParameter) => str_contains(strtolower($key), $sensitiveParameter) + static fn (int|string $parameter): bool => str_contains($name, (string) $parameter) ); if (! isset($this->sensitiveParameters[ProcessorInterface::ALL]) && count($matches) === 0) { @@ -36,7 +41,7 @@ public function process(array $data): array continue; } - $return[$key] = $this->replace($this->replacementStrategy, (string) $value); + $return[$key] = $this->replace($this->replacementStrategy, $this->stringify($value)); } } diff --git a/src/Extra/Processor/ServerProcessor.php b/src/Extra/Processor/ServerProcessor.php index 4676a08..f79c606 100644 --- a/src/Extra/Processor/ServerProcessor.php +++ b/src/Extra/Processor/ServerProcessor.php @@ -10,11 +10,19 @@ use function count; use function explode; use function implode; +use function is_array; +use function preg_split; use function sprintf; use function strtolower; +use const PREG_SPLIT_NO_EMPTY; + class ServerProcessor extends AbstractProcessor { + /** + * @param array $data + * @return array + */ public function process(array $data): array { if (count($data) === 0) { @@ -25,7 +33,7 @@ public function process(array $data): array foreach ($data as $serverKey => $serverValue) { if ($serverKey === 'HTTP_COOKIE') { - $serverValue = $this->stringToAssociativeArray($serverValue); + $serverValue = $this->stringToAssociativeArray($this->stringify($serverValue)); $serverValue = $this->process($serverValue); $serverValue = $this->associativeArrayToString($serverValue); @@ -35,21 +43,34 @@ public function process(array $data): array if ( ! isset($this->sensitiveParameters[ProcessorInterface::ALL]) - && ! isset($this->sensitiveParameters[strtolower($serverKey)]) + && ! isset($this->sensitiveParameters[strtolower((string) $serverKey)]) ) { $return[$serverKey] = $serverValue; continue; } - $return[$serverKey] = $this->replace($this->replacementStrategy, (string) $serverValue); + $return[$serverKey] = is_array($serverValue) + ? array_map( + fn (mixed $item): string => $this->replace($this->replacementStrategy, $this->stringify($item)), + $serverValue + ) + : $this->replace($this->replacementStrategy, $this->stringify($serverValue)); } return $return; } + /** + * Splits a Cookie header value into name => value pairs. + * Pairs are separated by ";" with optional whitespace around it. + * + * @return array + */ private function stringToAssociativeArray(string $subject): array { - return array_reduce(explode('; ', $subject), static function (array $result, string $keyValue): array { + $pairs = preg_split('/\s*;\s*/', $subject, -1, PREG_SPLIT_NO_EMPTY) ?: []; + + return array_reduce($pairs, static function (array $result, string $keyValue): array { $keyValue = explode('=', $keyValue, 2); $result[$keyValue[0]] = $keyValue[1] ?? ''; @@ -57,10 +78,13 @@ private function stringToAssociativeArray(string $subject): array }, []); } + /** + * @param array $subject + */ private function associativeArrayToString(array $subject): string { $subject = array_map( - static fn(string $key, string $value) => sprintf('%s=%s', $key, $value), + static fn (int|string $key, string $value): string => sprintf('%s=%s', $key, $value), array_keys($subject), $subject ); diff --git a/src/Extra/Processor/SessionProcessor.php b/src/Extra/Processor/SessionProcessor.php index 813cf54..0bca192 100644 --- a/src/Extra/Processor/SessionProcessor.php +++ b/src/Extra/Processor/SessionProcessor.php @@ -7,13 +7,18 @@ use Laminas\Stdlib\ArrayUtils; use function array_map; +use function is_iterable; class SessionProcessor extends AbstractProcessor { + /** + * @param array $data + * @return array + */ public function process(array $data): array { return array_map( - static fn (iterable $container): array => ArrayUtils::iteratorToArray($container), + static fn (mixed $value): mixed => is_iterable($value) ? ArrayUtils::iteratorToArray($value) : $value, $data ); } diff --git a/src/Extra/Processor/TraceProcessor.php b/src/Extra/Processor/TraceProcessor.php index 91cb6bf..5efc198 100644 --- a/src/Extra/Processor/TraceProcessor.php +++ b/src/Extra/Processor/TraceProcessor.php @@ -9,11 +9,15 @@ class TraceProcessor extends AbstractProcessor { + /** + * @param array $data + * @return array + */ public function process(array $data): array { return array_map( /** - * @param string[] $trace + * @param array{file?: string, line?: int, function?: string, class?: string, type?: string} $trace */ static fn (array $trace): string => sprintf( '%s%s%s:%d', diff --git a/src/Extra/Provider/AbstractProvider.php b/src/Extra/Provider/AbstractProvider.php index dc45c56..00d1381 100644 --- a/src/Extra/Provider/AbstractProvider.php +++ b/src/Extra/Provider/AbstractProvider.php @@ -24,5 +24,9 @@ public function getProcessor(): ?ProcessorInterface return $this->processor; } + /** + * @param array $data + * @return array + */ abstract public function provide(array $data): array; } diff --git a/src/Extra/Provider/CookieProvider.php b/src/Extra/Provider/CookieProvider.php index 9e68fb3..cdde01c 100644 --- a/src/Extra/Provider/CookieProvider.php +++ b/src/Extra/Provider/CookieProvider.php @@ -8,6 +8,10 @@ class CookieProvider extends AbstractProvider { + /** + * @param array $data + * @return array + */ public function provide(array $data): array { if ($this->processor instanceof ProcessorInterface) { diff --git a/src/Extra/Provider/HeaderProvider.php b/src/Extra/Provider/HeaderProvider.php index 5d838aa..706a4ea 100644 --- a/src/Extra/Provider/HeaderProvider.php +++ b/src/Extra/Provider/HeaderProvider.php @@ -8,6 +8,10 @@ class HeaderProvider extends AbstractProvider { + /** + * @param array $data + * @return array + */ public function provide(array $data): array { if ($this->processor instanceof ProcessorInterface) { diff --git a/src/Extra/Provider/ProviderInterface.php b/src/Extra/Provider/ProviderInterface.php index ce3c9f5..7942196 100644 --- a/src/Extra/Provider/ProviderInterface.php +++ b/src/Extra/Provider/ProviderInterface.php @@ -12,5 +12,9 @@ public function isEnabled(): bool; public function getProcessor(): ?ProcessorInterface; + /** + * @param array $data + * @return array + */ public function provide(array $data): array; } diff --git a/src/Extra/Provider/RequestProvider.php b/src/Extra/Provider/RequestProvider.php index 677d263..b1a64bd 100644 --- a/src/Extra/Provider/RequestProvider.php +++ b/src/Extra/Provider/RequestProvider.php @@ -8,6 +8,10 @@ class RequestProvider extends AbstractProvider { + /** + * @param array $data + * @return array + */ public function provide(array $data): array { if ($this->processor instanceof ProcessorInterface) { diff --git a/src/Extra/Provider/ServerProvider.php b/src/Extra/Provider/ServerProvider.php index 2b196a4..b97a92e 100644 --- a/src/Extra/Provider/ServerProvider.php +++ b/src/Extra/Provider/ServerProvider.php @@ -8,6 +8,10 @@ class ServerProvider extends AbstractProvider { + /** + * @param array $data + * @return array + */ public function provide(array $data): array { if ($this->processor instanceof ProcessorInterface) { diff --git a/src/Extra/Provider/SessionProvider.php b/src/Extra/Provider/SessionProvider.php index 09b4449..0714284 100644 --- a/src/Extra/Provider/SessionProvider.php +++ b/src/Extra/Provider/SessionProvider.php @@ -8,6 +8,10 @@ class SessionProvider extends AbstractProvider { + /** + * @param array $data + * @return array + */ public function provide(array $data): array { if ($this->processor instanceof ProcessorInterface) { diff --git a/src/Extra/Provider/TraceProvider.php b/src/Extra/Provider/TraceProvider.php index 989091f..123273b 100644 --- a/src/Extra/Provider/TraceProvider.php +++ b/src/Extra/Provider/TraceProvider.php @@ -8,6 +8,10 @@ class TraceProvider extends AbstractProvider { + /** + * @param array $data + * @return array + */ public function provide(array $data): array { if ($this->processor instanceof ProcessorInterface) { diff --git a/src/LogErrorHandler.php b/src/LogErrorHandler.php index 0fffaa8..1f5609f 100644 --- a/src/LogErrorHandler.php +++ b/src/LogErrorHandler.php @@ -5,31 +5,16 @@ namespace Dot\ErrorHandler; use Dot\ErrorHandler\Extra\ExtraProvider; -use ErrorException; -use Laminas\Stratigility\Middleware\ErrorResponseGenerator; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\MiddlewareInterface; -use Psr\Http\Server\RequestHandlerInterface; use Psr\Log\LoggerInterface; use Throwable; -use function error_reporting; -use function in_array; -use function restore_error_handler; -use function set_error_handler; +use function error_log; +use function sprintf; -class LogErrorHandler implements MiddlewareInterface, ErrorHandlerInterface +class LogErrorHandler extends ErrorHandler { - /** @var callable[] */ - private $listeners = []; - /** @var callable|null Routine that will generate the error response. */ - private $responseGenerator; - /** @var callable */ - private $responseFactory; - private ?LoggerInterface $logger; - private ?ExtraProvider $extraProvider; - /** * @param callable $responseFactory A factory capable of returning an * empty ResponseInterface instance to update and return when returning @@ -40,98 +25,50 @@ class LogErrorHandler implements MiddlewareInterface, ErrorHandlerInterface public function __construct( callable $responseFactory, ?callable $responseGenerator = null, - ?LoggerInterface $logger = null, - ?ExtraProvider $extraProvider = null, + private readonly ?LoggerInterface $logger = null, + private readonly ?ExtraProvider $extraProvider = null, ) { - $this->responseFactory = static function () use ($responseFactory): ResponseInterface { - return $responseFactory(); - }; - $this->responseGenerator = $responseGenerator ?: new ErrorResponseGenerator(); - $this->logger = $logger; - $this->extraProvider = $extraProvider; - } - - public function attachListener(callable $listener): void - { - if (in_array($listener, $this->listeners, true)) { - return; - } - - $this->listeners[] = $listener; - } - - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { - set_error_handler($this->createErrorHandler()); - - try { - $response = $handler->handle($request); - } catch (Throwable $e) { - $response = $this->handleThrowable($e, $request); - } - - restore_error_handler(); - - return $response; + parent::__construct($responseFactory, $responseGenerator); } /** * Handles all throwables, generating and returning a response. * - * Passes the error, request, and response prototype to createErrorResponse(), - * triggers all listeners with the same arguments (but using the response - * returned from createErrorResponse()), and then returns the response. - * * If a valid Logger is available, the error and its message are logged in the - * configured format. + * configured format before the error response is generated and the listeners + * are triggered. Logging never prevents the error response: if the logger or an + * extra-data processor fails, the original error is written to PHP's error_log(). */ public function handleThrowable(Throwable $e, ServerRequestInterface $request): ResponseInterface { - $generator = $this->responseGenerator; - if ($this->logger instanceof LoggerInterface) { - $extra = $this->provideExtra($e, $request); - $this->logger->error($e->getMessage(), $extra); - } + $this->log($e, $request); - $response = $generator($e, $request, ($this->responseFactory)()); - $this->triggerListeners($e, $request, $response); - - return $response; + return parent::handleThrowable($e, $request); } - /** - * Creates and returns a callable error handler that raises exceptions. - * - * Only raises exceptions for errors that are within the error_reporting mask. - */ - public function createErrorHandler(): callable + private function log(Throwable $e, ServerRequestInterface $request): void { - /** - * @throws ErrorException if error is not within the error_reporting mask. - */ - return static function (int $errno, string $errstr, string $errfile, int $errline): void { - if (! (error_reporting() & $errno)) { - // error_reporting does not include this error - return; - } - - throw new ErrorException($errstr, 0, $errno, $errfile, $errline); - }; - } + if (! $this->logger instanceof LoggerInterface) { + return; + } - /** - * Trigger all error listeners. - */ - public function triggerListeners( - Throwable $error, - ServerRequestInterface $request, - ResponseInterface $response - ): void { - foreach ($this->listeners as $listener) { - $listener($error, $request, $response); + try { + $this->logger->error($e->getMessage(), $this->provideExtra($e, $request)); + } catch (Throwable $failure) { + error_log(sprintf( + '%s: %s in %s:%d (logging failed: %s)', + $e::class, + $e->getMessage(), + $e->getFile(), + $e->getLine(), + $failure->getMessage() + )); } } + /** + * @return array + */ public function provideExtra(Throwable $throwable, ServerRequestInterface $request): array { $extra = [ diff --git a/src/LogErrorHandlerFactory.php b/src/LogErrorHandlerFactory.php index 9f89f74..2c2ccb0 100644 --- a/src/LogErrorHandlerFactory.php +++ b/src/LogErrorHandlerFactory.php @@ -11,12 +11,13 @@ use Psr\Container\ContainerInterface; use Psr\Container\NotFoundExceptionInterface; use Psr\Http\Message\ResponseInterface; -use Psr\Http\Server\MiddlewareInterface; use Psr\Log\LoggerInterface; -use function array_key_exists; use function count; use function is_array; +use function is_bool; +use function is_callable; +use function is_string; use function sprintf; class LogErrorHandlerFactory @@ -27,47 +28,78 @@ class LogErrorHandlerFactory /** * @throws NotFoundExceptionInterface * @throws ContainerExceptionInterface + * @throws InvalidArgumentException When the configuration or a configured service is invalid. */ - public function __invoke(ContainerInterface $container): MiddlewareInterface + public function __invoke(ContainerInterface $container): LogErrorHandler { $errorHandlerConfig = $container->get('config')[self::ERROR_HANDLER_KEY] ?? null; if (! is_array($errorHandlerConfig)) { throw new InvalidArgumentException(sprintf('\'[%s\'] not found in config', self::ERROR_HANDLER_KEY)); } - if ($errorHandlerConfig['loggerEnabled'] && ! isset($errorHandlerConfig[self::ERROR_HANDLER_LOGGER_KEY])) { + $responseFactory = $container->get(ResponseInterface::class); + if (! is_callable($responseFactory)) { throw new InvalidArgumentException( - sprintf( - 'Logger: \'[%s\'] is enabled, but not found in config', - self::ERROR_HANDLER_LOGGER_KEY - ) + sprintf('Service "%s" must be a callable response factory', ResponseInterface::class) ); } - $logger = null; - if ($errorHandlerConfig['loggerEnabled']) { - /** @var LoggerInterface $logger */ - $logger = $container->get($errorHandlerConfig[self::ERROR_HANDLER_LOGGER_KEY]); - } - $generator = $container->has(ErrorResponseGenerator::class) ? $container->get(ErrorResponseGenerator::class) : null; + if ($generator !== null && ! is_callable($generator)) { + throw new InvalidArgumentException( + sprintf('Service "%s" must be callable', ErrorResponseGenerator::class) + ); + } $extraProvider = null; - if ( - array_key_exists(ExtraProvider::CONFIG_KEY, $errorHandlerConfig) - && is_array($errorHandlerConfig[ExtraProvider::CONFIG_KEY]) - && count($errorHandlerConfig[ExtraProvider::CONFIG_KEY]) > 0 - ) { - $extraProvider = new ExtraProvider($errorHandlerConfig[ExtraProvider::CONFIG_KEY]); + $extraConfig = $errorHandlerConfig[ExtraProvider::CONFIG_KEY] ?? null; + if (is_array($extraConfig) && count($extraConfig) > 0) { + $extraProvider = new ExtraProvider($extraConfig); } return new LogErrorHandler( - $container->get(ResponseInterface::class), + $responseFactory, $generator, - $logger, + $this->createLogger($container, $errorHandlerConfig), $extraProvider ); } + + /** + * @param array $errorHandlerConfig + * @throws NotFoundExceptionInterface + * @throws ContainerExceptionInterface + */ + private function createLogger(ContainerInterface $container, array $errorHandlerConfig): ?LoggerInterface + { + $loggerEnabled = $errorHandlerConfig['loggerEnabled'] ?? false; + if (! is_bool($loggerEnabled)) { + throw new InvalidArgumentException('Logger: \'[loggerEnabled\'] must be a boolean'); + } + + if (! $loggerEnabled) { + return null; + } + + $loggerService = $errorHandlerConfig[self::ERROR_HANDLER_LOGGER_KEY] ?? null; + if (! is_string($loggerService)) { + throw new InvalidArgumentException( + sprintf( + 'Logger: \'[%s\'] is enabled, but not found in config', + self::ERROR_HANDLER_LOGGER_KEY + ) + ); + } + + $logger = $container->get($loggerService); + if (! $logger instanceof LoggerInterface) { + throw new InvalidArgumentException( + sprintf('Service "%s" must implement %s', $loggerService, LoggerInterface::class) + ); + } + + return $logger; + } } diff --git a/test/ConfigProviderTest.php b/test/ConfigProviderTest.php index 4b3ec7a..f26fe51 100644 --- a/test/ConfigProviderTest.php +++ b/test/ConfigProviderTest.php @@ -12,6 +12,7 @@ class ConfigProviderTest extends TestCase { + /** @var array>> */ private array $config; protected function setUp(): void diff --git a/test/DistConfigTest.php b/test/DistConfigTest.php new file mode 100644 index 0000000..0a832b8 --- /dev/null +++ b/test/DistConfigTest.php @@ -0,0 +1,38 @@ +>}} $config */ + $config = require __DIR__ . '/../config/error-handling.global.php.dist'; + $options = $config['dot-errorhandler'][ExtraProvider::CONFIG_KEY]; + + $options[HeaderProvider::class]['enabled'] = true; + + $output = (new ExtraProvider($options))->getHeader()->provide([ + 'Authorization' => ['Bearer abc'], + 'Proxy-Authorization' => ['Basic xyz'], + 'Accept' => ['*/*'], + ]); + + $this->assertSame( + ['Authorization' => '**********', 'Proxy-Authorization' => '*********', 'Accept' => '*/*'], + $output + ); + } +} diff --git a/test/ErrorHandlerFactoryTest.php b/test/ErrorHandlerFactoryTest.php index a30aab8..42f327f 100644 --- a/test/ErrorHandlerFactoryTest.php +++ b/test/ErrorHandlerFactoryTest.php @@ -6,66 +6,116 @@ use Dot\ErrorHandler\ErrorHandler; use Dot\ErrorHandler\ErrorHandlerFactory; +use DotTest\ErrorHandler\Fixture\InMemoryContainer; +use DotTest\ErrorHandler\Fixture\ThrowingHandler; +use InvalidArgumentException; +use Laminas\Diactoros\Response; +use Laminas\Diactoros\ServerRequest; use Mezzio\Middleware\ErrorResponseGenerator; -use PHPUnit\Framework\MockObject\Exception; -use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerExceptionInterface; -use Psr\Container\ContainerInterface; use Psr\Container\NotFoundExceptionInterface; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use RuntimeException; +use Throwable; + +use function sprintf; class ErrorHandlerFactoryTest extends TestCase { - private MockObject|ContainerInterface $container; - /** @var callable $responseFactory */ - private $responseFactory; - /** - * @throws Exception + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface */ - public function setUp(): void + public function testWillCreateWithDefaultOption(): void { - $this->container = $this->createMock(ContainerInterface::class); - $this->responseFactory = fn(): ResponseInterface => $this->createMock(ResponseInterface::class); + $container = new InMemoryContainer([ + ResponseInterface::class => static fn (): ResponseInterface => new Response(), + ]); + + $result = (new ErrorHandlerFactory())($container); + + $this->assertSame(ErrorHandler::class, $result::class); + $response = $result->process(new ServerRequest(), new ThrowingHandler(new RuntimeException('boom'))); + $this->assertSame(500, $response->getStatusCode()); } /** * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface */ - public function testWillCreateWithDefaultOption(): void + public function testWillCreateWithErrorResponseGenerator(): void { - $this->container->method('has') - ->with(ErrorResponseGenerator::class) - ->willReturn(false); + $container = new InMemoryContainer([ + ErrorResponseGenerator::class => new ErrorResponseGenerator(), + ResponseInterface::class => static fn (): ResponseInterface => new Response(), + ]); - $this->container->method('get') - ->with(ResponseInterface::class) - ->willReturn($this->responseFactory); + $result = (new ErrorHandlerFactory())($container); - $result = (new ErrorHandlerFactory())($this->container); $this->assertSame(ErrorHandler::class, $result::class); + $response = $result->process(new ServerRequest(), new ThrowingHandler(new RuntimeException('boom'))); + $this->assertSame(500, $response->getStatusCode()); } /** + * F17: any callable generator is accepted, whatever zend.assertions is set to. + * * @throws ContainerExceptionInterface - * @throws Exception * @throws NotFoundExceptionInterface */ - public function testWillCreateWithErrorResponseGenerator(): void + public function testWillUseAnyCallableErrorResponseGenerator(): void { - $this->container->method('has') - ->with(ErrorResponseGenerator::class) - ->willReturn(true); + $container = new InMemoryContainer([ + ErrorResponseGenerator::class => static fn ( + Throwable $e, + ServerRequestInterface $request, + ResponseInterface $response + ): ResponseInterface => $response->withStatus(418), + ResponseInterface::class => static fn (): ResponseInterface => new Response(), + ]); - $this->container->method('get') - ->willReturnMap([ - [ErrorResponseGenerator::class, $this->createMock(ErrorResponseGenerator::class)], - [ResponseInterface::class, $this->responseFactory], - ]); + $result = (new ErrorHandlerFactory())($container); + $response = $result->process(new ServerRequest(), new ThrowingHandler(new RuntimeException('boom'))); - $result = (new ErrorHandlerFactory())($this->container); - $this->assertSame(ErrorHandler::class, $result::class); + $this->assertSame(418, $response->getStatusCode()); + } + + /** + * F17: validated with an exception, not with assert(), which is compiled out in production. + * + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testWillNotCreateWhenResponseFactoryIsNotCallable(): void + { + $container = new InMemoryContainer([ResponseInterface::class => 'not callable']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + sprintf('Service "%s" must be a callable response factory', ResponseInterface::class) + ); + + (new ErrorHandlerFactory())($container); + } + + /** + * F17 + * + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testWillNotCreateWhenErrorResponseGeneratorIsNotCallable(): void + { + $container = new InMemoryContainer([ + ErrorResponseGenerator::class => 'not callable', + ResponseInterface::class => static fn (): ResponseInterface => new Response(), + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf('Service "%s" must be callable', ErrorResponseGenerator::class)); + + (new ErrorHandlerFactory())($container); } } diff --git a/test/ErrorHandlerTest.php b/test/ErrorHandlerTest.php index 0f2b2a5..6c71775 100644 --- a/test/ErrorHandlerTest.php +++ b/test/ErrorHandlerTest.php @@ -4,49 +4,36 @@ namespace DotTest\ErrorHandler; -use Dot\ErrorHandler\ErrorHandler; use Dot\ErrorHandler\ErrorHandler as Subject; +use DotTest\ErrorHandler\Fixture\ResponseHandler; +use DotTest\ErrorHandler\Fixture\ThrowingHandler; use ErrorException; +use Laminas\Diactoros\Response; +use Laminas\Diactoros\ServerRequest; use Laminas\Stratigility\Middleware\ErrorResponseGenerator; -use PHPUnit\Framework\MockObject\Exception; -use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Message\StreamInterface; -use Psr\Http\Server\RequestHandlerInterface; -use ReflectionObject; use RuntimeException; use Throwable; use function error_reporting; +use function restore_error_handler; +use function set_error_handler; class ErrorHandlerTest extends TestCase { private Subject $subject; - private MockObject&ServerRequestInterface $serverRequest; - private MockObject&ResponseInterface $response; - private ErrorResponseGenerator $errorResponseGenerator; - /** @var callable():ResponseInterface $responseFactory */ - private $responseFactory; - private MockObject&StreamInterface $body; - private MockObject&RequestHandlerInterface $handler; + private ServerRequestInterface $serverRequest; + private ResponseInterface $response; private Throwable $exception; - /** - * @throws Exception - */ public function setUp(): void { - $this->response = $this->createMock(ResponseInterface::class); - $this->serverRequest = $this->createMock(ServerRequestInterface::class); - $this->body = $this->createMock(StreamInterface::class); - $this->handler = $this->createMock(RequestHandlerInterface::class); - - $this->responseFactory = fn(): ResponseInterface => $this->response; - $this->errorResponseGenerator = new ErrorResponseGenerator(); - $this->subject = new ErrorHandler($this->responseFactory, $this->errorResponseGenerator); - $this->exception = new RuntimeException('Not Implemented', 501); + $this->response = new Response(); + $this->serverRequest = new ServerRequest(); + $this->exception = new RuntimeException('Not Implemented', 501); + $this->subject = new Subject(fn (): ResponseInterface => $this->response, new ErrorResponseGenerator()); } public function testWillCreateWithDefaultParameters(): void @@ -75,81 +62,38 @@ public function testCreateErrorHandlerSkipsErrorsOutsideErrorReportingMask(): vo public function testAttachListenerDoesNotAttachDuplicates(): void { - $listener = static function (): void { + $calls = 0; + $listener = static function () use (&$calls): void { + $calls++; }; $this->subject->attachListener($listener); $this->subject->attachListener($listener); + $this->subject->triggerListeners($this->exception, $this->serverRequest, $this->response); - $ref = new ReflectionObject($this->subject); - $listeners = $ref->getProperty('listeners'); - - $this->assertContains($listener, $listeners->getValue($this->subject)); - $this->assertCount(1, $listeners->getValue($this->subject)); + $this->assertSame(1, $calls); } public function testHandleThrowable(): void { - $this->body - ->expects(self::once()) - ->method('write') - ->with('Not Implemented') - ->willReturn(0); - - $this->response - ->method('getStatusCode') - ->willReturn(501); - $this->response - ->method('withStatus') - ->with(501) - ->willReturnSelf(); - $this->response - ->method('getBody') - ->willReturn($this->body); - $this->response - ->method('getReasonPhrase') - ->willReturn('Not Implemented'); - - $response = ($this->errorResponseGenerator)($this->exception, $this->serverRequest, ($this->responseFactory)()); - - $this->assertContainsOnlyInstancesOf(ResponseInterface::class, [$response]); + $response = $this->subject->handleThrowable($this->exception, $this->serverRequest); + + $this->assertSame(501, $response->getStatusCode()); + $this->assertSame('Not Implemented', (string) $response->getBody()); } public function testErrorHandlingTriggersListeners(): void { - $this->handler - ->method('handle') - ->with($this->serverRequest) - ->willThrowException($this->exception); - - $this->body - ->expects(self::once()) - ->method('write') - ->with('Not Implemented') - ->willReturn(0); - - $this->response - ->method('getStatusCode') - ->willReturn(501); - $this->response - ->method('withStatus') - ->with(501) - ->willReturnSelf(); - $this->response - ->method('getBody') - ->willReturn($this->body); - $this->response - ->method('getReasonPhrase') - ->willReturn('Not Implemented'); - + $calls = 0; $listener = function ( Throwable $error, ServerRequestInterface $request, ResponseInterface $response - ): void { + ) use (&$calls): void { + $calls++; $this->assertSame($this->exception, $error); $this->assertSame($this->serverRequest, $request); - $this->assertSame($this->response, $response); + $this->assertSame(501, $response->getStatusCode()); }; $listener2 = clone $listener; @@ -157,8 +101,64 @@ public function testErrorHandlingTriggersListeners(): void $this->subject->attachListener($listener); $this->subject->attachListener($listener2); - $result = $this->subject->process($this->serverRequest, $this->handler); + $result = $this->subject->process($this->serverRequest, new ThrowingHandler($this->exception)); + + $this->assertSame(501, $result->getStatusCode()); + $this->assertSame(2, $calls); + } + + public function testProcessReturnsHandlerResponseAndRestoresErrorHandler(): void + { + $sentinel = static fn (): bool => false; + set_error_handler($sentinel); + + try { + $expected = new Response(status: 204); + $result = $this->subject->process($this->serverRequest, new ResponseHandler($expected)); + + $this->assertSame($expected, $result); + $this->assertSame($sentinel, $this->currentErrorHandler()); + } finally { + restore_error_handler(); + } + } + + public function testProcessRestoresErrorHandlerWhenErrorHandlingThrows(): void + { + $sentinel = static fn (): bool => false; + set_error_handler($sentinel); + + try { + $this->subject->attachListener(static function (): void { + throw new RuntimeException('listener failure'); + }); + + try { + $this->subject->process($this->serverRequest, new ThrowingHandler($this->exception)); + $this->fail('Expected the listener exception to propagate'); + } catch (RuntimeException $e) { + $this->assertSame('listener failure', $e->getMessage()); + } + + $this->assertSame($sentinel, $this->currentErrorHandler()); + } finally { + restore_error_handler(); + } + } + + public function testWillUseDefaultResponseGeneratorWhenNoneIsProvided(): void + { + $subject = new Subject(static fn (): ResponseInterface => new Response()); + $response = $subject->handleThrowable($this->exception, $this->serverRequest); + + $this->assertSame(501, $response->getStatusCode()); + } + + private function currentErrorHandler(): ?callable + { + $current = set_error_handler(null); + restore_error_handler(); - $this->assertSame($this->response, $result); + return $current; } } diff --git a/test/Extra/ExtraProviderTest.php b/test/Extra/ExtraProviderTest.php index b4aac48..f37ff37 100644 --- a/test/Extra/ExtraProviderTest.php +++ b/test/Extra/ExtraProviderTest.php @@ -20,7 +20,9 @@ use Dot\ErrorHandler\Extra\Provider\TraceProvider; use Dot\ErrorHandler\Extra\ReplacementStrategy; use Laminas\Stdlib\ArrayObject; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use stdClass; use function array_flip; @@ -72,6 +74,97 @@ public function testWillInstantiateWithInvalidOptions(): void $this->assertNull($extraProvider->getTrace()->getProcessor()); } + /** + * F9: invalid provider options are ignored, not fatal. + */ + public function testWillIgnoreNonArrayProviderOptions(): void + { + $extraProvider = new ExtraProvider([ + CookieProvider::class => true, + HeaderProvider::class => 'enabled', + TraceProvider::class => null, + ]); + + $this->assertFalse($extraProvider->getCookie()->isEnabled()); + $this->assertFalse($extraProvider->getHeader()->isEnabled()); + $this->assertFalse($extraProvider->getTrace()->isEnabled()); + } + + /** + * F9: only the boolean true enables a provider. + */ + #[DataProvider('nonTrueEnabledValues')] + public function testWillOnlyEnableProviderWhenEnabledIsTrue(mixed $enabled): void + { + $extraProvider = new ExtraProvider([CookieProvider::class => ['enabled' => $enabled]]); + + $this->assertFalse($extraProvider->getCookie()->isEnabled()); + } + + /** + * @return array + */ + public static function nonTrueEnabledValues(): array + { + return [ + 'int 1' => [1], + 'int 0' => [0], + 'null' => [null], + 'string yes' => ['yes'], + 'string true' => ['true'], + ]; + } + + /** + * F9: a processor class that does not implement ProcessorInterface is ignored. + */ + public function testWillIgnoreProcessorClassNotImplementingProcessorInterface(): void + { + $extraProvider = new ExtraProvider([ + CookieProvider::class => ['enabled' => true, 'processor' => ['class' => stdClass::class]], + ]); + + $this->assertTrue($extraProvider->getCookie()->isEnabled()); + $this->assertNull($extraProvider->getCookie()->getProcessor()); + } + + /** + * F9: only string and integer sensitive parameter names are kept, lower-cased. + */ + public function testWillIgnoreInvalidSensitiveParameterNames(): void + { + $extraProvider = new ExtraProvider([ + CookieProvider::class => [ + 'enabled' => true, + 'processor' => [ + 'class' => CookieProcessor::class, + 'sensitiveParameters' => ['Token', 123, null, ['nested'], 1.5], + ], + ], + ]); + + $processor = $extraProvider->getCookie()->getProcessor(); + $this->assertInstanceOf(CookieProcessor::class, $processor); + $this->assertSame(['token' => 0, 123 => 1], $processor->getSensitiveParameters()); + } + + /** + * An unknown replacement strategy falls back to Full. + */ + public function testWillUseFullReplacementForInvalidStrategy(): void + { + $extraProvider = new ExtraProvider([ + CookieProvider::class => [ + 'enabled' => true, + 'processor' => ['class' => CookieProcessor::class, 'replacementStrategy' => 'partial'], + ], + ]); + + $processor = $extraProvider->getCookie()->getProcessor(); + $this->assertInstanceOf(CookieProcessor::class, $processor); + $this->assertSame(ReplacementStrategy::Full, $processor->getReplacementStrategy()); + } + public function testWillNotEnableProvidersWhenEmptyProviderOptions(): void { $extraProvider = new ExtraProvider([ diff --git a/test/Extra/Processor/CookieProcessorTest.php b/test/Extra/Processor/CookieProcessorTest.php new file mode 100644 index 0000000..0df792e --- /dev/null +++ b/test/Extra/Processor/CookieProcessorTest.php @@ -0,0 +1,53 @@ +assertSame( + [123 => 'x', 'sid' => '***'], + $processor->process(['123' => 'x', 'sid' => 'abc']) + ); + } + + /** + * F8: partial masking must not split a multibyte character. + */ + public function testPartialMaskingKeepsValidUtf8(): void + { + $processor = new CookieProcessor(array_flip([ProcessorInterface::ALL]), ReplacementStrategy::Partial); + + $output = $processor->process(['city' => 'Brașov', 'name' => 'Zoë']); + + $this->assertSame(['city' => 'Bra***', 'name' => 'Zo*'], $output); + $this->assertTrue(mb_check_encoding(implode('', $output), 'UTF-8')); + } + + /** + * F8: full masking counts characters, not bytes. + */ + public function testFullMaskingCountsCharacters(): void + { + $processor = new CookieProcessor(array_flip([ProcessorInterface::ALL]), ReplacementStrategy::Full); + + $this->assertSame(['city' => '******'], $processor->process(['city' => 'Brașov'])); + } +} diff --git a/test/Extra/Processor/HeaderProcessorTest.php b/test/Extra/Processor/HeaderProcessorTest.php new file mode 100644 index 0000000..31bacf1 --- /dev/null +++ b/test/Extra/Processor/HeaderProcessorTest.php @@ -0,0 +1,39 @@ +assertSame( + ['Cookie' => 'sid=******', 'cookie' => 'sid=******'], + $processor->process(['Cookie' => ['sid=secret'], 'cookie' => ['sid=secret']]) + ); + } + + /** + * F4: a numeric header name arrives as an integer key. + */ + public function testWillProcessNumericHeaderNames(): void + { + $processor = new HeaderProcessor(array_flip(['authorization'])); + + $this->assertSame( + [123 => 'value', 'Authorization' => '**********'], + $processor->process(['123' => ['value'], 'Authorization' => ['Bearer abc']]) + ); + } +} diff --git a/test/Extra/Processor/RequestProcessorTest.php b/test/Extra/Processor/RequestProcessorTest.php new file mode 100644 index 0000000..7686e3e --- /dev/null +++ b/test/Extra/Processor/RequestProcessorTest.php @@ -0,0 +1,73 @@ +assertSame( + ['tags' => ['a', 'b'], 'password' => '******'], + $processor->process(['tags' => ['a', 'b'], 'password' => 'secret']) + ); + } + + /** + * F3: integer keys at the top level. + */ + public function testWillProcessIntegerKeys(): void + { + $processor = new RequestProcessor(array_flip(['password'])); + + $this->assertSame( + [0 => 'a', 'password' => '******'], + $processor->process([0 => 'a', 'password' => 'secret']) + ); + } + + /** + * F3: array_flip() turns the numeric sensitive name '123' into the integer key 123. + */ + public function testWillMatchNumericSensitiveParameterNames(): void + { + $processor = new RequestProcessor(array_flip(['123'])); + + $this->assertSame( + ['code123' => '***', 'other' => 'xyz'], + $processor->process(['code123' => 'abc', 'other' => 'xyz']) + ); + } + + /** + * F3: non-string values are masked from their string form instead of crashing. + */ + public function testWillMaskNonStringValues(): void + { + $processor = new RequestProcessor(array_flip([ProcessorInterface::ALL])); + + $this->assertSame( + ['object' => '********', 'null' => '', 'int' => '**', 'float' => '***', 'bool' => '*'], + $processor->process([ + 'object' => new stdClass(), + 'null' => null, + 'int' => 42, + 'float' => 1.5, + 'bool' => true, + ]) + ); + } +} diff --git a/test/Extra/Processor/ServerProcessorTest.php b/test/Extra/Processor/ServerProcessorTest.php new file mode 100644 index 0000000..7f3a87c --- /dev/null +++ b/test/Extra/Processor/ServerProcessorTest.php @@ -0,0 +1,66 @@ +assertSame( + ['HTTP_COOKIE' => '1=a; b=c'], + $processor->process(['HTTP_COOKIE' => '1=a; b=c']) + ); + } + + /** + * F5: argv is an array whenever register_argc_argv is on (CLI, RoadRunner, Swoole). + */ + public function testWillMaskArrayValuesElementByElement(): void + { + $processor = new ServerProcessor(array_flip([ProcessorInterface::ALL])); + + $this->assertSame( + ['argv' => ['***', '****'], 'argc' => '*'], + $processor->process(['argv' => ['run', '--do'], 'argc' => 2]) + ); + } + + /** + * F5: array values that are not sensitive are left unchanged. + */ + public function testWillKeepArrayValuesThatAreNotSensitive(): void + { + $processor = new ServerProcessor(array_flip(['server_admin'])); + + $this->assertSame( + ['argv' => ['run', '--do']], + $processor->process(['argv' => ['run', '--do']]) + ); + } + + /** + * F7: cookie pairs separated by ';' without a space must still be split. + */ + public function testWillSplitHttpCookieWithoutSpaces(): void + { + $processor = new ServerProcessor(array_flip(['b'])); + + $this->assertSame( + ['HTTP_COOKIE' => 'a=1; b=*******'], + $processor->process(['HTTP_COOKIE' => 'a=1;b=secret2']) + ); + } +} diff --git a/test/Extra/Processor/SessionProcessorTest.php b/test/Extra/Processor/SessionProcessorTest.php new file mode 100644 index 0000000..4ce47bf --- /dev/null +++ b/test/Extra/Processor/SessionProcessorTest.php @@ -0,0 +1,25 @@ +assertSame( + ['user_id' => 5, 'flag' => true, 'container' => ['a' => 1]], + $processor->process(['user_id' => 5, 'flag' => true, 'container' => new ArrayObject(['a' => 1])]) + ); + } +} diff --git a/test/Fixture/InMemoryContainer.php b/test/Fixture/InMemoryContainer.php new file mode 100644 index 0000000..cd4d0e6 --- /dev/null +++ b/test/Fixture/InMemoryContainer.php @@ -0,0 +1,38 @@ + $services + */ + public function __construct( + private readonly array $services = [], + ) { + } + + public function get(string $id): mixed + { + if (! $this->has($id)) { + throw new ServiceNotFoundException(sprintf('Service "%s" not found', $id)); + } + + return $this->services[$id]; + } + + public function has(string $id): bool + { + return array_key_exists($id, $this->services); + } +} diff --git a/test/Fixture/ResponseHandler.php b/test/Fixture/ResponseHandler.php new file mode 100644 index 0000000..9475d4e --- /dev/null +++ b/test/Fixture/ResponseHandler.php @@ -0,0 +1,25 @@ +response; + } +} diff --git a/test/Fixture/ServiceNotFoundException.php b/test/Fixture/ServiceNotFoundException.php new file mode 100644 index 0000000..0ae06c2 --- /dev/null +++ b/test/Fixture/ServiceNotFoundException.php @@ -0,0 +1,12 @@ +}> */ + public array $records = []; + + /** + * @param array $context + */ + public function log(mixed $level, string|Stringable $message, array $context = []): void + { + $this->records[] = [ + 'level' => $level, + 'message' => (string) $message, + 'context' => $context, + ]; + } +} diff --git a/test/Fixture/ThrowingHandler.php b/test/Fixture/ThrowingHandler.php new file mode 100644 index 0000000..84bd4ad --- /dev/null +++ b/test/Fixture/ThrowingHandler.php @@ -0,0 +1,26 @@ +throwable; + } +} diff --git a/test/Fixture/ThrowingLogger.php b/test/Fixture/ThrowingLogger.php new file mode 100644 index 0000000..babcbdc --- /dev/null +++ b/test/Fixture/ThrowingLogger.php @@ -0,0 +1,23 @@ + $context + */ + public function log(mixed $level, string|Stringable $message, array $context = []): void + { + throw new RuntimeException('disk full'); + } +} diff --git a/test/Fixture/ThrowingProcessor.php b/test/Fixture/ThrowingProcessor.php new file mode 100644 index 0000000..3d43166 --- /dev/null +++ b/test/Fixture/ThrowingProcessor.php @@ -0,0 +1,22 @@ + $data + */ + public function process(array $data): never + { + throw new RuntimeException('processor failure'); + } +} diff --git a/test/LogErrorHandlerFactoryTest.php b/test/LogErrorHandlerFactoryTest.php index d64583b..8ad548a 100644 --- a/test/LogErrorHandlerFactoryTest.php +++ b/test/LogErrorHandlerFactoryTest.php @@ -5,34 +5,35 @@ namespace DotTest\ErrorHandler; use Dot\ErrorHandler\Extra\ExtraProvider; +use Dot\ErrorHandler\Extra\Provider\TraceProvider; use Dot\ErrorHandler\LogErrorHandler; use Dot\ErrorHandler\LogErrorHandlerFactory; +use DotTest\ErrorHandler\Fixture\InMemoryContainer; +use DotTest\ErrorHandler\Fixture\TestLogger; +use DotTest\ErrorHandler\Fixture\ThrowingHandler; use InvalidArgumentException; +use Laminas\Diactoros\Response; +use Laminas\Diactoros\ServerRequest; use Mezzio\Middleware\ErrorResponseGenerator; -use PHPUnit\Framework\MockObject\Exception; -use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerExceptionInterface; -use Psr\Container\ContainerInterface; use Psr\Container\NotFoundExceptionInterface; use Psr\Http\Message\ResponseInterface; use Psr\Log\LoggerInterface; +use RuntimeException; +use stdClass; use function sprintf; class LogErrorHandlerFactoryTest extends TestCase { - private ContainerInterface&MockObject $container; - /** @var callable $responseFactory */ - private $responseFactory; + private const LOGGER_SERVICE = 'test.logger'; + + private TestLogger $logger; - /** - * @throws Exception - */ public function setUp(): void { - $this->container = $this->createMock(ContainerInterface::class); - $this->responseFactory = fn(): ResponseInterface => $this->createMock(ResponseInterface::class); + $this->logger = new TestLogger(); } /** @@ -41,16 +42,14 @@ public function setUp(): void */ public function testWillNotCreateWithoutConfig(): void { - $this->container->method('get') - ->with('config') - ->willReturn(false); + $container = new InMemoryContainer(['config' => []]); $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage( sprintf('\'[%s\'] not found in config', LogErrorHandlerFactory::ERROR_HANDLER_KEY) ); - (new LogErrorHandlerFactory())($this->container); + (new LogErrorHandlerFactory())($container); } /** @@ -59,14 +58,100 @@ public function testWillNotCreateWithoutConfig(): void */ public function testWillNotCreateWithMissingLoggerKey(): void { - $this->container->method('get') - ->with('config') - ->willReturn([ + $container = $this->createContainer([ + 'loggerEnabled' => true, + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + sprintf( + 'Logger: \'[%s\'] is enabled, but not found in config', + LogErrorHandlerFactory::ERROR_HANDLER_LOGGER_KEY + ) + ); + + (new LogErrorHandlerFactory())($container); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testWillNotCreateWhenLoggerServiceIsMissing(): void + { + $container = new InMemoryContainer([ + 'config' => [ LogErrorHandlerFactory::ERROR_HANDLER_KEY => [ - 'loggerEnabled' => true, - 'test', + 'loggerEnabled' => true, + LogErrorHandlerFactory::ERROR_HANDLER_LOGGER_KEY => 'missing.logger', ], - ]); + ], + ResponseInterface::class => static fn (): ResponseInterface => new Response(), + ]); + + $this->expectException(NotFoundExceptionInterface::class); + + (new LogErrorHandlerFactory())($container); + } + + /** + * F10: a missing loggerEnabled key means "disabled", without a PHP warning. + * + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testWillCreateWithoutLoggerWhenLoggerEnabledIsMissing(): void + { + $result = (new LogErrorHandlerFactory())($this->createContainer([])); + + $this->assertLoggerIsNotInjected($result); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testWillCreateWithoutLoggerWhenLoggerIsDisabled(): void + { + $result = (new LogErrorHandlerFactory())($this->createContainer([ + 'loggerEnabled' => false, + LogErrorHandlerFactory::ERROR_HANDLER_LOGGER_KEY => self::LOGGER_SERVICE, + ])); + + $this->assertLoggerIsNotInjected($result); + } + + /** + * F10: a non-boolean flag (for example from an environment variable) must not silently disable logging. + * + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testWillNotCreateWhenLoggerEnabledIsNotBoolean(): void + { + $container = $this->createContainer([ + 'loggerEnabled' => 'true', + LogErrorHandlerFactory::ERROR_HANDLER_LOGGER_KEY => self::LOGGER_SERVICE, + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must be a boolean'); + + (new LogErrorHandlerFactory())($container); + } + + /** + * F10 + * + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testWillNotCreateWhenLoggerKeyIsNotAString(): void + { + $container = $this->createContainer([ + 'loggerEnabled' => true, + LogErrorHandlerFactory::ERROR_HANDLER_LOGGER_KEY => 123, + ]); $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage( @@ -76,189 +161,145 @@ public function testWillNotCreateWithMissingLoggerKey(): void ) ); - (new LogErrorHandlerFactory())($this->container); + (new LogErrorHandlerFactory())($container); } /** + * F10 + * * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface - * @throws Exception */ - public function testWillCreateWithValidConfigAndMissingLogger(): void + public function testWillNotCreateWhenLoggerServiceIsNotALogger(): void { - $this->container->method('get') - ->willReturnMap([ - [ - 'config', - [ - LogErrorHandlerFactory::ERROR_HANDLER_KEY => [ - 'loggerEnabled' => true, - LogErrorHandlerFactory::ERROR_HANDLER_LOGGER_KEY => 'test', - ], - ], - ], - [ - 'config[' . LogErrorHandlerFactory::ERROR_HANDLER_KEY - . '][' . LogErrorHandlerFactory::ERROR_HANDLER_LOGGER_KEY . ']', - null, - ], - [ErrorResponseGenerator::class, $this->createMock(ErrorResponseGenerator::class)], - [ResponseInterface::class, $this->responseFactory], - ]); + $container = $this->createContainer($this->loggerConfig(), [self::LOGGER_SERVICE => new stdClass()]); - $result = (new LogErrorHandlerFactory())($this->container); - $this->assertInstanceOf(LogErrorHandler::class, $result); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + sprintf('Service "%s" must implement %s', self::LOGGER_SERVICE, LoggerInterface::class) + ); + + (new LogErrorHandlerFactory())($container); + } + + /** + * F10 + * + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testWillNotCreateWhenResponseFactoryIsNotCallable(): void + { + $container = $this->createContainer($this->loggerConfig(), [ResponseInterface::class => 'not callable']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + sprintf('Service "%s" must be a callable response factory', ResponseInterface::class) + ); + + (new LogErrorHandlerFactory())($container); } /** * @throws ContainerExceptionInterface - * @throws Exception * @throws NotFoundExceptionInterface */ public function testWillCreateWithoutExtraProviderConfig(): void { - $logger = $this->createMock(LoggerInterface::class); - - $this->container->method('has') - ->with(ErrorResponseGenerator::class) - ->willReturn(true); - - $this->container->method('get') - ->willReturnMap([ - [ - 'config', - [ - LogErrorHandlerFactory::ERROR_HANDLER_KEY => [ - 'loggerEnabled' => true, - LogErrorHandlerFactory::ERROR_HANDLER_LOGGER_KEY => 'test', - ], - ], - ], - [ - 'config[' . LogErrorHandlerFactory::ERROR_HANDLER_KEY . '][' - . LogErrorHandlerFactory::ERROR_HANDLER_LOGGER_KEY . ']', - $logger, - ], - [ErrorResponseGenerator::class, $this->createMock(ErrorResponseGenerator::class)], - [ResponseInterface::class, $this->responseFactory], - ]); + $result = (new LogErrorHandlerFactory())($this->createContainer($this->loggerConfig())); - $result = (new LogErrorHandlerFactory())($this->container); - $this->assertInstanceOf(LogErrorHandler::class, $result); + $this->assertNull($result->getExtraProvider()); + $this->assertLoggerIsInjected($result); } /** * @throws ContainerExceptionInterface - * @throws Exception * @throws NotFoundExceptionInterface */ public function testWillCreateWithEmptyExtraProviderConfig(): void { - $logger = $this->createMock(LoggerInterface::class); - - $this->container->method('has') - ->with(ErrorResponseGenerator::class) - ->willReturn(true); - - $this->container->method('get') - ->willReturnMap([ - [ - 'config', - [ - LogErrorHandlerFactory::ERROR_HANDLER_KEY => [ - 'loggerEnabled' => true, - LogErrorHandlerFactory::ERROR_HANDLER_LOGGER_KEY => 'test', - ], - ExtraProvider::CONFIG_KEY => [], - ], - ], - [ - 'config[' . LogErrorHandlerFactory::ERROR_HANDLER_KEY . '][' - . LogErrorHandlerFactory::ERROR_HANDLER_LOGGER_KEY . ']', - $logger, - ], - [ErrorResponseGenerator::class, $this->createMock(ErrorResponseGenerator::class)], - [ResponseInterface::class, $this->responseFactory], - ]); + $result = (new LogErrorHandlerFactory())($this->createContainer( + $this->loggerConfig() + [ExtraProvider::CONFIG_KEY => []] + )); - $result = (new LogErrorHandlerFactory())($this->container); - $this->assertInstanceOf(LogErrorHandler::class, $result); + $this->assertNull($result->getExtraProvider()); + $this->assertLoggerIsInjected($result); } /** * @throws ContainerExceptionInterface - * @throws Exception * @throws NotFoundExceptionInterface */ public function testWillCreateWithInvalidExtraProviderConfig(): void { - $logger = $this->createMock(LoggerInterface::class); - - $this->container->method('has') - ->with(ErrorResponseGenerator::class) - ->willReturn(true); - - $this->container->method('get') - ->willReturnMap([ - [ - 'config', - [ - LogErrorHandlerFactory::ERROR_HANDLER_KEY => [ - 'loggerEnabled' => true, - LogErrorHandlerFactory::ERROR_HANDLER_LOGGER_KEY => 'test', - ], - ExtraProvider::CONFIG_KEY => [ - 'test' => 'test', - ], - ], - ], - [ - 'config[' . LogErrorHandlerFactory::ERROR_HANDLER_KEY . '][' - . LogErrorHandlerFactory::ERROR_HANDLER_LOGGER_KEY . ']', - $logger, - ], - [ErrorResponseGenerator::class, $this->createMock(ErrorResponseGenerator::class)], - [ResponseInterface::class, $this->responseFactory], - ]); + $result = (new LogErrorHandlerFactory())($this->createContainer( + $this->loggerConfig() + [ExtraProvider::CONFIG_KEY => ['test' => 'test']] + )); - $result = (new LogErrorHandlerFactory())($this->container); - $this->assertInstanceOf(LogErrorHandler::class, $result); + $extraProvider = $result->getExtraProvider(); + $this->assertInstanceOf(ExtraProvider::class, $extraProvider); + $this->assertFalse($extraProvider->getTrace()->isEnabled()); + $this->assertLoggerIsInjected($result); } /** * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface - * @throws Exception */ public function testWillCreateWithValidConfig(): void { - $logger = $this->createMock(LoggerInterface::class); - - $this->container->method('has') - ->with(ErrorResponseGenerator::class) - ->willReturn(true); - - $this->container->method('get') - ->willReturnMap([ - [ - 'config', - [ - LogErrorHandlerFactory::ERROR_HANDLER_KEY => [ - 'loggerEnabled' => true, - LogErrorHandlerFactory::ERROR_HANDLER_LOGGER_KEY => 'test', - ], - ], - ], - [ - 'config[' . LogErrorHandlerFactory::ERROR_HANDLER_KEY . '][' - . LogErrorHandlerFactory::ERROR_HANDLER_LOGGER_KEY . ']', - $logger, - ], - [ErrorResponseGenerator::class, $this->createMock(ErrorResponseGenerator::class)], - [ResponseInterface::class, $this->responseFactory], - ]); + $result = (new LogErrorHandlerFactory())($this->createContainer( + $this->loggerConfig() + [ExtraProvider::CONFIG_KEY => [TraceProvider::class => ['enabled' => true]]], + [ErrorResponseGenerator::class => new ErrorResponseGenerator()] + )); + + $extraProvider = $result->getExtraProvider(); + $this->assertInstanceOf(ExtraProvider::class, $extraProvider); + $this->assertTrue($extraProvider->getTrace()->isEnabled()); + $this->assertLoggerIsInjected($result); + $this->assertArrayHasKey('trace', $this->logger->records[0]['context']); + } + + /** + * @param array $errorHandlerConfig + * @param array $services + */ + private function createContainer(array $errorHandlerConfig, array $services = []): InMemoryContainer + { + return new InMemoryContainer($services + [ + 'config' => [ + LogErrorHandlerFactory::ERROR_HANDLER_KEY => $errorHandlerConfig, + ], + self::LOGGER_SERVICE => $this->logger, + ResponseInterface::class => static fn (): ResponseInterface => new Response(), + ]); + } + + /** + * @return array + */ + private function loggerConfig(): array + { + return [ + 'loggerEnabled' => true, + LogErrorHandlerFactory::ERROR_HANDLER_LOGGER_KEY => self::LOGGER_SERVICE, + ]; + } + + private function assertLoggerIsNotInjected(LogErrorHandler $handler): void + { + $response = $handler->process(new ServerRequest(), new ThrowingHandler(new RuntimeException('boom'))); + + $this->assertSame(500, $response->getStatusCode()); + $this->assertCount(0, $this->logger->records); + } + + private function assertLoggerIsInjected(LogErrorHandler $handler): void + { + $response = $handler->process(new ServerRequest(), new ThrowingHandler(new RuntimeException('boom'))); - $result = (new LogErrorHandlerFactory())($this->container); - $this->assertInstanceOf(LogErrorHandler::class, $result); + $this->assertSame(500, $response->getStatusCode()); + $this->assertCount(1, $this->logger->records); + $this->assertSame('boom', $this->logger->records[0]['message']); } } diff --git a/test/LogErrorHandlerTest.php b/test/LogErrorHandlerTest.php index 4281094..9e8c9a0 100644 --- a/test/LogErrorHandlerTest.php +++ b/test/LogErrorHandlerTest.php @@ -5,65 +5,65 @@ namespace DotTest\ErrorHandler; use Dot\ErrorHandler\Extra\ExtraProvider; +use Dot\ErrorHandler\Extra\Processor\SessionProcessor; use Dot\ErrorHandler\Extra\Provider\CookieProvider; use Dot\ErrorHandler\Extra\Provider\HeaderProvider; use Dot\ErrorHandler\Extra\Provider\RequestProvider; use Dot\ErrorHandler\Extra\Provider\ServerProvider; use Dot\ErrorHandler\Extra\Provider\SessionProvider; use Dot\ErrorHandler\Extra\Provider\TraceProvider; -use Dot\ErrorHandler\LogErrorHandler; use Dot\ErrorHandler\LogErrorHandler as Subject; use Dot\Log\Formatter\Json; use Dot\Log\Logger; +use DotTest\ErrorHandler\Fixture\TestLogger; +use DotTest\ErrorHandler\Fixture\ThrowingHandler; +use DotTest\ErrorHandler\Fixture\ThrowingLogger; +use DotTest\ErrorHandler\Fixture\ThrowingProcessor; use ErrorException; +use Exception; +use Laminas\Diactoros\Response; +use Laminas\Diactoros\ServerRequest; +use Laminas\Stdlib\ArrayObject; use Laminas\Stratigility\Middleware\ErrorResponseGenerator; use org\bovigo\vfs\vfsStream; use org\bovigo\vfs\vfsStreamDirectory; -use PHPUnit\Framework\MockObject\Exception; -use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerExceptionInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Message\StreamInterface; -use Psr\Http\Server\RequestHandlerInterface; -use Psr\Log\LoggerInterface; -use ReflectionObject; use RuntimeException; use Throwable; use function error_reporting; use function file_get_contents; +use function ini_set; +use function sys_get_temp_dir; +use function tempnam; +use function unlink; class LogErrorHandlerTest extends TestCase { private Subject $subject; - private MockObject&ServerRequestInterface $serverRequest; - private MockObject&ResponseInterface $response; - /** @var callable():ResponseInterface $responseFactory */ + private ServerRequestInterface $serverRequest; + private ResponseInterface $response; + /** @var callable(): ResponseInterface */ private $responseFactory; - private MockObject&StreamInterface $body; - private MockObject&RequestHandlerInterface $handler; private Throwable $exception; private ErrorResponseGenerator $errorResponseGenerator; + private TestLogger $logger; private vfsStreamDirectory $fileSystem; - /** - * @throws Exception - */ public function setUp(): void { - $this->response = $this->createMock(ResponseInterface::class); - $this->serverRequest = $this->createMock(ServerRequestInterface::class); - $this->body = $this->createMock(StreamInterface::class); - $this->handler = $this->createMock(RequestHandlerInterface::class); - - $this->responseFactory = fn(): ResponseInterface => $this->response; + $this->response = new Response(); + $this->serverRequest = new ServerRequest(); + $this->responseFactory = fn (): ResponseInterface => $this->response; $this->errorResponseGenerator = new ErrorResponseGenerator(); - $this->subject = new LogErrorHandler( + $this->logger = new TestLogger(); + $this->subject = new Subject( $this->responseFactory, $this->errorResponseGenerator, - $this->createMock(LoggerInterface::class) + $this->logger ); $this->exception = new RuntimeException('Not Implemented', 501); $this->fileSystem = vfsStream::setup('root', 0644, ['log']); @@ -93,80 +93,53 @@ public function testCreateErrorHandlerSkipsErrorsOutsideErrorReportingMask(): vo $this->assertNull($callableErrorHandler(-(error_reporting() + 1), ErrorException::class, 'testErrorFile', 0)); } - /** - * @throws ContainerExceptionInterface - */ public function testLogErrorHandlerWillInitiateWhenExtraProviderIsMissing(): void { - $logErrorHandler = new LogErrorHandler( - $this->responseFactory, - $this->errorResponseGenerator, - new Logger($this->getConfig()), - ); + $logErrorHandler = new Subject($this->responseFactory, $this->errorResponseGenerator, $this->logger); - $this->assertSame(LogErrorHandler::class, $logErrorHandler::class); $this->assertNull($logErrorHandler->getExtraProvider()); } - /** - * @throws ContainerExceptionInterface - */ public function testLogErrorHandlerWillInitiateWhenExtraProviderIsNull(): void { - $logErrorHandler = new LogErrorHandler( - $this->responseFactory, - $this->errorResponseGenerator, - new Logger($this->getConfig()), - null - ); + $logErrorHandler = new Subject($this->responseFactory, $this->errorResponseGenerator, $this->logger, null); - $this->assertSame(LogErrorHandler::class, $logErrorHandler::class); $this->assertNull($logErrorHandler->getExtraProvider()); } - /** - * @throws ContainerExceptionInterface - */ public function testLogErrorHandlerWillInitiateWhenExtraProviderIsProvided(): void { - $logErrorHandler = new LogErrorHandler( + $logErrorHandler = new Subject( $this->responseFactory, $this->errorResponseGenerator, - new Logger($this->getConfig()), + $this->logger, new ExtraProvider() ); - $this->assertSame(LogErrorHandler::class, $logErrorHandler::class); $this->assertInstanceOf(ExtraProvider::class, $logErrorHandler->getExtraProvider()); } - /** - * @throws ContainerExceptionInterface - */ public function testLogErrorHandlerLogsWillOnlyContainDefaultKeysWhenNoProvidersAreEnabled(): void { - $logErrorHandler = new LogErrorHandler( + $logErrorHandler = new Subject( $this->responseFactory, $this->errorResponseGenerator, - new Logger($this->getConfig()), + $this->logger, new ExtraProvider() ); - $log = $logErrorHandler->provideExtra(new \Exception('test'), $this->serverRequest); + $log = $logErrorHandler->provideExtra(new Exception('test'), $this->serverRequest); $this->assertCount(2, $log); $this->assertArrayHasKey('file', $log); $this->assertArrayHasKey('line', $log); } - /** - * @throws ContainerExceptionInterface - */ public function testLogErrorHandlerLogsWillContainExtraKeysWhenProvidersAreEnabled(): void { - $logErrorHandler = new LogErrorHandler( + $logErrorHandler = new Subject( $this->responseFactory, $this->errorResponseGenerator, - new Logger($this->getConfig()), + $this->logger, new ExtraProvider([ CookieProvider::class => ['enabled' => true], HeaderProvider::class => ['enabled' => true], @@ -177,12 +150,7 @@ public function testLogErrorHandlerLogsWillContainExtraKeysWhenProvidersAreEnabl ]) ); - $this->serverRequest->method('getCookieParams')->willReturn([]); - $this->serverRequest->method('getHeaders')->willReturn([]); - $this->serverRequest->method('getParsedBody')->willReturn([]); - $this->serverRequest->method('getServerParams')->willReturn([]); - - $extra = $logErrorHandler->provideExtra(new \Exception('test'), $this->serverRequest); + $extra = $logErrorHandler->provideExtra(new Exception('test'), $this->serverRequest); $this->assertCount(8, $extra); $this->assertArrayHasKey('file', $extra); @@ -195,28 +163,60 @@ public function testLogErrorHandlerLogsWillContainExtraKeysWhenProvidersAreEnabl $this->assertArrayHasKey('trace', $extra); } + /** + * F16: provideExtra() reads the session from $_SESSION. + */ + public function testLogErrorHandlerLogsSessionData(): void + { + $logErrorHandler = new Subject( + $this->responseFactory, + $this->errorResponseGenerator, + $this->logger, + new ExtraProvider([ + SessionProvider::class => ['enabled' => true, 'processor' => ['class' => SessionProcessor::class]], + ]) + ); + + $hadSession = isset($_SESSION); + $previous = $_SESSION ?? []; + $_SESSION = ['user_id' => 5, 'container' => new ArrayObject(['token' => 'abc'])]; + + try { + $extra = $logErrorHandler->provideExtra(new Exception('test'), $this->serverRequest); + } finally { + if ($hadSession) { + $_SESSION = $previous; + } else { + unset($_SESSION); + } + } + + $this->assertSame(['user_id' => 5, 'container' => ['token' => 'abc']], $extra['session']); + } + public function testAttachListenerDoesNotAttachDuplicates(): void { - $listener = static function (): void { + $calls = 0; + $listener = static function () use (&$calls): void { + $calls++; }; $this->subject->attachListener($listener); $this->subject->attachListener($listener); + $this->subject->triggerListeners($this->exception, $this->serverRequest, $this->response); - $ref = new ReflectionObject($this->subject); - $listeners = $ref->getProperty('listeners'); - - $this->assertContains($listener, $listeners->getValue($this->subject)); - $this->assertCount(1, $listeners->getValue($this->subject)); + $this->assertSame(1, $calls); } public function testTriggerListenersWithTwoListeners(): void { + $calls = 0; $listener1 = function ( Throwable $error, ServerRequestInterface $request, ResponseInterface $response - ): void { + ) use (&$calls): void { + $calls++; $this->assertSame($this->exception, $error); $this->assertSame($this->serverRequest, $request); $this->assertSame($this->response, $response); @@ -227,127 +227,132 @@ public function testTriggerListenersWithTwoListeners(): void $this->subject->attachListener($listener2); $this->subject->triggerListeners($this->exception, $this->serverRequest, $this->response); + + $this->assertSame(2, $calls); } public function testHandleThrowable(): void { - $responseGenerator = new ErrorResponseGenerator(); - - $this->body - ->expects(self::once()) - ->method('write') - ->with('Not Implemented') - ->willReturn(0); - - $this->response - ->method('getStatusCode') - ->willReturn(501); - $this->response - ->method('withStatus') - ->with(501) - ->willReturnSelf(); - $this->response - ->method('getBody') - ->willReturn($this->body); - $this->response - ->method('getReasonPhrase') - ->willReturn('Not Implemented'); - - $response = $responseGenerator($this->exception, $this->serverRequest, ($this->responseFactory)()); - - $this->assertContainsOnlyInstancesOf(ResponseInterface::class, [$response]); + $response = $this->subject->handleThrowable($this->exception, $this->serverRequest); + + $this->assertSame(501, $response->getStatusCode()); + $this->assertSame('Not Implemented', (string) $response->getBody()); + $this->assertCount(1, $this->logger->records); + $this->assertSame('Not Implemented', $this->logger->records[0]['message']); } public function testErrorHandlingTriggersListeners(): void { - $this->handler - ->method('handle') - ->with($this->serverRequest) - ->willThrowException($this->exception); - - $this->body - ->expects(self::once()) - ->method('write') - ->with('Not Implemented') - ->willReturn(0); - - $this->response - ->method('getStatusCode') - ->willReturn(501); - $this->response - ->method('withStatus') - ->with(501) - ->willReturnSelf(); - $this->response - ->method('getBody') - ->willReturn($this->body); - $this->response - ->method('getReasonPhrase') - ->willReturn('Not Implemented'); - + $calls = 0; $listener = function ( Throwable $error, ServerRequestInterface $request, ResponseInterface $response - ): void { + ) use (&$calls): void { + $calls++; $this->assertSame($this->exception, $error, 'Listener did not receive same exception as was raised'); $this->assertSame($this->serverRequest, $request, 'Listener did not receive same request'); - $this->assertSame($this->response, $response, 'Listener did not receive same response'); + $this->assertSame(501, $response->getStatusCode(), 'Listener did not receive the error response'); }; $listener2 = clone $listener; $this->subject->attachListener($listener); $this->subject->attachListener($listener2); - $result = $this->subject->process($this->serverRequest, $this->handler); + $result = $this->subject->process($this->serverRequest, new ThrowingHandler($this->exception)); + + $this->assertSame(501, $result->getStatusCode()); + $this->assertSame(2, $calls); + } + + /** + * F1: a failing logger must not replace the error response with its own exception. + */ + public function testWillReturnErrorResponseWhenLoggerThrows(): void + { + $calls = 0; + $subject = new Subject($this->responseFactory, $this->errorResponseGenerator, new ThrowingLogger()); + $subject->attachListener(static function () use (&$calls): void { + $calls++; + }); + + [$response, $errorLog] = $this->captureErrorLog( + fn (): ResponseInterface => $subject->process($this->serverRequest, new ThrowingHandler($this->exception)) + ); - $this->assertSame($this->response, $result); + $this->assertSame(501, $response->getStatusCode()); + $this->assertSame(1, $calls, 'Listeners must still be triggered'); + $this->assertStringContainsString('RuntimeException: Not Implemented', $errorLog); + $this->assertStringContainsString('logging failed: disk full', $errorLog); } /** + * F1: a failing extra-data processor must not replace the error response either. + */ + public function testWillReturnErrorResponseWhenExtraProcessingThrows(): void + { + $subject = new Subject( + $this->responseFactory, + $this->errorResponseGenerator, + $this->logger, + new ExtraProvider([ + CookieProvider::class => ['enabled' => true, 'processor' => ['class' => ThrowingProcessor::class]], + ]) + ); + + [$response, $errorLog] = $this->captureErrorLog( + fn (): ResponseInterface => $subject->process($this->serverRequest, new ThrowingHandler($this->exception)) + ); + + $this->assertSame(501, $response->getStatusCode()); + $this->assertCount(0, $this->logger->records); + $this->assertStringContainsString('RuntimeException: Not Implemented', $errorLog); + $this->assertStringContainsString('logging failed: processor failure', $errorLog); + } + + /** + * Runs $action with PHP's error_log() redirected to a temporary file. + * + * @param callable(): ResponseInterface $action + * @return array{ResponseInterface, string} the response, and what was written to the error log + */ + private function captureErrorLog(callable $action): array + { + $file = (string) tempnam(sys_get_temp_dir(), 'dot-errorhandler-'); + $previous = ini_set('error_log', $file); + + try { + $response = $action(); + + return [$response, (string) file_get_contents($file)]; + } finally { + ini_set('error_log', (string) $previous); + unlink($file); + } + } + + /** + * Integration check of the documented setup: dot-log's Logger writing JSON to a file. + * * @throws ContainerExceptionInterface */ public function testHandleThrowableLogsError(): void { - $config = $this->getConfig(); - - $logErrorHandler = new LogErrorHandler( + $logErrorHandler = new Subject( $this->responseFactory, $this->errorResponseGenerator, - new Logger($config) + new Logger($this->getConfig()) ); - $this->handler - ->method('handle') - ->with($this->serverRequest) - ->willThrowException($this->exception); - - $this->body - ->expects(self::once()) - ->method('write') - ->with('Not Implemented') - ->willReturn(0); - - $this->response - ->method('getStatusCode') - ->willReturn(501); - $this->response - ->method('withStatus') - ->with(501) - ->willReturnSelf(); - $this->response - ->method('getBody') - ->willReturn($this->body); - $this->response - ->method('getReasonPhrase') - ->willReturn('Not Implemented'); - - $logErrorHandler->process($this->serverRequest, $this->handler); + $logErrorHandler->process($this->serverRequest, new ThrowingHandler($this->exception)); $this->assertTrue($this->fileSystem->hasChild('test-error-log.log')); $this->assertNotEmpty(file_get_contents($this->fileSystem->url() . '/test-error-log.log')); } + /** + * @return array + */ private function getConfig(): array { return [ From 8bf38c4926be322703cb9f06d979e9464a16a7cb Mon Sep 17 00:00:00 2001 From: bidi Date: Thu, 12 Nov 2026 14:33:54 +0200 Subject: [PATCH 2/2] updates based on review Signed-off-by: bidi --- .laminas-ci.json | 2 +- composer.json | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/.laminas-ci.json b/.laminas-ci.json index efdd72e..e827682 100644 --- a/.laminas-ci.json +++ b/.laminas-ci.json @@ -2,5 +2,5 @@ "ignore_php_platform_requirements": { "8.6": true }, - "backwardCompatibilityCheck": false + "backwardCompatibilityCheck": true } diff --git a/composer.json b/composer.json index fa9f715..84c9c46 100644 --- a/composer.json +++ b/composer.json @@ -62,13 +62,11 @@ "check": [ "@cs-check", "@test", - "@static-analysis", - "@arch" + "@static-analysis" ], "cs-check": "phpcs", "cs-fix": "phpcbf", "test": "phpunit --colors=always", - "static-analysis": "phpstan analyse --memory-limit 1G", - "arch": "structarmed analyse --no-progress" + "static-analysis": "phpstan analyse --memory-limit 1G" } }