From fe348dfb45d6cc2b9e8e924d6c3d5813b0e11a3d Mon Sep 17 00:00:00 2001 From: Matthias Breddin Date: Wed, 22 Jul 2026 23:41:08 +0200 Subject: [PATCH 01/34] feat!: rebuild timezone handling for modern Symfony Move production code into src/, add typed resolver and storage contracts, ship optional framework adapters, and harden diagnostics, persistence, packaging, documentation, and CI. BREAKING CHANGE: V2 removes the legacy guesser, event, provider, and validator APIs without a compatibility layer. See UPGRADE-2.0.md. --- .gitattributes | 14 + .github/workflows/tests.yml | 109 ++++ .gitignore | 6 + .travis.yml | 52 -- CHANGELOG.md | 17 + .../Compiler/GuesserCompilerPass.php | 44 -- DependencyInjection/Configuration.php | 105 ---- .../LuneticsTimezoneExtension.php | 89 --- Event/FilterTimezoneEvent.php | 50 -- EventListener/TimezoneListener.php | 140 ----- Exception/TimezoneGuesserException.php | 8 - Resources/meta/LICENSE => LICENSE | 0 LuneticsTimezoneBundle.php | 35 -- README.markdown | 100 ++-- Resources/config/LocaleMapper.yml | 152 ----- Resources/config/routes.php | 12 + Resources/config/services.xml | 54 -- Resources/doc/guesser.md | 94 --- Resources/doc/index.md | 14 +- Resources/doc/installation.md | 227 +++++-- Resources/doc/resolvers.md | 68 +++ Resources/doc/scope.md | 5 + Resources/doc/v2-implementation-plan.md | 197 +++++++ Resources/public/timezone.js | 25 + Resources/views/Collector/timezone.html.twig | 39 ++ .../Console/DebugTimezoneCommandTest.php | 27 + .../Bridge/Form/TimezoneTypeExtensionTest.php | 57 ++ Tests/Bridge/MaxMind/GeoIp2CityReaderTest.php | 88 +++ .../MaxMindDatabaseCheckCommandTest.php | 58 ++ .../Messenger/TimezoneMessengerTest.php | 122 ++++ Tests/Bridge/Twig/TwigTimezoneScopeTest.php | 59 ++ .../Twig/TwigTimezoneSubscriberTest.php | 54 ++ .../WebProfiler/TimezoneDataCollectorTest.php | 86 +++ Tests/Browser/timezone.test.mjs | 88 +++ .../Context/TimezoneExecutionContextTest.php | 66 +++ .../BrowserTimezoneControllerTest.php | 223 +++++++ .../LuneticsTimezoneBundleTest.php | 552 ++++++++++++++++++ .../LuneticsTimezoneExtensionTest.php | 111 ---- Tests/Distribution/ExportPolicyTest.php | 49 ++ .../PreferenceDiagnosticsFlagsTest.php | 254 ++++++++ .../ResolveTimezoneListenerV2Test.php | 95 +++ Tests/Integration/BundleKernelSmokeTest.php | 279 +++++++++ .../Resolution/TimezoneResolverChainTest.php | 259 ++++++++ .../Resolver/CallableTimezoneResolverTest.php | 93 +++ Tests/Resolver/HeaderTimezoneResolverTest.php | 105 ++++ .../LocaleMappingTimezoneResolverTest.php | 66 +++ Tests/Resolver/LocaleTimezoneResolverTest.php | 85 +++ .../Resolver/MaxMindTimezoneResolverTest.php | 77 +++ Tests/Resolver/OidcTimezoneResolverTest.php | 83 +++ .../RequestAttributeTimezoneResolverTest.php | 69 +++ .../StoredPreferenceTimezoneResolverTest.php | 63 ++ Tests/Resolver/UserTimezoneResolverTest.php | 143 +++++ Tests/Resources/BrowserRouteTest.php | 24 + Tests/Storage/CookieTimezoneStorageTest.php | 200 +++++++ Tests/Storage/StorageDomainTest.php | 137 +++++ Tests/Timezone/TimezoneIdTest.php | 67 +++ .../GeoTimezoneGuesserTest.php | 101 ---- .../LocaleTimezoneGuesserTest.php | 87 --- .../LocalemapperGuesserTest.php | 109 ---- .../TimezoneGuesserManagerTest.php | 52 -- .../TimezoneProvider/TimezoneProviderTest.php | 96 --- Tests/Validator/TimezoneValidatorTest.php | 115 ---- Tests/bootstrap.php | 17 +- TimezoneBundleEvents.php | 27 - TimezoneGuesser/GeoTimezoneGuesser.php | 58 -- TimezoneGuesser/LocaleTimezoneGuesser.php | 54 -- .../LocalemapperTimezoneGuesser.php | 74 --- TimezoneGuesser/TimezoneGuesserInterface.php | 38 -- TimezoneGuesser/TimezoneGuesserManager.php | 109 ---- TimezoneProvider/TimezoneProvider.php | 90 --- UPGRADE-2.0.md | 44 ++ Validator/Timezone.php | 23 - Validator/TimezoneValidator.php | 36 -- composer.json | 72 ++- package.json | 7 + phpstan.neon.dist | 10 + phpunit.xml.dist | 36 +- scripts/no-dev-smoke.php | 91 +++ src/Bridge/Console/DebugTimezoneCommand.php | 33 ++ src/Bridge/Form/TimezoneTypeExtension.php | 30 + .../MaxMind/CallableMaxMindCityReader.php | 29 + src/Bridge/MaxMind/GeoIp2CityReader.php | 29 + src/Bridge/MaxMind/LazyGeoIp2CityReader.php | 57 ++ .../MaxMind/MaxMindCityReaderInterface.php | 12 + .../MaxMind/MaxMindDatabaseCheckCommand.php | 60 ++ .../Messenger/DispatchTimezoneMiddleware.php | 26 + src/Bridge/Messenger/TimezoneStamp.php | 39 ++ .../Messenger/WorkerTimezoneMiddleware.php | 37 ++ src/Bridge/Twig/TwigTimezoneScope.php | 56 ++ src/Bridge/Twig/TwigTimezoneSubscriber.php | 44 ++ .../WebProfiler/TimezoneDataCollector.php | 121 ++++ src/Clock/SystemClock.php | 15 + src/Context/CurrentTimezoneProvider.php | 50 ++ .../CurrentTimezoneProviderInterface.php | 17 + src/Context/TimezoneExecutionContext.php | 34 ++ .../TimezoneExecutionContextInterface.php | 12 + .../Oidc/OidcClaimsProviderInterface.php | 20 + .../User/TimezoneAwareUserInterface.php | 12 + .../User/UserTimezoneAccessorInterface.php | 12 + src/Controller/BrowserTimezoneController.php | 89 +++ .../Compiler/TimezoneCompilerPass.php | 184 ++++++ src/Event/TimezonePreferenceChangedEvent.php | 18 + src/Event/TimezoneResolvedEvent.php | 15 + .../InvalidPreferenceCleanupListener.php | 54 ++ src/EventListener/ResolveTimezoneListener.php | 36 ++ src/Exception/InvalidTimezoneException.php | 18 + .../PersistenceFailureExceptionInterface.php | 9 + .../ResolutionFailureExceptionInterface.php | 14 + .../Exception}/TimezoneException.php | 5 +- src/Exception/TimezoneResolverException.php | 33 ++ src/Exception/TimezoneStorageException.php | 13 + src/LuneticsTimezoneBundle.php | 375 ++++++++++++ src/Resolution/PersistenceFailureStrategy.php | 11 + src/Resolution/ResolutionAttemptOutcome.php | 14 + src/Resolution/ResolutionFailureStrategy.php | 11 + src/Resolution/ResolutionKind.php | 14 + src/Resolution/TimezoneResolution.php | 20 + src/Resolution/TimezoneResolutionAttempt.php | 22 + src/Resolution/TimezoneResolutionTrace.php | 33 ++ src/Resolution/TimezoneResolverChain.php | 155 +++++ src/Resolver/CallableTimezoneResolver.php | 55 ++ .../CountryTimezoneSourceInterface.php | 13 + src/Resolver/HeaderTimezoneResolver.php | 84 +++ src/Resolver/HeaderTrustMode.php | 12 + .../LocaleMappingTimezoneResolver.php | 51 ++ src/Resolver/LocaleTimezoneResolver.php | 60 ++ src/Resolver/MaxMindTimezoneResolver.php | 61 ++ src/Resolver/OidcTimezoneResolver.php | 53 ++ src/Resolver/PhpCountryTimezoneSource.php | 22 + .../RequestAttributeTimezoneResolver.php | 42 ++ .../StoredPreferenceTimezoneResolver.php | 52 ++ src/Resolver/TimezoneResolverInterface.php | 16 + src/Resolver/UserTimezoneResolver.php | 57 ++ src/Storage/CookieTimezoneStorage.php | 165 ++++++ src/Storage/PreferenceReadStatus.php | 13 + src/Storage/PreferenceSource.php | 11 + src/Storage/SessionTimezoneStorage.php | 103 ++++ src/Storage/TimezonePreference.php | 26 + src/Storage/TimezonePreferenceRead.php | 15 + .../TimezonePreferenceStorageInterface.php | 21 + src/Timezone/TimezoneId.php | 78 +++ 141 files changed, 7495 insertions(+), 2144 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/tests.yml create mode 100644 .gitignore delete mode 100644 .travis.yml create mode 100644 CHANGELOG.md delete mode 100644 DependencyInjection/Compiler/GuesserCompilerPass.php delete mode 100644 DependencyInjection/Configuration.php delete mode 100644 DependencyInjection/LuneticsTimezoneExtension.php delete mode 100644 Event/FilterTimezoneEvent.php delete mode 100644 EventListener/TimezoneListener.php delete mode 100644 Exception/TimezoneGuesserException.php rename Resources/meta/LICENSE => LICENSE (100%) delete mode 100644 LuneticsTimezoneBundle.php delete mode 100644 Resources/config/LocaleMapper.yml create mode 100644 Resources/config/routes.php delete mode 100644 Resources/config/services.xml delete mode 100644 Resources/doc/guesser.md create mode 100644 Resources/doc/resolvers.md create mode 100644 Resources/doc/scope.md create mode 100644 Resources/doc/v2-implementation-plan.md create mode 100644 Resources/public/timezone.js create mode 100644 Resources/views/Collector/timezone.html.twig create mode 100644 Tests/Bridge/Console/DebugTimezoneCommandTest.php create mode 100644 Tests/Bridge/Form/TimezoneTypeExtensionTest.php create mode 100644 Tests/Bridge/MaxMind/GeoIp2CityReaderTest.php create mode 100644 Tests/Bridge/MaxMind/MaxMindDatabaseCheckCommandTest.php create mode 100644 Tests/Bridge/Messenger/TimezoneMessengerTest.php create mode 100644 Tests/Bridge/Twig/TwigTimezoneScopeTest.php create mode 100644 Tests/Bridge/Twig/TwigTimezoneSubscriberTest.php create mode 100644 Tests/Bridge/WebProfiler/TimezoneDataCollectorTest.php create mode 100644 Tests/Browser/timezone.test.mjs create mode 100644 Tests/Context/TimezoneExecutionContextTest.php create mode 100644 Tests/Controller/BrowserTimezoneControllerTest.php create mode 100644 Tests/DependencyInjection/LuneticsTimezoneBundleTest.php delete mode 100644 Tests/DependencyInjection/LuneticsTimezoneExtensionTest.php create mode 100644 Tests/Distribution/ExportPolicyTest.php create mode 100644 Tests/EventListener/PreferenceDiagnosticsFlagsTest.php create mode 100644 Tests/EventListener/ResolveTimezoneListenerV2Test.php create mode 100644 Tests/Integration/BundleKernelSmokeTest.php create mode 100644 Tests/Resolution/TimezoneResolverChainTest.php create mode 100644 Tests/Resolver/CallableTimezoneResolverTest.php create mode 100644 Tests/Resolver/HeaderTimezoneResolverTest.php create mode 100644 Tests/Resolver/LocaleMappingTimezoneResolverTest.php create mode 100644 Tests/Resolver/LocaleTimezoneResolverTest.php create mode 100644 Tests/Resolver/MaxMindTimezoneResolverTest.php create mode 100644 Tests/Resolver/OidcTimezoneResolverTest.php create mode 100644 Tests/Resolver/RequestAttributeTimezoneResolverTest.php create mode 100644 Tests/Resolver/StoredPreferenceTimezoneResolverTest.php create mode 100644 Tests/Resolver/UserTimezoneResolverTest.php create mode 100644 Tests/Resources/BrowserRouteTest.php create mode 100644 Tests/Storage/CookieTimezoneStorageTest.php create mode 100644 Tests/Storage/StorageDomainTest.php create mode 100644 Tests/Timezone/TimezoneIdTest.php delete mode 100644 Tests/TimezoneGuesser/GeoTimezoneGuesserTest.php delete mode 100644 Tests/TimezoneGuesser/LocaleTimezoneGuesserTest.php delete mode 100644 Tests/TimezoneGuesser/LocalemapperGuesserTest.php delete mode 100644 Tests/TimezoneGuesser/TimezoneGuesserManagerTest.php delete mode 100644 Tests/TimezoneProvider/TimezoneProviderTest.php delete mode 100644 Tests/Validator/TimezoneValidatorTest.php delete mode 100644 TimezoneBundleEvents.php delete mode 100644 TimezoneGuesser/GeoTimezoneGuesser.php delete mode 100644 TimezoneGuesser/LocaleTimezoneGuesser.php delete mode 100644 TimezoneGuesser/LocalemapperTimezoneGuesser.php delete mode 100644 TimezoneGuesser/TimezoneGuesserInterface.php delete mode 100644 TimezoneGuesser/TimezoneGuesserManager.php delete mode 100644 TimezoneProvider/TimezoneProvider.php create mode 100644 UPGRADE-2.0.md delete mode 100644 Validator/Timezone.php delete mode 100644 Validator/TimezoneValidator.php create mode 100644 package.json create mode 100644 phpstan.neon.dist create mode 100644 scripts/no-dev-smoke.php create mode 100644 src/Bridge/Console/DebugTimezoneCommand.php create mode 100644 src/Bridge/Form/TimezoneTypeExtension.php create mode 100644 src/Bridge/MaxMind/CallableMaxMindCityReader.php create mode 100644 src/Bridge/MaxMind/GeoIp2CityReader.php create mode 100644 src/Bridge/MaxMind/LazyGeoIp2CityReader.php create mode 100644 src/Bridge/MaxMind/MaxMindCityReaderInterface.php create mode 100644 src/Bridge/MaxMind/MaxMindDatabaseCheckCommand.php create mode 100644 src/Bridge/Messenger/DispatchTimezoneMiddleware.php create mode 100644 src/Bridge/Messenger/TimezoneStamp.php create mode 100644 src/Bridge/Messenger/WorkerTimezoneMiddleware.php create mode 100644 src/Bridge/Twig/TwigTimezoneScope.php create mode 100644 src/Bridge/Twig/TwigTimezoneSubscriber.php create mode 100644 src/Bridge/WebProfiler/TimezoneDataCollector.php create mode 100644 src/Clock/SystemClock.php create mode 100644 src/Context/CurrentTimezoneProvider.php create mode 100644 src/Context/CurrentTimezoneProviderInterface.php create mode 100644 src/Context/TimezoneExecutionContext.php create mode 100644 src/Context/TimezoneExecutionContextInterface.php create mode 100644 src/Contract/Oidc/OidcClaimsProviderInterface.php create mode 100644 src/Contract/User/TimezoneAwareUserInterface.php create mode 100644 src/Contract/User/UserTimezoneAccessorInterface.php create mode 100644 src/Controller/BrowserTimezoneController.php create mode 100644 src/DependencyInjection/Compiler/TimezoneCompilerPass.php create mode 100644 src/Event/TimezonePreferenceChangedEvent.php create mode 100644 src/Event/TimezoneResolvedEvent.php create mode 100644 src/EventListener/InvalidPreferenceCleanupListener.php create mode 100644 src/EventListener/ResolveTimezoneListener.php create mode 100644 src/Exception/InvalidTimezoneException.php create mode 100644 src/Exception/PersistenceFailureExceptionInterface.php create mode 100644 src/Exception/ResolutionFailureExceptionInterface.php rename {Exception => src/Exception}/TimezoneException.php (79%) create mode 100644 src/Exception/TimezoneResolverException.php create mode 100644 src/Exception/TimezoneStorageException.php create mode 100644 src/LuneticsTimezoneBundle.php create mode 100644 src/Resolution/PersistenceFailureStrategy.php create mode 100644 src/Resolution/ResolutionAttemptOutcome.php create mode 100644 src/Resolution/ResolutionFailureStrategy.php create mode 100644 src/Resolution/ResolutionKind.php create mode 100644 src/Resolution/TimezoneResolution.php create mode 100644 src/Resolution/TimezoneResolutionAttempt.php create mode 100644 src/Resolution/TimezoneResolutionTrace.php create mode 100644 src/Resolution/TimezoneResolverChain.php create mode 100644 src/Resolver/CallableTimezoneResolver.php create mode 100644 src/Resolver/CountryTimezoneSourceInterface.php create mode 100644 src/Resolver/HeaderTimezoneResolver.php create mode 100644 src/Resolver/HeaderTrustMode.php create mode 100644 src/Resolver/LocaleMappingTimezoneResolver.php create mode 100644 src/Resolver/LocaleTimezoneResolver.php create mode 100644 src/Resolver/MaxMindTimezoneResolver.php create mode 100644 src/Resolver/OidcTimezoneResolver.php create mode 100644 src/Resolver/PhpCountryTimezoneSource.php create mode 100644 src/Resolver/RequestAttributeTimezoneResolver.php create mode 100644 src/Resolver/StoredPreferenceTimezoneResolver.php create mode 100644 src/Resolver/TimezoneResolverInterface.php create mode 100644 src/Resolver/UserTimezoneResolver.php create mode 100644 src/Storage/CookieTimezoneStorage.php create mode 100644 src/Storage/PreferenceReadStatus.php create mode 100644 src/Storage/PreferenceSource.php create mode 100644 src/Storage/SessionTimezoneStorage.php create mode 100644 src/Storage/TimezonePreference.php create mode 100644 src/Storage/TimezonePreferenceRead.php create mode 100644 src/Storage/TimezonePreferenceStorageInterface.php create mode 100644 src/Timezone/TimezoneId.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1d16d50 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +/.github export-ignore +/.github/** export-ignore +/.firecrawl export-ignore +/.firecrawl/** export-ignore +/.claude export-ignore +/.claude/** export-ignore +/Tests export-ignore +/Tests/** export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.travis.yml export-ignore +/phpunit.xml.dist export-ignore +/phpstan.neon.dist export-ignore +/package.json export-ignore diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..38f4156 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,109 @@ +name: Tests + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + minimal-dependencies: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + coverage: none + tools: composer:v2 + - run: composer validate --strict --no-check-publish + - run: composer update --no-dev --prefer-dist --no-interaction --no-progress + - run: composer audit --locked + - name: Smoke-test no-dev package + run: php scripts/no-dev-smoke.php + + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - php: '8.2' + symfony: '6.4.*' + - php: '8.3' + symfony: '6.4.*' + - php: '8.2' + symfony: '7.4.*' + - php: '8.3' + symfony: '7.4.*' + - php: '8.4' + symfony: '8.0.*' + - php: '8.4' + symfony: '8.1.*' + - php: '8.5' + symfony: '8.1.*' + + name: PHP ${{ matrix.php }} / Symfony ${{ matrix.symfony }} + + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: composer:v2 + + - run: composer validate --strict --no-check-publish + + - name: Select Symfony runtime versions + run: >- + composer require --no-update + symfony/config:${{ matrix.symfony }} + symfony/dependency-injection:${{ matrix.symfony }} + symfony/http-foundation:${{ matrix.symfony }} + symfony/http-kernel:${{ matrix.symfony }} + + - name: Select Symfony development versions + run: >- + composer require --dev --no-update + symfony/asset-mapper:${{ matrix.symfony }} + symfony/console:${{ matrix.symfony }} + symfony/event-dispatcher:${{ matrix.symfony }} + symfony/form:${{ matrix.symfony }} + symfony/framework-bundle:${{ matrix.symfony }} + symfony/messenger:${{ matrix.symfony }} + symfony/routing:${{ matrix.symfony }} + symfony/security-bundle:${{ matrix.symfony }} + symfony/security-core:${{ matrix.symfony }} + symfony/security-csrf:${{ matrix.symfony }} + symfony/serializer:${{ matrix.symfony }} + symfony/twig-bundle:${{ matrix.symfony }} + symfony/web-profiler-bundle:${{ matrix.symfony }} + + - run: composer update --prefer-dist --no-interaction --no-progress + - run: composer audit --locked + - run: composer check + + prefer-lowest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + coverage: none + tools: composer:v2 + - run: composer validate --strict --no-check-publish + - run: composer update --prefer-lowest --prefer-dist --no-interaction --no-progress + - run: composer check + + javascript: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + - run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7de486a --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/vendor/ +/var/ +/.phpunit.cache/ +/composer.lock +/.claude/ +/.firecrawl/ diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 352a992..0000000 --- a/.travis.yml +++ /dev/null @@ -1,52 +0,0 @@ -language: php - -php: - - 5.3 - - 5.4 - - 5.5 - - 5.6 - - hhvm - -matrix: - allow_failures: - - php: hhvm - - env: SYMFONY_VERSION=dev-master - -env: - - SYMFONY_VERSION=2.2.* - - SYMFONY_VERSION=2.3.* - - SYMFONY_VERSION=2.4.* - - SYMFONY_VERSION=2.5.* - - SYMFONY_VERSION=2.6.* - - SYMFONY_VERSION=2.7.* - - SYMFONY_VERSION=2.8.* - - SYMFONY_VERSION=3.0.* - - SYMFONY_VERSION=dev-master - -cache: - apt: true - directories: - - $COMPOSER_CACHE_DIR - -before_script: - - sudo apt-get install geoip-bin - - sudo apt-get install geoip-database - - sudo apt-get install libgeoip-dev - - ls -l /usr/share/GeoIP - - curl -o GeoLiteCity.dat.gz https://cloud.github.com/downloads/lunetics/TimezoneBundle/GeoLiteCity.dat.gz - - gunzip GeoLiteCity.dat.gz - - sudo mkdir -p /usr/share/GeoIP - - sudo mv GeoLiteCity.dat /usr/share/GeoIP/GeoIPCity.dat - - pecl install geoip - - composer self-update - - composer require symfony/framework-bundle:${SYMFONY_VERSION} --dev --no-update - - composer install --dev --prefer-source - -script: phpunit --coverage-text --coverage-clover=coverage.clover - -after_script: - - wget https://scrutinizer-ci.com/ocular.phar - - php ocular.phar code-coverage:upload --format=php-clover coverage.clover - -notifications: - email: mb@lunetics.com diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bf13500 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +## Unreleased — 2.0 + +- Replaced the legacy Guesser/Event API with ordered, explicitly tagged timezone resolvers and traceable resolution results. +- Added validated `TimezoneId`, per-request current-timezone provider, and bounded execution context without global timezone mutation. +- Added versioned session persistence, signed HMAC/HKDF cookie persistence, and a request/response-aware custom storage contract. +- Added opt-in browser timezone synchronization with CSRF, strict JSON protocol, and a dependency-free ES module. +- Added explicit authenticated-user and OIDC contracts, MaxMind City support, and locale mapping/unique-country inference. +- Added optional Twig, Form, Messenger, profiler, and console integrations. Messenger middleware is published for application bus configuration and does not mutate buses. +- Added independent resolution and persistence failure strategies, structured redacted logging, diagnostics, supported-version CI, and V2 migration documentation. +- Added the explicitly registered `CallableTimezoneResolver` convenience adapter for application callables, with typed policy-controlled failures and strict result/source validation. +- Hardened container compilation: integration keys are strict; cookie and MaxMind identifiers are validated; aware-user/accessor exceptions become typed resolver failures; and configured-service alias cycles are rejected. +- Scoped clock selection to the bundle's internal alias without defining or replacing the application's global PSR clock alias. +- Hardened browser/session and diagnostics behavior: sessionless session-storage writes return `503`, and profiler data survives profile serialization/reload. +- Hardened distribution and CI checks with automatic bundle AssetMapper discovery, clean Composer `--no-dev` smoke coverage, PHP 8.3/Symfony 6.4 coverage, and archive retention of linked scope/plan docs and the root `LICENSE`. +- Removed the bundle Validator integration and the Symfony Validator requirement. V2 has no 1.x compatibility layer. diff --git a/DependencyInjection/Compiler/GuesserCompilerPass.php b/DependencyInjection/Compiler/GuesserCompilerPass.php deleted file mode 100644 index 0984b9d..0000000 --- a/DependencyInjection/Compiler/GuesserCompilerPass.php +++ /dev/null @@ -1,44 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that is distributed with this source code. - */ - -namespace Lunetics\TimezoneBundle\DependencyInjection\Compiler; - -use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; -use Symfony\Component\DependencyInjection\Reference; - -/** - * Compilerpass Class - * - * @author Matthias Breddin - */ -class GuesserCompilerPass implements CompilerPassInterface -{ - /** - * Compilerpass for Timezone Guessers - * - * @param \Symfony\Component\DependencyInjection\ContainerBuilder $container - */ - public function process(ContainerBuilder $container) - { - if (false === $container->hasDefinition('lunetics_timezone.guesser_manager')) { - return; - } - - $definition = $container->getDefinition('lunetics_timezone.guesser_manager'); - - foreach ($container->findTaggedServiceIds('lunetics_timezone.guesser') as $id => $tagAttributes) { - foreach ($tagAttributes as $attributes) { - $definition->addMethodCall('addGuesser', array(new Reference($id), $attributes["alias"])); - } - } - } -} - diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php deleted file mode 100644 index f02c727..0000000 --- a/DependencyInjection/Configuration.php +++ /dev/null @@ -1,105 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that is distributed with this source code. - */ - -namespace Lunetics\TimezoneBundle\DependencyInjection; - -use Symfony\Component\Config\Definition\Builder\TreeBuilder; -use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; -use Symfony\Component\Config\Definition\ConfigurationInterface; - -/** - * This is the class that validates and merges configuration from your app/config files - * - * @author Matthias Breddin - */ -class Configuration implements ConfigurationInterface -{ - /** - * {@inheritDoc} - */ - public function getConfigTreeBuilder() - { - $availableTimezones = \DateTimeZone::listIdentifiers(); - $treeBuilder = new TreeBuilder(); - $rootNode = $treeBuilder->root('lunetics_timezone'); - - $rootNode - ->children() - ->scalarNode('session_var') - ->defaultValue('lunetics_timezone') - ->end() - ->scalarNode('default_timezone') - ->defaultValue('UTC') - ->validate() - ->ifNotInArray($availableTimezones) - ->thenInvalid('Invalid timezone "%s"') - ->end() - ->end() - ->arrayNode('guesser') - ->children() - ->arrayNode('manager') - ->addDefaultsIfNotSet() - ->children() - ->scalarNode('class') - ->defaultValue('Lunetics\TimezoneBundle\TimezoneGuesser\TimezoneGuesserManager') - ->end() - ->end() - ->end() - ->arrayNode('listener') - ->addDefaultsIfNotSet() - ->children() - ->scalarNode('class') - ->defaultValue('Lunetics\TimezoneBundle\EventListener\TimezoneListener') - ->end() - ->end() - ->end() - ->arrayNode('order') - ->isRequired() - ->requiresAtLeastOneElement() - ->prototype('scalar')->end() - ->end() - ->end() - ->end() - ->end(); - $this->addServiceSection($rootNode); - - return $treeBuilder; - } - - private function addServiceSection(ArrayNodeDefinition $node) - { - $node - ->children() - ->arrayNode('service') - ->addDefaultsIfNotSet() - ->children() - ->arrayNode('geo') - ->addDefaultsIfNotSet() - ->children() - ->scalarNode('class')->defaultValue('Lunetics\TimezoneBundle\TimezoneGuesser\GeoTimezoneGuesser')->end() - ->end() - ->end() - ->arrayNode('locale_mapper') - ->addDefaultsIfNotSet() - ->children() - ->scalarNode('class')->defaultValue('Lunetics\TimezoneBundle\TimezoneGuesser\LocalemapperTimezoneGuesser')->end() - ->end() - ->end() - ->arrayNode('locale') - ->addDefaultsIfNotSet() - ->children() - ->scalarNode('class')->defaultValue('Lunetics\TimezoneBundle\TimezoneGuesser\LocaleTimezoneGuesser')->end() - ->end() - ->end() - ->end() - ->end() - ->end(); - } -} diff --git a/DependencyInjection/LuneticsTimezoneExtension.php b/DependencyInjection/LuneticsTimezoneExtension.php deleted file mode 100644 index b2d1abc..0000000 --- a/DependencyInjection/LuneticsTimezoneExtension.php +++ /dev/null @@ -1,89 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that is distributed with this source code. - */ - -namespace Lunetics\TimezoneBundle\DependencyInjection; - -use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\Config\FileLocator; -use Symfony\Component\HttpKernel\DependencyInjection\Extension; -use Symfony\Component\DependencyInjection\Loader; -use Symfony\Component\Yaml\Parser; - -/** - * This is the class that loads and manages your bundle configuration - * - * @author Matthias Breddin - */ -class LuneticsTimezoneExtension extends Extension -{ - /** - * {@inheritDoc} - */ - public function load(array $configs, ContainerBuilder $container) - { - $configuration = new Configuration(); - $config = $this->processConfiguration($configuration, $configs); - $this->bindParameters($container, $this->getAlias(), $config); - $order = $container->getParameter('lunetics_timezone.guesser.order'); - $extGeoip = extension_loaded('geoip'); - if (!$extGeoip && in_array('geo', $order)) { - throw new \Symfony\Component\Config\Definition\Exception\InvalidConfigurationException('Cannot load the "geo" guesser without the pecl-geoip extension.'); - } - if (in_array('locale', $order)) { - if (!$extGeoip) { - throw new \Symfony\Component\Config\Definition\Exception\InvalidConfigurationException('Cannot load the "locale" guesser without the pecl-geoip extension.'); - } - $geoipInfo = geoip_db_get_all_info(); - if (!geoip_db_avail(GEOIP_CITY_EDITION_REV0) OR !geoip_db_avail(GEOIP_CITY_EDITION_REV1)) { - $error = 'Could not find "'.$geoipInfo[GEOIP_CITY_EDITION_REV0]['description'].'" ('.$geoipInfo[GEOIP_CITY_EDITION_REV0]['filename'].') or '; - $error .= '"'.$geoipInfo[GEOIP_CITY_EDITION_REV1]['description'].'" ('.$geoipInfo[GEOIP_CITY_EDITION_REV1]['filename'].')'; - throw new \Symfony\Component\Config\Definition\Exception\InvalidConfigurationException($error); - } - } - $loader = new Loader\XmlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); - $loader->load('services.xml'); - $this->loadLocaleMapper($container); - } - - /** - * {@inheritDoc} - */ - public function getAlias() - { - return 'lunetics_timezone'; - } - - public function loadLocaleMapper(ContainerBuilder $container) - { - $localeMapper = new Parser(); - $file = new FileLocator(__DIR__ . '/../Resources/config'); - $container->setParameter('lunetics_timezone.service.locale_mapper.data', $localeMapper->parse(file_get_contents($file->locate('LocaleMapper.yml')))); - } - - /** - * Binds the config Parameters to the container - * - * @param ContainerBuilder $container Containter - * @param string $name Name - * @param array $config Configuration - * - * @author Christophe Willemsen - */ - public function bindParameters(ContainerBuilder $container, $name, $config) - { - if (is_array($config) && empty($config[0])) { - foreach ($config as $key => $value) { - $this->bindParameters($container, $name . '.' . $key, $value); - } - } else { - $container->setParameter($name, $config); - } - } -} diff --git a/Event/FilterTimezoneEvent.php b/Event/FilterTimezoneEvent.php deleted file mode 100644 index 0d65b45..0000000 --- a/Event/FilterTimezoneEvent.php +++ /dev/null @@ -1,50 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that is distributed with this source code. - */ - -namespace Lunetics\TimezoneBundle\Event; - -use Symfony\Component\EventDispatcher\Event; - -/** - * Filter for the timezone event - */ -class FilterTimezoneEvent extends Event -{ - /** - * @var string - */ - protected $timezone; - - /** - * Constructor - * - * @param string $timezone - * - * @throws \InvalidArgumentException - */ - public function __construct($timezone) - { - if (!is_string($timezone) || null == $timezone || '' == $timezone) { - throw new \InvalidArgumentException(sprintf('Wrong type, expected \'string\' got \'%s\'', gettype($timezone))); - } - - $this->timezone = $timezone; - } - - /** - * Returns the timezone string - * - * @return string - */ - public function getTimezone() - { - return $this->timezone; - } -} \ No newline at end of file diff --git a/EventListener/TimezoneListener.php b/EventListener/TimezoneListener.php deleted file mode 100644 index c31868c..0000000 --- a/EventListener/TimezoneListener.php +++ /dev/null @@ -1,140 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that is distributed with this source code. - */ - -namespace Lunetics\TimezoneBundle\EventListener; - -use Lunetics\TimezoneBundle\TimezoneProvider\TimezoneProvider; -use Psr\Log\NullLogger; -use Symfony\Component\HttpKernel\Event\GetResponseEvent; -use Symfony\Component\HttpKernel\HttpKernelInterface; -use Symfony\Component\HttpKernel\Log\LoggerInterface; -use Symfony\Component\HttpFoundation\Session\Session; -use Symfony\Component\Validator\Validator; -use Symfony\Component\EventDispatcher\EventSubscriberInterface; -use Symfony\Component\HttpKernel\KernelEvents; - -use Lunetics\TimezoneBundle\TimezoneGuesser\TimezoneGuesserManager; -use Lunetics\TimezoneBundle\Event\FilterTimezoneEvent; -use Lunetics\TimezoneBundle\TimezoneBundleEvents; -use Lunetics\TimezoneBundle\Validator\Timezone; -use Symfony\Component\Validator\ValidatorInterface as LegacyValidatorInterface; -use Symfony\Component\Validator\Validator\ValidatorInterface; - -/** - * Listener for Timezone detection - * - * @author Matthias Breddin - */ -class TimezoneListener implements EventSubscriberInterface -{ - protected $session; - protected $manager; - protected $validator; - protected $timezoneProvider; - protected $logger; - protected $timezone; - protected $sessionTimezoneString; - - /** - * Construct the TimezoneListener - * - * @param Session $session Session - * @param string $sessionVar - * @param string $defaultTimezone - * @param TimezoneGuesserManager $manager The Timezone Manager - * @param ValidatorInterace $validator Timzone Validator - * @param LoggerInterface $logger Logger - */ - public function __construct(Session $session, $sessionVar, $defaultTimezone = 'UTC', TimezoneGuesserManager $manager, $validator, TimezoneProvider $provider, LoggerInterface $logger = null) - { - if (!$validator instanceof ValidatorInterface && !$validator instanceof LegacyValidatorInterface) { - throw new \InvalidArgumentException('MetadataValidator accepts either the new or the old ValidatorInterface, '.get_class($validator).' was injected instead.'); - } - $this->session = $session; - $this->manager = $manager; - $this->validator = $validator; - $this->timezoneProvider = $provider; - $this->logger = $logger ? : new NullLogger(); - $this->sessionTimezoneString = $sessionVar; - $this->defaultTimezone = $defaultTimezone; - } - - /** - * Called at the "kernel.request" event - * - * Call the TimezoneGuesserManager to guess the Timezone by the activated guessers - * - * @param GetResponseEvent $event - */ - public function onKernelRequest(GetResponseEvent $event) - { - $request = $event->getRequest(); - if ($event->getRequestType() !== HttpKernelInterface::MASTER_REQUEST && !$request->isXmlHttpRequest()) { - $this->logger->info('Request is not a "MASTER_REQUEST" : SKIPPING...'); - - return; - } - - if (!$this->session->has($this->sessionTimezoneString)) { - - if ($this->timezone = $this->manager->runTimezoneGuessing($request)) { - $errors = $this->validator->validate($this->timezone, new Timezone()); - - if ($errors->count() > 0) { - $iterator = $errors->getIterator(); - while ($iterator->valid()) { - $this->logger->notice($iterator->current()); - $iterator->next(); - } - - return; - } - } else { - $this->timezone = $this->defaultTimezone; - } - - } else { - $this->timezone = $this->session->get($this->sessionTimezoneString); - $event->getDispatcher()->removeListener(TimezoneBundleEvents::TIMEZONE_CHANGE, array($this,'setSessionAttribute')); - } - $localeSwitchEvent = new FilterTimezoneEvent($this->timezone); - $event->getDispatcher()->dispatch(TimezoneBundleEvents::TIMEZONE_CHANGE, $localeSwitchEvent); - } - - /** - * Sets the timezone in the session - * - * @param FilterTimezoneEvent $event - */ - public function setSessionAttribute(FilterTimezoneEvent $event) - { - $timezone = $event->getTimezone(); - $this->session->set($this->sessionTimezoneString, $timezone); - $this->logger->info(sprintf('Setting [ %s ] as default timezone into session var [ %s ]', $timezone, $this->sessionTimezoneString)); - } - - public function setTimezoneProviderValue(FilterTimezoneEvent $event) - { - $this->timezoneProvider->setTimezone($event->getTimezone()); - } - /** - * {@inheritDoc} - */ - public static function getSubscribedEvents() - { - return array( - KernelEvents::REQUEST => array('onKernelRequest'), - TimezoneBundleEvents::TIMEZONE_CHANGE => array( - array('setSessionAttribute'), - array('setTimezoneProviderValue') - ) - ); - } -} diff --git a/Exception/TimezoneGuesserException.php b/Exception/TimezoneGuesserException.php deleted file mode 100644 index 9da592a..0000000 --- a/Exception/TimezoneGuesserException.php +++ /dev/null @@ -1,8 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that is distributed with this source code. - */ - -namespace Lunetics\TimezoneBundle; - -use Symfony\Component\HttpKernel\Bundle\Bundle; -use Symfony\Component\DependencyInjection\ContainerBuilder; - -use Lunetics\TimezoneBundle\DependencyInjection\Compiler\GuesserCompilerPass; - -/** - * Lunetics Timezonebundle - */ -class LuneticsTimezoneBundle extends Bundle -{ - /** - * Add Compilerpass - * - * @param \Symfony\Component\DependencyInjection\ContainerBuilder $container - */ - public function build(ContainerBuilder $container) - { - parent::build($container); - - $container->addCompilerPass(new GuesserCompilerPass()); - } - -} diff --git a/README.markdown b/README.markdown index 25756eb..823eba9 100644 --- a/README.markdown +++ b/README.markdown @@ -1,47 +1,77 @@ -TimezoneBundle -============== -The TimezoneBundle adds support for guessing a appropiate Timezone serverside in your Symfony 2.1 application. +# LuneticsTimezoneBundle 2.x -[![Latest Stable Version](https://poser.pugx.org/lunetics/timezone-bundle/v/stable.png)](https://packagist.org/packages/lunetics/timezone-bundle) -[![Build Status](https://secure.travis-ci.org/lunetics/TimezoneBundle.png?branch=master)](http://travis-ci.org/lunetics/TimezoneBundle) -[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/lunetics/TimezoneBundle/badges/quality-score.png?s=cd22c2fddc8121153c3827f5321f46d7564ef62d)](https://scrutinizer-ci.com/g/lunetics/TimezoneBundle/) -[![Code Coverage](https://scrutinizer-ci.com/g/lunetics/TimezoneBundle/badges/coverage.png?s=ef7f518cbc1033d923145f37aa4288e01af96302)](https://scrutinizer-ci.com/g/lunetics/TimezoneBundle/) +LuneticsTimezoneBundle resolves an IANA timezone for each Symfony request and exposes it through a stable application context. It can combine explicit request data, trusted headers, persisted preferences, authenticated users, OIDC claims, MaxMind City data, and locale fallbacks. +Symfony 8 supplies timezone lists, validation primitives, and form options, but applications still need policy for choosing a user's timezone, retaining it, and carrying it through Twig and asynchronous work. This bundle provides that orchestration without changing PHP's process-wide default timezone. -[![Latest Unstable Version](https://poser.pugx.org/lunetics/timezone-bundle/v/unstable.png)](https://packagist.org/packages/lunetics/timezone-bundle) -[![Total Downloads](https://poser.pugx.org/lunetics/timezone-bundle/downloads.png)](https://packagist.org/packages/lunetics/timezone-bundle) -[![License](https://poser.pugx.org/lunetics/timezone-bundle/license.png)](https://packagist.org/packages/lunetics/timezone-bundle) +## Requirements -About ------ -This Bundle offers Timezone detection via a Kernel-listener. -Also included is a TimezoneValidator. +- PHP `^8.2` +- Symfony `^6.4 || ^7.4 || ^8.0` -Included are 3 **TimezoneGuessers** +Symfony 8.1 is the primary current target (July 2026); the `^8.0` constraint intentionally permits compatible later Symfony 8 minors. -* geoip -* locale_mapper -* locale +## Install and register -You can define the order of which guesser should be called first. -Once a guesser finds a appropiate timezone, the timezone will be stored in the session variable **lunetics_timezone**. +```bash +composer require lunetics/timezone-bundle +``` -Requirements ------------- -To use the **geoip** guesser and the **locale** guesser, the **pecl-geoip** extension must be installed and working. +Register the bundle when Symfony Flex has not done so: -Documentation -------------- -[Read the Documentation](https://github.com/lunetics/TimezoneBundle/blob/master/Resources/doc/index.md) +```php +// config/bundles.php +return [ + // ... + Lunetics\TimezoneBundle\LuneticsTimezoneBundle::class => ['all' => true], +]; +``` -Installation ------------- -[Read the Documentation](https://github.com/lunetics/TimezoneBundle/blob/master/Resources/doc/installation.md) +Minimal configuration: -License -------- -This bundle is under the MIT license. +```yaml +# config/packages/lunetics_timezone.yaml +lunetics_timezone: + default_timezone: UTC +``` -Authors -------- -Matthias Breddin : [@lunetics](https://github.com/lunetics) +Inject `Lunetics\TimezoneBundle\Context\CurrentTimezoneProviderInterface`: + +```php +use Lunetics\TimezoneBundle\Context\CurrentTimezoneProviderInterface; + +final class LocalClock +{ + public function __construct(private CurrentTimezoneProviderInterface $timezones) {} + + public function timezone(): \DateTimeZone + { + return $this->timezones->getDateTimeZone(); + } +} +``` + +## Documentation + +- [Installation and configuration](Resources/doc/installation.md) +- [Resolvers and precedence](Resources/doc/resolvers.md) +- [Scope and non-goals](Resources/doc/scope.md) +- [V2 architecture, contracts, and ADRs](Resources/doc/v2-implementation-plan.md) +- [Upgrade from 1.x](UPGRADE-2.0.md) +- [Changelog](CHANGELOG.md) + +## Adapters + +The core ships session or signed-cookie persistence and request/execution context. Optional adapters cover application callables (`CallableTimezoneResolver`), authenticated users, explicit OIDC claims, MaxMind City databases or readers, browser timezone sync, Twig, Symfony Form, Messenger, the web profiler, and console diagnostics. Integrations activate only when configured and available; see the installation guide for their exact wiring. + +## Quality + +```bash +composer validate --strict --no-check-publish +composer analyse +composer test +composer check +npm test +``` + +`composer check` runs PHPStan and PHPUnit. The Node test covers the dependency-free browser ES module. diff --git a/Resources/config/LocaleMapper.yml b/Resources/config/LocaleMapper.yml deleted file mode 100644 index e39a491..0000000 --- a/Resources/config/LocaleMapper.yml +++ /dev/null @@ -1,152 +0,0 @@ -#code timezone language country capital -locales_lang_only: - ar: Asia/Dubai # Arabic United Arab Emirates Abu Dhabi - be: Europe/Minsk # Belarusian Belarus Minsk - ca: Europe/Madrid # Catalan Spain Madrid - bg: Europe/Sofia # Bulgarian Bulgaria Sofia - cs: Europe/Prague # Czech Czech Republic Prague - da: Europe/Copenhagen # Danish Denmark Copenhagen - de: Europe/Berlin # German Germany Berlin - el: Asia/Nicosia # Greek Cyprus Nicosia - en: America/Los_Angeles # English United States Washington - et: Europe/Tallinn # Estonian Estonia Tallinn - es: Europe/Madrid # Spanish Spain Madrid - fi: Europe/Helsinki # Finnish Finland Helsinki - fr: Europe/Paris # French France Paris - ga: Europe/Dublin # Irish Ireland Dublin - hr: Europe/Zagreb # Croatian Croatia Zagreb - hu: Europe/Budapest # Hungarian Hungary Budapest - in: Asia/Jakarta # Indonesian Indonesia Jakarta - is: Atlantic/Reykjavik # Icelandic Iceland Reykjavik - it: Europe/Rome # Italian Italy Rome - iw: Asia/Jerusalem # Hebrew Israel Jerusalem* - ja: Asia/Tokyo # Japanese Japan Tokyo - ko: Asia/Seoul # Korean South Korea Seoul - lt: Europe/Vilnius # Lithuanian Lithuania Vilnius - lv: Europe/Riga # Latvian Latvia Riga - mk: Europe/Skopje # Macedonian Macedonia Skopje - ms: Asia/Kuala_Lumpur # Malay Malaysia Kuala Lumpur - mt: Europe/Malta # Maltese Malta Valletta - nl: Europe/Amsterdam # Dutch Netherlands Amsterdam - no: Europe/Oslo # Norwegian Norway Oslo - pl: Europe/Warsaw # Polish Poland Warsaw - ro: Europe/Bucharest # Romanian Romania Bucharest - ru: Europe/Moscow # Russian Russia Moscow - sk: Europe/Bratislava # Slovak Slovakia Bratislava - sl: Europe/Ljubljana # Slovenian Slovenia Ljubljana - sq: Europe/Tirane # Albanian Albania Tirane - sr: Europe/Sarajevo # Serbian Bosnia and Herzegovina Sarajevo - sv: Europe/Stockholm # Swedish Sweden Stockholm - tr: Europe/Istanbul # Turkish Turkey Ankara - pt: Europe/Lisbon # Portuguese Portugal Lisbon - th: Asia/Bangkok # Thai Thailand Bangkok - uk: Europe/Kiev # Ukrainian Ukraine Kyiv - vi: Asia/Ho_Chi_Minh # Vietnamese Vietnam Hanoi - zh: Asia/Taipei # Chinese Taiwan Taipei -locales_full: - ar_AE: Asia/Dubai # Arabic United Arab Emirates Abu Dhabi - ar_BH: Asia/Bahrain # Arabic Bahrain Manama - ar_DZ: Africa/Algiers # Arabic Algeria Algiers - ar_EG: Africa/Cairo # Arabic Egypt Cairo - ar_IQ: Asia/Baghdad # Arabic Iraq Baghdad - ar_KW: Asia/Kuwait # Arabic Kuwait Kuwait City - ar_LB: Asia/Beirut # Arabic Lebanon Beirut - ar_LY: Africa/Tripoli # Arabic Libya Tripoli - ar_MA: Africa/Casablanca # Arabic Morocco Rabat - ar_OM: Asia/Muscat # Arabic Oman Muscat - ar_QA: Asia/Qatar # Arabic Qatar Doha - ar_SA: Asia/Riyadh # Arabic Saudi Arabia Riyadh - ar_SY: Asia/Damascus # Arabic Syria Damascus - ar_SD: Africa/Khartoum # Arabic Sudan Khartoum - ar_TN: Africa/Tunis # Arabic Tunisia Tunis - ar_YE: Asia/Qatar # Arabic Yemen Sanaa - ar_JO: Asia/Amman # Arabic Jordan Amman - be_BY: Europe/Minsk # Belarusian Belarus Minsk - bg_BG: Europe/Sofia # Bulgarian Bulgaria Sofia - ca_ES: Europe/Madrid # Catalan Spain Madrid - cs_CZ: Europe/Prague # Czech Czech Republic Prague - da_DK: Europe/Copenhagen # Danish Denmark Copenhagen - de_AT: Europe/Vienna # German Austria Vienna - de_DE: Europe/Berlin # German Germany Berlin - de_CH: Europe/Zurich # German Switzerland Bern - de_LU: Europe/Luxembourg # German Luxembourg Luxembourg - el_CY: Asia/Nicosia # Greek Cyprus Nicosia - el_GR: Europe/Athens # Greek Greece Athens - en_AU: Australia/Canberra # English Australia Canberra - en_CA: America/Montreal # English Canada Ottawa - en_GB: Europe/London # English United Kingdom London - en_IE: Europe/Dublin # English Ireland Dublin - en_IN: Asia/Calcutta # English India New Delhi - en_NZ: Pacific/Auckland # English New Zealand Wellington - en_MT: Europe/Malta # English Malta Valletta - en_PH: Asia/Manila # English Philippines Manila - en_SG: Asia/Singapore # English Singapore Singapore - en_US: America/Los_Angeles # English United States Washington - en_ZA: Africa/Johannesburg # English South Africa Pretoria - es_AR: America/Argentina/Buenos_Aires # Spanish Argentina Buenos Aires - es_BO: America/La_Paz # Spanish Bolivia La Paz - es_CO: America/Bogota # Spanish Colombia Bogota - es_CR: America/Costa_Rica # Spanish Costa Rica San Jose - es_CL: America/Santiago # Spanish Chile Santiago - es_DO: America/Santo_Domingo # Spanish Dominican Republic Santo Domingo - es_EC: America/Guayaquil # Spanish Ecuador Quito - es_ES: Europe/Madrid # Spanish Spain Madrid - es_GT: America/Guatemala # Spanish Guatemala Guatemala City - es_HN: America/Tegucigalpa # Spanish Honduras Tegucigalpa - es_MX: America/Mexico_City # Spanish Mexico Mexico City - es_NI: America/Managua # Spanish Nicaragua Managua - es_PA: America/Panama # Spanish Panama Panama City - es_PE: America/Lima # Spanish Peru Lima - es_PR: America/Argentina/San_Juan # Spanish Puerto Rico San Juan - es_PY: America/Asuncion # Spanish Paraguay Asuncion - es_SV: America/El_Salvador # Spanish El Salvador San Salvador - es_US: America/Los_Angeles # Spanish United States Washington - es_UY: America/Montevideo # Spanish Uruguay Montevideo - es_VE: America/Caracas # Spanish Venezuela Caracas - et_EE: Europe/Tallinn # Estonian Estonia Tallinn - fi_FI: Europe/Helsinki # Finnish Finland Helsinki - fr_BE: Europe/Brussels # French Belgium Brussels - fr_CA: America/Montreal # French Canada Ottawa - fr_CH: Europe/Zurich # French Switzerland Bern - fr_FR: Europe/Paris # French France Paris - fr_LU: Europe/Luxembourg # French Luxembourg Luxembourg - ga_IE: Europe/Dublin # Irish Ireland Dublin - hi_IN: Asia/Calcutta # Hindi India New Delhi - hu_HU: Europe/Budapest # Hungarian Hungary Budapest - hr_HR: Europe/Zagreb # Croatian Croatia Zagreb - in_ID: Asia/Jakarta # Indonesian Indonesia Jakarta - it_IT: Europe/Rome # Italian Italy Rome - it_CH: Europe/Zurich # Italian Switzerland Bern - is_IS: Atlantic/Reykjavik # Icelandic Iceland Reykjavik - iw_IL: Asia/Jerusalem # Hebrew Israel Jerusalem* - ja_JP: Asia/Tokyo # Japanese Japan Tokyo - ko_KR: Asia/Seoul # Korean South Korea Seoul - lt_LT: Europe/Vilnius # Lithuanian Lithuania Vilnius - lv_LV: Europe/Riga # Latvian Latvia Riga - mk_MK: Europe/Skopje # Macedonian Macedonia Skopje - mt_MT: Europe/Malta # Maltese Malta Valletta - ms_MY: Asia/Kuala_Lumpur # Malay Malaysia Kuala Lumpur - nl_BE: Europe/Brussels # Dutch Belgium Brussels - nl_NL: Europe/Amsterdam # Dutch Netherlands Amsterdam - no_NO: Europe/Oslo # Norwegian Norway Oslo - pl_PL: Europe/Warsaw # Polish Poland Warsaw - pt_BR: America/Sao_Paulo # Portuguese Brazil Brasilia - pt_PT: Europe/Lisbon # Portuguese Portugal Lisbon - ro_RO: Europe/Bucharest # Romanian Romania Bucharest - ru_RU: Europe/Moscow # Russian Russia Moscow - sk_SK: Europe/Bratislava # Slovak Slovakia Bratislava - sl_SI: Europe/Ljubljana # Slovenian Slovenia Ljubljana - sq_AL: Europe/Tirane # Albanian Albania Tirane - sr_BA: Europe/Sarajevo # Serbian Bosnia and Herzegovina Sarajevo - sr_CS: Europe/Belgrade # Serbian Serbia and Montenegro Belgrade - sr_ME: Europe/Podgorica # Serbian Montenegro Podgorica - sr_RS: Europe/Belgrade # Serbian Serbia Belgrade - sv_SE: Europe/Stockholm # Swedish Sweden Stockholm - th_TH: Asia/Bangkok # Thai Thailand Bangkok - tr_TR: Europe/Istanbul # Turkish Turkey Ankara - uk_UA: Europe/Kiev # Ukrainian Ukraine Kyiv - zh_SG: Asia/Singapore # Chinese Singapore Singapore - zh_TW: Asia/Taipei # Chinese Taiwan Taipei - vi_VN: Asia/Ho_Chi_Minh # Vietnamese Vietnam Hanoi - zh_CN: Asia/Shanghai # Chinese China Beijing - zh_HK: Asia/Hong_Kong # Chinese Hong Kong Hong Kong \ No newline at end of file diff --git a/Resources/config/routes.php b/Resources/config/routes.php new file mode 100644 index 0000000..238daa7 --- /dev/null +++ b/Resources/config/routes.php @@ -0,0 +1,12 @@ +add('lunetics_timezone_browser', new Route('/_lunetics/timezone/browser', ['_controller' => BrowserTimezoneController::class], methods: ['POST'])); + +return $routes; diff --git a/Resources/config/services.xml b/Resources/config/services.xml deleted file mode 100644 index 33c3f00..0000000 --- a/Resources/config/services.xml +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - %lunetics_timezone.guesser.listener.class% - %lunetics_timezone.guesser.manager.class% - Lunetics\TimezoneBundle\TimezoneProvider\TimezoneProvider - - - - - - - - - - - - - - %lunetics_timezone.service.locale_mapper.data% - - - - - %lunetics_timezone.guesser.order% - - - - - - - %lunetics_timezone.default_timezone% - - - - - - - %lunetics_timezone.session_var% - %lunetics_timezone.default_timezone% - - - - - - - - - - diff --git a/Resources/doc/guesser.md b/Resources/doc/guesser.md deleted file mode 100644 index c749dd7..0000000 --- a/Resources/doc/guesser.md +++ /dev/null @@ -1,94 +0,0 @@ -Guessers -======== - -There are currently 3 timezone guessers available: - -1. geo -2. locale_mapper -3. locale - -geo guesser ------------ -The geo guesser utilizes the **pecl-geoip** extension and [MaxMinds geoip Database](http://www.maxmind.com/) -It tries to locate the client and set the appropiate timezone. - -locale_mapper guesser ---------------------- -The locale_mapper guesser tries to identify the timezone based on the locale mapping provided in the [LocaleMapper.yml](https://github.com/lunetics/TimezoneBundle/blob/master/Resources/config/LocaleMapper.yml). - -locale guesser --------------- -The locale guesser tries to identify the timezone based on the current locale via the **pecl-geoip** extension. - -Custom Guesser -============== -If you want to implement your own timezone guesser, you need the following steps: - -1. Define the guesser class in the config ------------------------------------------ -Add the following configuration to `app/config/config.yml`: - -``` yaml -lunetics_timezone: - service: - acme_guesser: - class: Acme\AcmeBundle\TimezoneGuesser\AcmeTimezoneGuesser -``` - -2. Add the guesser as service ------------------- -It is important that you add exactly this tag name. -``` xml - - - -``` - -3. Build your guesser class --------------------------- -You need to build the guesser class. The class must implement the TimezoneGuesserInterface. - -If a timezone was found, the `guessTimezone` function must set the timezone in the object and return the string value of the timezone. -If no timezone was found, the `guessTimezone` function must return -``` php -// src/Acme/AcmeBundle/TimezoneGuesser/AcmeTimezoneGuesser.php -identifiedTimezone = $foundTimezone; - return $this->identifiedTimezone; - } - - return false; - } - - public function getIdentifiedTimezone() - { - return $this->identifiedTimezone; - } -} -``` - -4. Add the guesser to the config --------------------------------- -Add the Service alias tag name to the order in `app/config/config.yml`: -``` yaml -lunetics_timezone: - guesser: - order: - - acme_guesser - - geo - - locale_mapper - - locale -``` diff --git a/Resources/doc/index.md b/Resources/doc/index.md index 36dbcb6..455b078 100644 --- a/Resources/doc/index.md +++ b/Resources/doc/index.md @@ -1,7 +1,9 @@ -Installation -============ -[Read the Documentation](https://github.com/lunetics/TimezoneBundle/blob/master/Resources/doc/installation.md) +# LuneticsTimezoneBundle 2.x -Guessers -============ -[Read the Documentation](https://github.com/lunetics/TimezoneBundle/blob/master/Resources/doc/guesser.md) \ No newline at end of file +The bundle resolves one validated IANA timezone per main request and makes it available as request-scoped application state. Resolution is an ordered policy: explicit request input and manual preferences can outrank user/OIDC data, browser preferences, geographic data, locale inference, and the configured fallback. + +Use it when an application needs a consistent current-user timezone across controllers, Twig rendering, Symfony forms, and Messenger handlers, with persistence and an inspectable resolution trace. It never changes PHP's global default timezone. + +Start with [installation and configuration](installation.md), then see [resolver precedence](resolvers.md). [Scope](scope.md) explains what belongs in the bundle. The [V2 technical plan and ADR](v2-implementation-plan.md) is the authoritative implementation and contract reference; existing 1.x users should read [the upgrade guide](../../UPGRADE-2.0.md). + +The public entry point for application code is `Lunetics\TimezoneBundle\Context\CurrentTimezoneProviderInterface`. Optional adapters support application callables through `CallableTimezoneResolver`, users, explicit OIDC claims, MaxMind City lookups, browser sync, Twig, Form, Messenger, the profiler, and diagnostics commands. diff --git a/Resources/doc/installation.md b/Resources/doc/installation.md index 55e6cb7..b088a1d 100644 --- a/Resources/doc/installation.md +++ b/Resources/doc/installation.md @@ -1,67 +1,190 @@ -Installation -============ -This section shows you how to install this bundle - -Add the package to your dependencies ------------------------------------- -``` php -"require": { - "lunetics/timezone-bundle": "2.1.*", - .... -}, +# Installation and configuration + +## Requirements and registration + +Install PHP `^8.2` and Symfony `^6.4 || ^7.4 || ^8.0`, then: + +```bash +composer require lunetics/timezone-bundle ``` -If you want the bleeding-edge version, add this instead of above: -``` php -"require": { - "lunetics/timezone-bundle": "dev-master", - .... -}, +If Flex did not register it, add `Lunetics\TimezoneBundle\LuneticsTimezoneBundle::class => ['all' => true]` to `config/bundles.php`. + +No Symfony Validator component is required. The browser adapter is a pure ES module and requires neither Stimulus nor a JavaScript build tool. + +## Complete configuration + +This tree shows every option and default: + +```yaml +lunetics_timezone: + default_timezone: UTC + resolution: + failure_strategy: continue # continue|throw + request_attribute: + enabled: true + attribute: _timezone + priority: 1000 + header: + enabled: false + name: X-Timezone + trust: framework # framework|allowlist|any + trusted_sources: [] + priority: 950 + user: + enabled: auto # auto|true|false + accessor: null + priority: 900 + oidc: + enabled: false + service: null + claim: zoneinfo + priority: 850 + maxmind: + enabled: false + database: null + reader: null + priority: 400 + locale_mapping: + enabled: auto # auto|true|false + mapping: {} + priority: 200 + locale: + enabled: true + priority: 100 + persistence: + storage: session # session|cookie|service-id + failure_strategy: continue # continue|throw + session: + key: _lunetics_timezone + cookie: + name: _lunetics_timezone + max_age: 31536000 + path: / + domain: null + secure: auto # auto|true|false + http_only: true + same_site: lax # lax|strict|none + secret: null + future_skew: 60 + max_size: 4096 + browser: + enabled: false + csrf: + enabled: true + token_id: lunetics_timezone.preference + header: X-CSRF-Token + integrations: + twig: auto # auto|true|false + form: false + messenger: false + profiler: auto # auto|true|false ``` +`header.trust: framework` accepts the header only from requests Symfony identifies as coming through a trusted proxy. `allowlist` requires at least one exact IP/CIDR in `trusted_sources`. `any` trusts every client and is explicitly unsafe unless an upstream boundary removes and rewrites the header. + +Configuration is strict at container-build time. Unknown keys under `integrations` are rejected. The cookie name must be a valid cookie identifier, and configured MaxMind `database`/`reader` identifiers must be non-blank; invalid or blank values fail while the container is built rather than at first use. + +Cookie storage requires `cookie.secret`. Its versioned JSON value is signed; invalid and expired values are ignored and cleared on the response. `same_site: none` requires the explicit setting `secure: true`. A `__Host-` name likewise requires explicit `secure: true`, `path: /`, and `domain: null`; `secure: auto` is not sufficient for either rule. + +## Browser synchronization and route opt-in -Register the bundle in your kernel ----------------------------------- -``` php -public function registerBundles() - { - $bundles = array( - // ... - new Lunetics\TimezoneBundle\LuneticsTimezoneBundle(), - ); +Enabling the service does not add a route. Import it explicitly: + +```yaml +# config/routes/lunetics_timezone.yaml +lunetics_timezone: + resource: '@LuneticsTimezoneBundle/Resources/config/routes.php' + type: php ``` -Update your packages --------------------- -``` sh -php composer.phar update lunetics/timezone-bundle +With AssetMapper, import the shipped path and call it from your own module: + +```js +import {syncBrowserTimezone} from 'bundles/luneticstimezone/timezone.js'; + +syncBrowserTimezone({ + endpoint: '/_lunetics/timezone/browser', + csrfToken: document.querySelector('meta[name="timezone-csrf"]')?.content, + csrfHeader: 'X-CSRF-Token', + storedBrowserTimezone: null, +}); ``` -Configuration -============= -Add the following lines to your app/config/config.yml +Render the CSRF token for the configured token ID, for example ``. Pass the configured `browser.csrf.header` as `csrfHeader`; both default to `X-CSRF-Token`. The module POSTs exactly `{"timezone":"Europe/Berlin"}` as JSON, sends the token in that header, and emits `lunetics:timezone-synced` after a successful response. CSRF is enabled by default and requires Symfony CSRF protection. The endpoint uses `204` for success/no change, `400` for malformed JSON or shape, `403` for invalid CSRF, `413` above 1024 bytes, `415` for a non-JSON content type, `422` for an invalid timezone, and `503` on persistence failure. A manual preference is never overwritten by browser sync. -``` yaml -lunetics_timezone: - guesser: - order: - - geo - - locale_mapper - - locale +When `persistence.storage: session` is used, browser writes require Framework session configuration so the request has a session. A missing session raises the bundle's typed storage failure at the storage boundary and the browser endpoint returns `503`. + +## Provider and execution context + +Inject `Lunetics\TimezoneBundle\Context\CurrentTimezoneProviderInterface`. Its `getTimezone()` returns `TimezoneId`, `getDateTimeZone()` returns `\DateTimeZone`, and `getResolution()` includes source and kind. `TimezoneExecutionContextInterface::run(TimezoneId $timezone, callable $callback): mixed` temporarily overrides the current timezone for a bounded callback. + +## Users and OIDC + +With Symfony Security available, `resolution.user.enabled: auto` enables user resolution. Either make the authenticated user implement: + +```php +Lunetics\TimezoneBundle\Contract\User\TimezoneAwareUserInterface::getTimezone(): TimezoneId|string|null ``` -PHP geoip extension -=================== -The easiest way to install the pecl `geo` extension is to run: -``` sh -pecl install geoip +or configure an accessor service implementing: + +```php +Lunetics\TimezoneBundle\Contract\User\UserTimezoneAccessorInterface::getTimezoneForUser(object $user): TimezoneId|string|null ``` -There shoult be a meta-package in your distributions, e.g. `pecl-geoip` port in FreeBSD or `php5-geoip` in Ubuntu. +Set its service ID in `resolution.user.accessor`. OIDC is separate and explicit: enable it, set `resolution.oidc.service` to a service implementing `OidcClaimsProviderInterface::claimsForRequest(Request $request): array`, and optionally change the `zoneinfo` claim name. The public resolution source is `oidc_zoneinfo` for the default `zoneinfo` claim. Any customized claim uses `oidc_claim_`, where `` is the first 16 lowercase hexadecimal SHA-256 characters of the exact case-sensitive configured claim name. This bounded hash keeps raw/custom claim names out of diagnostics while remaining stable. The provider supplies already verified claims; the bundle uses no reflection and does not authenticate tokens. + +## Custom resolver and storage + +Custom resolvers implement `TimezoneResolverInterface::resolve(Request $request): ?TimezoneResolution` and require an explicit `lunetics_timezone.resolver` tag with a unique `index` and optional `priority`. The interface is deliberately not auto-tagged. See [resolver precedence](resolvers.md). + +The shipped `CallableTimezoneResolver` adapts a `callable(Request): mixed` and is likewise registered explicitly; it is useful for application methods such as `['@App\Timezone\AccountTimezoneLookup', 'resolve']`. See [resolver precedence](resolvers.md) for the complete constructor, return conversion, failure semantics, and YAML service definition. + +For custom persistence, set `persistence.storage` to a service ID implementing: + +```php +public function read(Request $request): TimezonePreferenceRead; +public function write(Request $request, Response $response, TimezonePreference $preference): void; +public function clear(Request $request, Response $response): void; +``` + +The storage must preserve the preference timezone, `manual|browser` source, and recording time. Built-in session storage stores the envelope directly; cookie storage encodes a signed versioned JSON envelope. + +## MaxMind City + +Install `geoip2/geoip2` and point `resolution.maxmind.database` at a GeoLite2 City or GeoIP2 City database. Both use the reader's `city()` lookup. Country and ASN databases are unsupported. GeoIP2 Enterprise is supported only across the custom reader boundary: configure `resolution.maxmind.reader` with a service implementing `MaxMindCityReaderInterface::timezoneForIp(string $ipAddress): TimezoneId|string|null`, or adapt a callable with `CallableMaxMindCityReader`. Exactly one of `database` and `reader` is required when enabled. + +## Framework integrations and diagnostics + +- Twig (`auto`) sets Twig's CoreExtension timezone after main-request resolution and restores the previous value on finish/terminate/reset, including nested stack handling. +- Form (`false`) defaults `view_timezone` on `DateTimeType`, `DateType`, and `TimeType` to the current timezone. +- Messenger (`false`) publishes `lunetics_timezone.messenger.dispatch_middleware` and `lunetics_timezone.messenger.worker_middleware`. Add them to the appropriate bus middleware configuration yourself; the bundle does not mutate buses. Dispatch adds `TimezoneStamp`; worker handling runs downstream middleware inside the stamped execution context. +- Profiler (`auto`) adds the timezone collector in a debug kernel when WebProfilerBundle is active. +- `debug:timezone` prints the configured default and resolver order when Console is installed. `timezone:check` is available for a configured local MaxMind City database. + +For example, wire both Messenger services on a bus whose dispatches and handlers should carry timezone context: + +```yaml +framework: + messenger: + buses: + messenger.bus.default: + middleware: + - lunetics_timezone.messenger.dispatch_middleware + - lunetics_timezone.messenger.worker_middleware +``` + +Place them according to your send/handle topology if dispatch and worker handling use different buses. + +Profiler diagnostics are scalar/array data and survive Symfony profile serialization and later profile reload. + +## Clock ownership + +The bundle uses the internal service `lunetics_timezone.clock`. After all extensions have loaded, it points to an existing application `Psr\Clock\ClockInterface` service when one is available, or to the bundle's `SystemClock` fallback otherwise. The bundle never defines or replaces the application's global `Psr\Clock\ClockInterface` alias. + +## Failures and logging + +`resolution.failure_strategy` independently controls resolver failures; `persistence.failure_strategy` controls storage read/cleanup failures. `continue` records the failure and proceeds where possible, while `throw` propagates the typed failure. Browser writes always map persistence failures to `503`. -Install additional geoip database ---------------------------------- -You also need to install the GeoLiteCity.dat file to use the `locale` guesser. -* Download http://geolite.maxmind.com/download/geoip/database/GeoLiteCity.dat.gz -* gunzip the file -* Rename the file to **GeoIPCity.dat** and move it to `/usr/share/GeoIP/` +When a PSR-3 `logger` service exists, resolver attempts are logged with structured fields such as resolver, outcome, source, kind, timezone, and duration. Claims, user objects, headers, IP addresses, cookie/session contents, secrets, and exception messages are not logged. Keep the same redaction boundary in custom adapters. diff --git a/Resources/doc/resolvers.md b/Resources/doc/resolvers.md new file mode 100644 index 0000000..75e894a --- /dev/null +++ b/Resources/doc/resolvers.md @@ -0,0 +1,68 @@ +# Resolvers and precedence + +The first resolver returning a `TimezoneResolution` wins. Defaults produce this order: + +| Priority | Resolver | Notes | +| ---: | --- | --- | +| 1000 | request attribute | `_timezone` by default | +| 950 | trusted header | disabled by default | +| 925 | stored manual preference | fixed priority | +| 900 | authenticated user | `auto` by default | +| 850 | OIDC claim | disabled by default | +| 800 | stored browser preference | fixed priority | +| 400 | MaxMind City | disabled by default | +| 200 | locale mapping | active in `auto` only when mapping is non-empty | +| 100 | locale unique-country inference | enabled by default; resolves only when the country has exactly one timezone | +| last | synthetic configured default | always present and not a tagged resolver | + +Manual storage suppresses a browser preference: the browser endpoint does not overwrite it, and the browser resolver only accepts browser-sourced records. Equal priorities retain registration order (FIFO). Every explicit tag `index` must be unique; duplicate indices are rejected while compiling the container. + +The OIDC resolver's public resolution source is `oidc_zoneinfo` for the default `zoneinfo` claim. Any customized claim uses `oidc_claim_`, where `` is the first 16 lowercase hexadecimal SHA-256 characters of the exact case-sensitive configured claim name. This bounded hash keeps raw/custom claim names out of diagnostics while remaining stable. + +## Custom resolver + +Implement the deliberately non-auto-tagged contract: + +```php +use Lunetics\TimezoneBundle\Resolution\TimezoneResolution; +use Lunetics\TimezoneBundle\Resolver\TimezoneResolverInterface; +use Symfony\Component\HttpFoundation\Request; + +final class AccountTimezoneResolver implements TimezoneResolverInterface +{ + public function resolve(Request $request): ?TimezoneResolution + { + // Return null to let the next resolver run. + return null; + } +} +``` + +Register it with an explicit, unique diagnostic index and an optional priority (the Symfony tagged-iterator default is `0`): + +```yaml +services: + App\Timezone\AccountTimezoneResolver: + tags: + - { name: lunetics_timezone.resolver, index: account, priority: 875 } +``` + +Use a safe, stable `TimezoneResolution::$source` identifier. Invalid timezone values should surface as a bundle resolution failure so the configured `continue|throw` policy can apply. + +For an existing application callable, the shipped `CallableTimezoneResolver` is a convenience adapter. Its constructor accepts `callable(Request): mixed`, a source matching the lowercase identifier pattern `^[a-z][a-z0-9_.-]{0,99}$`, and an optional `ResolutionKind` that defaults to `INFERRED`. It is not auto-registered. For example, given an invokable service method such as `AccountTimezoneLookup::resolve(Request $request)`, register the adapter explicitly: + +```yaml +services: + App\Timezone\AccountTimezoneLookup: ~ + + App\Timezone\AccountTimezoneResolver: + class: Lunetics\TimezoneBundle\Resolver\CallableTimezoneResolver + arguments: + $resolver: ['@App\Timezone\AccountTimezoneLookup', 'resolve'] + $source: account + # $kind defaults to Lunetics\TimezoneBundle\Resolution\ResolutionKind::INFERRED + tags: + - { name: lunetics_timezone.resolver, index: account, priority: 875 } +``` + +The callable may return `null` to continue, a `TimezoneResolution` to pass it through unchanged, a string to validate through `TimezoneId`, or a `TimezoneId` to wrap with the configured source and kind. Any other return type raises `TimezoneResolverException`. Invalid strings raise `InvalidTimezoneException`. Those two typed resolution failures are governed by `resolution.failure_strategy`; an application callable may deliberately throw `TimezoneResolverException` to request that policy-controlled handling. Unrelated exceptions and errors intentionally bubble instead of being converted into resolver failures. diff --git a/Resources/doc/scope.md b/Resources/doc/scope.md new file mode 100644 index 0000000..280dd6f --- /dev/null +++ b/Resources/doc/scope.md @@ -0,0 +1,5 @@ +# Scope + +Symfony already provides timezone validation/list building through PHP and framework components, plus timezone-aware form options. This bundle does not replace those pieces. It supplies the missing application policy around them: ordered per-user resolution, preference persistence, request and execution context, browser-to-server synchronization, Twig and asynchronous propagation, and resolution traces/diagnostics. + +Non-goals are changing PHP's global default timezone, converting stored timestamps, replacing a clock or calendar library, managing timezone database updates, authenticating OIDC tokens, or shipping vendor-specific persistence and identity integrations. Doctrine, Redis, API Platform, vendor-specific OIDC, Stimulus, and timestamp-conversion adapters are intentionally outside the package. diff --git a/Resources/doc/v2-implementation-plan.md b/Resources/doc/v2-implementation-plan.md new file mode 100644 index 0000000..7dcf4e6 --- /dev/null +++ b/Resources/doc/v2-implementation-plan.md @@ -0,0 +1,197 @@ +# V2 technical plan, ADR, and contracts + +**Status: implemented and current (July 2026).** This is the sole authoritative technical plan and architecture decision record for V2. User-facing setup remains in [installation.md](installation.md). + +## Goals and non-goals + +V2 provides deterministic per-request IANA timezone resolution; validated value objects; session, cookie, or custom persistence; an application-facing current-timezone provider; bounded execution context; browser synchronization; optional user, OIDC, MaxMind City, locale, Twig, Form, Messenger, profiler, and console adapters; and redacted diagnostics. + +`CallableTimezoneResolver` is the shipped convenience boundary for application `callable(Request): mixed` resolvers. It requires a safe lowercase source matching `^[a-z][a-z0-9_.-]{0,99}$`, defaults its kind to `INFERRED`, passes through `null` and `TimezoneResolution`, validates strings through `TimezoneId`, wraps `TimezoneId` with its configured source/kind, and rejects unsupported results with `TimezoneResolverException`. `InvalidTimezoneException` and `TimezoneResolverException` follow `resolution.failure_strategy`; deliberately thrown `TimezoneResolverException` is therefore policy-controlled, while unrelated exceptions and errors bubble. The adapter is not auto-registered and requires the same explicit resolver tag as any custom resolver. + +It does not change PHP's process-global timezone, convert stored timestamps, authenticate OIDC tokens, update timezone/GeoIP databases, or provide Doctrine, Redis, vendor-specific OIDC, API Platform, Stimulus, timestamp-conversion, Country/ASN MaxMind, or other vendor persistence adapters. It is one Composer package, not a core-plus-bridge package family. + +## Support policy and forward compatibility + +The package requires PHP `^8.2` and Symfony components `^6.4 || ^7.4 || ^8.0`. As of July 2026, Symfony 8.1 is the primary current target. Composer `^8.0` deliberately admits forward-compatible Symfony 8.x minors; CI also exercises the declared older lines. Optional integrations fail clearly when explicitly enabled without their component, while `auto` integrations activate only when their framework extension/service is present. + +Public interfaces and value semantics listed below are the compatibility surface. Internal service construction and listener wiring may evolve without being treated as public API. V2 is a clean break from 1.x and contains no backward-compatibility layer. + +## Architecture and data flow + +For a main `kernel.request`, `ResolveTimezoneListener` invokes `TimezoneResolverChain`. The chain tries tagged resolvers in policy order, records every attempt in `TimezoneResolutionTrace`, selects the first result, or creates a synthetic configured-default result. It stores the resolution on the main request and dispatches `TimezoneResolvedEvent`. Subrequests do not resolve independently. + +`CurrentTimezoneProvider` computes current state in this exact precedence: + +1. active execution context; +2. resolution attached to the current main request; +3. configured default. + +Browser writes and invalid-preference cleanup use the response-aware storage contract. Twig temporarily applies the selected timezone to Twig only. Messenger stamps dispatches and establishes a bounded execution context while a worker middleware continues the stack. None of these paths changes PHP's global timezone. + +## Resolver policy + +The default order is request attribute `1000`, header `950`, stored manual fixed `925`, user `900`, OIDC `850`, stored browser fixed `800`, MaxMind `400`, locale mapping `200`, locale unique-country `100`, then the synthetic configured default last. Disabled resolvers are absent. The locale resolver returns a value only when the locale country has exactly one PHP timezone. + +The OIDC resolver's public resolution source is `oidc_zoneinfo` for the default `zoneinfo` claim. Any customized claim uses `oidc_claim_`, where `` is the first 16 lowercase hexadecimal SHA-256 characters of the exact case-sensitive configured claim name. This bounded hash keeps raw/custom claim names out of diagnostics while remaining stable. + +Higher priority runs first. Equal priority preserves service registration order (FIFO). Resolver services require the explicit `lunetics_timezone.resolver` tag; `TimezoneResolverInterface` is deliberately not auto-tagged. Every tag occurrence requires a non-empty, unique string `index`; `priority` is optional and defaults to `0` through Symfony's tagged iterator behavior. Missing, invalid, or duplicate tag indices are rejected at container compilation. Stable indices are used in traces and diagnostics. Stored manual and browser resolvers use distinct source filters. Manual preferences outrank and suppress browser preferences; the browser endpoint will not overwrite a manual record. + +## Public contracts + +The signatures below are copied from the implemented source (imports omitted only for readability). + +```php +interface TimezoneResolverInterface +{ + public function resolve(Request $request): ?TimezoneResolution; +} + +interface TimezonePreferenceStorageInterface +{ + public function read(Request $request): TimezonePreferenceRead; + public function write(Request $request, Response $response, TimezonePreference $preference): void; + public function clear(Request $request, Response $response): void; +} + +interface CurrentTimezoneProviderInterface +{ + public function getResolution(): TimezoneResolution; + public function getResolutionForRequest(Request $request): TimezoneResolution; + public function getTimezone(): TimezoneId; + public function getDateTimeZone(): \DateTimeZone; +} + +interface TimezoneExecutionContextInterface +{ + public function run(TimezoneId $timezone, callable $callback): mixed; +} + +interface TimezoneAwareUserInterface +{ + public function getTimezone(): TimezoneId|string|null; +} + +interface UserTimezoneAccessorInterface +{ + public function getTimezoneForUser(object $user): TimezoneId|string|null; +} + +interface OidcClaimsProviderInterface +{ + /** @return array */ + public function claimsForRequest(Request $request): array; +} + +interface MaxMindCityReaderInterface +{ + public function timezoneForIp(string $ipAddress): TimezoneId|string|null; +} + +interface CountryTimezoneSourceInterface +{ + /** @return list */ + public function forCountry(string $countryCode): array; +} +``` + +Core public value/event construction is: + +```php +TimezoneId::fromString(string $value): TimezoneId; +TimezoneId::fromDateTimeZone(\DateTimeZone $timezone): TimezoneId; +TimezoneId::value(): string; +TimezoneId::toDateTimeZone(): \DateTimeZone; +TimezoneId::equals(TimezoneId $other): bool; + +new TimezoneResolution(TimezoneId $timezone, string $source, ResolutionKind $kind); +new TimezonePreference(TimezoneId $timezone, PreferenceSource $source, \DateTimeImmutable $recordedAt); +new TimezonePreferenceRead(PreferenceReadStatus $status, ?TimezonePreference $preference = null); +new TimezoneResolutionAttempt(string $resolver, ResolutionAttemptOutcome $outcome, ?string $source = null, int $durationMicroseconds = 0); +new TimezoneResolutionTrace(array $attempts, ?TimezoneResolution $selected); +new TimezoneResolvedEvent(Request $request, TimezoneResolution $resolution); +new TimezonePreferenceChangedEvent(Request $request, ?TimezonePreference $previous, ?TimezonePreference $current); +new TimezoneStamp(string $timezone); +``` + +`ResolutionKind` values are `explicit`, `authenticated`, `persisted`, `inferred`, and `default`. Attempt outcomes are `no_result`, `resolved`, `invalid`, `failed`, and `defaulted`. Preference sources are only `manual` and `browser`; read statuses are `absent`, `valid`, `invalid`, and `expired`. Resolution and persistence failure interfaces are separate throwable markers. + +## Persistence format and security + +Every built-in storage envelope has exactly these keys in order: `v`, `timezone`, `source`, `recorded_at`. Version is integer `1`; timezone is a validated IANA identifier; source is `manual|browser`; recording time is an ISO-8601/ATOM string. Session storage stores this array under its configured key and does not start a missing session merely to read or clear it. + +Cookie storage serializes the same versioned envelope as JSON, base64url-encodes it, and appends a base64url HMAC-SHA-256 signature over the encoded payload. The HMAC key is derived from the configured secret using HKDF-SHA-256, length 32, info `lunetics-timezone-cookie-v1`. Reads use constant-time signature comparison, enforce encoded-size, schema/version, timezone/source/time, future-skew, and maximum-age checks, and classify bad input without exposing it. Cookie settings cover name, age, path, domain, secure, HttpOnly, SameSite, skew, and maximum size. `SameSite=None` and `__Host-` require explicit `secure=true`; `__Host-` also requires `/` and no domain. + +Custom storage is selected by service ID and must implement the exact request/response-aware interface. V2 ships no Doctrine or Redis implementation. + +Session storage reads and cleanup do not start a missing session. Writes require Framework session configuration; if the request has no session, the storage raises a typed failure and the browser endpoint returns `503`. + +## Browser route protocol + +Browser support defaults off. Enabling it registers the controller but applications must opt in to routing by importing `@LuneticsTimezoneBundle/Resources/config/routes.php` with type `php`. The POST route is `/_lunetics/timezone/browser`. The asset is the pure ES module `bundles/luneticstimezone/timezone.js`; it requires no Stimulus or build tool. + +The request content type must be `application/json` or an `application/*+json` type, body size at most 1024 bytes, and body exactly one string field: `{"timezone":""}`. CSRF defaults enabled with token ID `lunetics_timezone.preference` and header `X-CSRF-Token`. The controller returns `400` for malformed JSON/shape, `403` for CSRF failure, `413` for excess size, `415` for content type, `422` for invalid timezone, `503` for persistence failure, and `204` for written, unchanged, or manual-suppressed success. There is no Symfony Validator dependency. + +The module reads `Intl.DateTimeFormat().resolvedOptions().timeZone`, avoids a request when absent or equal to the supplied stored browser timezone, and accepts `csrfHeader` alongside `csrfToken` so applications can pass the configured `browser.csrf.header` (default `X-CSRF-Token`). It posts with same-origin credentials, emits `lunetics:timezone-synced` only on success, and resolves false on HTTP/network failure. + +## Shipped adapters and explicit exclusions + +- Request attribute, trusted header (`framework|allowlist|any`, with `any` explicitly unsafe), stored manual/browser, authenticated user, OIDC claim, MaxMind City, locale mapping, and unique-country locale resolvers. +- `CallableTimezoneResolver` for explicitly registered application callables. +- Session and signed-cookie storage plus the custom storage contract. +- `TimezoneAwareUserInterface` or an explicit `UserTimezoneAccessorInterface`; no reflective property/method discovery. +- Explicit `OidcClaimsProviderInterface`, default claim `zoneinfo`; no token verification or vendor-specific provider. +- GeoLite2 City and GeoIP2 City through `GeoIp2\\Database\\Reader::city()`. Country and ASN are unsupported. GeoIP2 Enterprise is available only through a custom `MaxMindCityReaderInterface` service or `CallableMaxMindCityReader` adapter. +- Browser ES module/controller, Twig, Form, Messenger, profiler, `debug:timezone`, and local-database `timezone:check`. + +Not shipped: Doctrine, Redis, vendor OIDC, API Platform, Stimulus, timestamp conversion, MaxMind Country/ASN, a general Enterprise database adapter, or recurring browser automation infrastructure. + +## Twig, Form, and Messenger lifecycle + +Twig integration obtains Twig's `CoreExtension`, pushes its prior timezone when `TimezoneResolvedEvent` fires, and restores it on main `kernel.finish_request`. `kernel.terminate` and reset provide cleanup; nested scope operations are stack-based and `run()` restores in `finally`. + +Form integration extends `DateTimeType`, `DateType`, and `TimeType`, setting the default `view_timezone` from the current provider. It does not alter model timezone or perform storage conversion. + +Messenger integration is opt-in and exposes public service IDs `lunetics_timezone.messenger.dispatch_middleware` and `lunetics_timezone.messenger.worker_middleware`. Users place these on their bus(es); the bundle does not inspect or mutate bus middleware. Dispatch middleware adds a validated `TimezoneStamp` only when absent. Worker middleware uses the last stamp, falling back to the provider, and runs downstream handling inside `TimezoneExecutionContextInterface::run()` so cleanup occurs in `finally`. + +## Diagnostics, failures, and logging + +`TimezoneResolutionTrace` is attached to the request and contains ordered resolver name, outcome, selected source, and duration. The debug command shows configured default/order. The profiler collector shows the effective result, configured resolvers, attempts, and preference read/write/cleanup flags; its normalized data survives Symfony profile serialization and reload. The MaxMind check command verifies that a configured local database is readable and has a City database type. + +`resolution.failure_strategy` and `persistence.failure_strategy` are separate `continue|throw` decisions. In continue mode, typed resolver failures are traced/logged and the chain continues; storage reads resolve as no result when configured to continue, and failed invalid-record cleanup is ignored. Throw mode propagates the typed failure. Browser persistence failures map to `503` regardless because the write did not succeed. + +Resolver logging uses PSR-3 levels and structured fields: resolver, outcome, duration, and, only for a selected validated result, source, kind, and timezone. It deliberately excludes raw header values, IPs, OIDC claims, user objects, cookies, session payloads, secrets, and exception text. Custom adapters must retain this redaction boundary. + +## Testing and CI policy + +PHPUnit covers value objects, resolver ordering/ties/failures, shipped resolvers, storage tamper/expiry/security behavior, controller protocol/statuses, context cleanup, Twig/Form/Messenger adapters, profiler/commands, compiler validation, and real-kernel smoke paths. Kernel coverage exercises both accepted and rejected CSRF tokens, the sessionless browser-write `503`, automatic bundle AssetMapper discovery without manually configured asset paths, and profiler serialization/reload. PHPStan is required. CI runs supported Symfony/PHP combinations—including PHP 8.3 with Symfony 6.4—plus prefer-lowest, `composer validate --strict --no-check-publish`, PHPStan, PHPUnit, and a clean `composer --no-dev` package smoke. The target is meaningful contract and branch-boundary coverage, not a promise of full branch coverage. + +The pure module has a small Node test. Browser automation is optional, one-time/local release-confidence work; recurring browser infrastructure or CI is intentionally not required. + +Distribution verification uses the Composer export/archive view: excluded development material must stay excluded, while documentation linked from the public entry points—including [scope.md](scope.md) and this plan—and the root [LICENSE](../../LICENSE) must remain in the archive. + +## Accepted ADRs and consequences + +1. **Use `TimezoneId`, not loose strings internally.** Invalid IDs fail at boundaries; adapters must convert explicitly. +2. **Resolve per request without changing the PHP default.** Concurrent and long-running workers avoid shared global timezone mutation; callers use the provider/context. +3. **Order explicit tagged resolvers.** Applications can insert policy predictably; they must choose unique stable indices and understand priorities. +4. **Keep manual and browser persistence sources distinct.** Explicit choice remains authoritative; storage format includes source and browser sync cannot overwrite manual state. +5. **Make routing and active integrations opt-in.** Installation has fewer hidden routes/bus changes; applications wire routes and Messenger middleware deliberately. +6. **Sign cookie state with a derived key and versioned schema.** Client persistence is tamper-evident and migratable; operators must supply/rotate a secret with awareness that old cookies become invalid. +7. **Use explicit user/OIDC/MaxMind boundaries.** No reflection or vendor coupling; applications write small adapters when their domain differs. +8. **Separate resolution from persistence failure policy.** Availability choices can differ; operators must configure both intentionally. +9. **Keep diagnostics structured and redacted.** Traces remain useful without leaking identity/network/session material; custom resolvers should use safe identifiers. +10. **Ship one Composer package.** Versioning and installation stay simple; optional code is guarded by configuration/component availability. +11. **Keep clock ownership scoped.** The internal `lunetics_timezone.clock` alias is selected after all extensions: it uses an existing application `Psr\Clock\ClockInterface`, otherwise `SystemClock`; the bundle never defines or replaces the application's global clock alias. +12. **Reject ambiguous configuration early.** Unknown integration keys, invalid/blank cookie or MaxMind identifiers, and configured-service alias cycles fail during container compilation. + +## Migration and release checklist + +- Remove every 1.x Guesser, manager, event, listener, provider, and bundle-validator reference; there is no BC layer. +- Replace application access with `CurrentTimezoneProviderInterface`; replace guessers with explicitly tagged `TimezoneResolverInterface` services. +- Choose session, signed cookie, or custom storage and migrate/discard old persisted values; V2 expects the version-1 envelope. +- Implement user/OIDC/MaxMind contracts explicitly where required. +- Import the browser route only if enabled, provide CSRF token/header, and load the pure ES module. +- Add both Messenger middleware service IDs to the intended bus configuration; verify ordering around send/handle middleware. +- Review header trust, cookie `secure`/SameSite/`__Host-`, failure strategies, and logging redaction. +- Run `composer validate --strict --no-check-publish`, `composer check`, `npm test`, the local kernel integration test, and targeted stale-API searches. +- Confirm README/docs links, changelog, upgrade guide, package contents, and supported dependency matrix before tagging 2.0.0. diff --git a/Resources/public/timezone.js b/Resources/public/timezone.js new file mode 100644 index 0000000..bf32755 --- /dev/null +++ b/Resources/public/timezone.js @@ -0,0 +1,25 @@ +export function syncBrowserTimezone({endpoint, csrfToken, csrfHeader = 'X-CSRF-Token', storedBrowserTimezone}) { + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + if (!timezone || timezone === storedBrowserTimezone) { + return Promise.resolve(false); + } + + const headers = {'Content-Type': 'application/json'}; + if (csrfToken) { + headers[csrfHeader] = csrfToken; + } + + return fetch(endpoint, { + method: 'POST', + headers, + body: JSON.stringify({timezone}), + credentials: 'same-origin', + }).then((response) => { + if (!response.ok) { + return false; + } + window.dispatchEvent(new CustomEvent('lunetics:timezone-synced', {detail: {timezone}})); + return true; + }).catch(() => false); +} diff --git a/Resources/views/Collector/timezone.html.twig b/Resources/views/Collector/timezone.html.twig new file mode 100644 index 0000000..d2fc6db --- /dev/null +++ b/Resources/views/Collector/timezone.html.twig @@ -0,0 +1,39 @@ +{% extends '@WebProfiler/Profiler/layout.html.twig' %} + +{% block toolbar %} + {% set diagnostics = collector.diagnostics %} + {% if diagnostics.effective_timezone %} + {% set icon %}TZ{% endset %} + {% set text %}{{ diagnostics.effective_timezone }}{% endset %} + {{ include('@WebProfiler/Profiler/toolbar_item.html.twig', {link: profiler_url}) }} + {% endif %} +{% endblock %} + +{% block menu %} + + TZ + Timezone + +{% endblock %} + +{% block panel %} + {% set diagnostics = collector.diagnostics %} +

Timezone

+
+
{{ diagnostics.effective_timezone|default('n/a') }}Effective timezone
+
{{ diagnostics.configured_default }}Configured default
+
+

Resolution attempts

+ + + + {% for attempt in diagnostics.attempts %} + + {% else %} + + {% endfor %} + +
ResolverOutcomeSourceDuration (μs)
{{ attempt.resolver }}{{ attempt.outcome }}{{ attempt.source|default('') }}{{ attempt.duration_microseconds }}
No resolver attempts recorded.
+

Preference diagnostics

+

Read: {{ diagnostics.preference_read ? diagnostics.preference_read_status : 'no' }}; written: {{ diagnostics.preference_written ? 'yes' : 'no' }}; cleared: {{ diagnostics.preference_cleared ? 'yes' : 'no' }}

+{% endblock %} diff --git a/Tests/Bridge/Console/DebugTimezoneCommandTest.php b/Tests/Bridge/Console/DebugTimezoneCommandTest.php new file mode 100644 index 0000000..fee0e1f --- /dev/null +++ b/Tests/Bridge/Console/DebugTimezoneCommandTest.php @@ -0,0 +1,27 @@ + 'manual', 'priority' => 925], + ['name' => 'browser', 'priority' => 800], + ])); + + self::assertSame(0, $tester->execute([])); + $display = $tester->getDisplay(); + self::assertStringContainsString('Configured default: UTC', $display); + self::assertLessThan(strpos($display, 'browser'), strpos($display, 'manual')); + self::assertStringContainsString('925', $display); + self::assertStringContainsString('800', $display); + } +} diff --git a/Tests/Bridge/Form/TimezoneTypeExtensionTest.php b/Tests/Bridge/Form/TimezoneTypeExtensionTest.php new file mode 100644 index 0000000..c114d6b --- /dev/null +++ b/Tests/Bridge/Form/TimezoneTypeExtensionTest.php @@ -0,0 +1,57 @@ +> $type */ + #[DataProvider('typeProvider')] + public function testLazyDefaultAndExplicitOverrideWithoutModelTimezone(string $type): void + { + $provider = $this->createStub(CurrentTimezoneProviderInterface::class); + $provider->method('getTimezone')->willReturn(TimezoneId::fromString('Europe/Berlin')); + $extension = new TimezoneTypeExtension($provider); + $factory = Forms::createFormFactoryBuilder()->addTypeExtension($extension)->getFormFactory(); + + $defaults = $factory->create($type)->getConfig()->getOptions(); + self::assertSame('Europe/Berlin', $defaults['view_timezone']); + self::assertArrayHasKey('model_timezone', $defaults); + + $options = ['view_timezone' => 'Asia/Tokyo', 'model_timezone' => 'UTC']; + if (TimeType::class === $type) { + $options['reference_date'] = new \DateTimeImmutable('2000-01-01', new \DateTimeZone('UTC')); + } + $explicit = $factory->create($type, null, $options)->getConfig()->getOptions(); + self::assertSame('Asia/Tokyo', $explicit['view_timezone']); + self::assertSame('UTC', $explicit['model_timezone']); + } + + /** @return iterable>}> */ + public static function typeProvider(): iterable + { + yield 'datetime' => [DateTimeType::class]; + yield 'date' => [DateType::class]; + yield 'time' => [TimeType::class]; + } +} diff --git a/Tests/Bridge/MaxMind/GeoIp2CityReaderTest.php b/Tests/Bridge/MaxMind/GeoIp2CityReaderTest.php new file mode 100644 index 0000000..c6b2c24 --- /dev/null +++ b/Tests/Bridge/MaxMind/GeoIp2CityReaderTest.php @@ -0,0 +1,88 @@ + ['names' => ['en' => 'Sensitive city']], + 'location' => ['time_zone' => 'Europe/Berlin'], + ])); + + self::assertSame('Europe/Berlin', (new GeoIp2CityReader($reader))->timezoneForIp('8.8.8.8')); + } + + public function testAddressNotFoundMapsToNullAndUnsupportedDatabaseIsWrapped(): void + { + self::assertNull((new GeoIp2CityReader(new StubGeoIp2Reader( + new AddressNotFoundException('not found'), + )))->timezoneForIp('8.8.8.8')); + + $this->expectException(TimezoneResolverException::class); + $this->expectExceptionMessage('The MaxMind City lookup failed.'); + (new GeoIp2CityReader(new StubGeoIp2Reader( + new \BadMethodCallException('country database'), + )))->timezoneForIp('8.8.8.8'); + } + + public function testLocalReaderIsConstructedOnlyOnFirstLookup(): void + { + $constructions = 0; + $lazy = new LazyGeoIp2CityReader( + '/not/opened/eagerly.mmdb', + readerFactory: static function (string $path, array $locales) use (&$constructions): Reader { + ++$constructions; + return new StubGeoIp2Reader(new City(['location' => ['time_zone' => 'UTC']])); + }, + ); + + self::assertSame(0, $constructions); + self::assertSame('UTC', $lazy->timezoneForIp('1.1.1.1')); + self::assertSame('UTC', $lazy->timezoneForIp('8.8.8.8')); + self::assertSame(1, $constructions); + } + + public function testLazyConstructionFailureIsWrappedWithoutPathDisclosure(): void + { + $lazy = new LazyGeoIp2CityReader( + '/sensitive/path.mmdb', + readerFactory: static fn (string $path, array $locales): never => throw new \InvalidArgumentException($path), + ); + + try { + $lazy->timezoneForIp('8.8.8.8'); + self::fail('Expected resolver exception.'); + } catch (TimezoneResolverException $exception) { + self::assertSame('The MaxMind City database could not be opened.', $exception->getMessage()); + self::assertStringNotContainsString('sensitive', $exception->getMessage()); + } + } +} + +final class StubGeoIp2Reader extends Reader +{ + public function __construct(private readonly City|\Exception $result) {} + + public function city(string $ipAddress): City + { + if ($this->result instanceof \Exception) { + throw $this->result; + } + return $this->result; + } +} diff --git a/Tests/Bridge/MaxMind/MaxMindDatabaseCheckCommandTest.php b/Tests/Bridge/MaxMind/MaxMindDatabaseCheckCommandTest.php new file mode 100644 index 0000000..5382a45 --- /dev/null +++ b/Tests/Bridge/MaxMind/MaxMindDatabaseCheckCommandTest.php @@ -0,0 +1,58 @@ +readableFile(); + try { + $tester = new CommandTester(new MaxMindDatabaseCheckCommand($path, static fn (): object => (object) ['databaseType' => 'GeoLite2-City'])); + self::assertSame(0, $tester->execute([])); + self::assertStringNotContainsString($path, $tester->getDisplay()); + } finally { + unlink($path); + } + } + + public function testRejectsNonCityMetadata(): void + { + $path = $this->readableFile(); + try { + $tester = new CommandTester(new MaxMindDatabaseCheckCommand($path, static fn (): object => (object) ['databaseType' => 'GeoLite2-Country'])); + self::assertNotSame(0, $tester->execute([])); + self::assertStringContainsString('not a City database', $tester->getDisplay()); + } finally { + unlink($path); + } + } + + public function testUnreadableDatabaseFailsBeforeFactoryIsCalled(): void + { + $called = false; + $path = sys_get_temp_dir().'/missing-timezone-'.bin2hex(random_bytes(6)).'.mmdb'; + $tester = new CommandTester(new MaxMindDatabaseCheckCommand($path, static function () use (&$called): object { + $called = true; + return (object) ['databaseType' => 'GeoIP2-City']; + })); + + self::assertNotSame(0, $tester->execute([])); + self::assertFalse($called); + self::assertStringNotContainsString($path, $tester->getDisplay()); + } + + private function readableFile(): string + { + $path = tempnam(sys_get_temp_dir(), 'timezone-mmdb-'); + self::assertIsString($path); + + return $path; + } +} diff --git a/Tests/Bridge/Messenger/TimezoneMessengerTest.php b/Tests/Bridge/Messenger/TimezoneMessengerTest.php new file mode 100644 index 0000000..23150ef --- /dev/null +++ b/Tests/Bridge/Messenger/TimezoneMessengerTest.php @@ -0,0 +1,122 @@ +timezone); + self::assertSame('Europe/Berlin', $stamp->toTimezoneId()->value()); + $roundTrip = unserialize(serialize($stamp)); + self::assertInstanceOf(TimezoneStamp::class, $roundTrip); + self::assertSame('Europe/Berlin', $roundTrip->timezone); + + $this->expectException(InvalidTimezoneException::class); + $class = TimezoneStamp::class; + unserialize(sprintf('O:%d:"%s":1:{s:8:"timezone";s:3:"bad";}', strlen($class), $class)); + } + + public function testDispatchAddsMissingStampAndRetainsExistingStamp(): void + { + $provider = $this->provider('Europe/Berlin'); + $middleware = new DispatchTimezoneMiddleware($provider); + $message = new \stdClass(); + $added = $middleware->handle(new Envelope($message), new StackMiddleware()); + self::assertSame('Europe/Berlin', $added->last(TimezoneStamp::class)?->timezone); + + $original = new TimezoneStamp('Asia/Tokyo'); + $retained = $middleware->handle(new Envelope($message, [$original]), new StackMiddleware()); + self::assertSame($original, $retained->last(TimezoneStamp::class)); + self::assertCount(1, $retained->all(TimezoneStamp::class)); + } + + public function testWorkerUsesLastStampAndCleansUpAfterSuccess(): void + { + $context = new TimezoneExecutionContext(); + $worker = new WorkerTimezoneMiddleware($context, $this->provider('UTC')); + $seen = null; + $terminal = $this->terminal(static function (Envelope $envelope) use ($context, &$seen): Envelope { + $seen = $context->current()?->value(); + return $envelope; + }); + $envelope = new Envelope(new \stdClass(), [new TimezoneStamp('Europe/Berlin'), new TimezoneStamp('Asia/Tokyo')]); + + $worker->handle($envelope, new StackMiddleware($terminal)); + self::assertSame('Asia/Tokyo', $seen); + self::assertNull($context->current()); + } + + public function testWorkerCleansUpAfterFailure(): void + { + $context = new TimezoneExecutionContext(); + $worker = new WorkerTimezoneMiddleware($context, $this->provider('UTC')); + $terminal = $this->terminal(static function (): never { + throw new \RuntimeException('failure'); + }); + + try { + $worker->handle(new Envelope(new \stdClass(), [new TimezoneStamp('Europe/Berlin')]), new StackMiddleware($terminal)); + self::fail('Expected exception.'); + } catch (\RuntimeException $exception) { + self::assertSame('failure', $exception->getMessage()); + } + self::assertNull($context->current()); + } + + public function testWorkerIsolatesSequentialAndMissingStampMessages(): void + { + $context = new TimezoneExecutionContext(); + $worker = new WorkerTimezoneMiddleware($context, $this->provider('UTC')); + $seen = []; + $terminal = $this->terminal(static function (Envelope $envelope) use ($context, &$seen): Envelope { + $seen[] = $context->current()?->value(); + return $envelope; + }); + + $worker->handle(new Envelope(new \stdClass(), [new TimezoneStamp('Europe/Berlin')]), new StackMiddleware($terminal)); + $worker->handle(new Envelope(new \stdClass()), new StackMiddleware($terminal)); + + self::assertSame(['Europe/Berlin', 'UTC'], $seen); + self::assertNull($context->current()); + } + + private function provider(string $timezone): CurrentTimezoneProviderInterface + { + $provider = $this->createStub(CurrentTimezoneProviderInterface::class); + $provider->method('getTimezone')->willReturn(TimezoneId::fromString($timezone)); + return $provider; + } + + /** @param callable(Envelope): Envelope $callback */ + private function terminal(callable $callback): MiddlewareInterface + { + return new class($callback) implements MiddlewareInterface { + /** @param callable(Envelope): Envelope $callback */ + public function __construct(private $callback) + { + } + + public function handle(Envelope $envelope, StackInterface $stack): Envelope + { + return ($this->callback)($envelope); + } + }; + } +} diff --git a/Tests/Bridge/Twig/TwigTimezoneScopeTest.php b/Tests/Bridge/Twig/TwigTimezoneScopeTest.php new file mode 100644 index 0000000..327c611 --- /dev/null +++ b/Tests/Bridge/Twig/TwigTimezoneScopeTest.php @@ -0,0 +1,59 @@ +setTimezone($original); + $scope = new TwigTimezoneScope($core); + + $result = $scope->run(TimezoneId::fromString('Europe/Berlin'), function () use ($scope, $core): string { + self::assertSame('Europe/Berlin', $core->getTimezone()->getName()); + $scope->run(TimezoneId::fromString('Asia/Tokyo'), static function () use ($core): void { + self::assertSame('Asia/Tokyo', $core->getTimezone()->getName()); + }); + self::assertSame('Europe/Berlin', $core->getTimezone()->getName()); + + return 'result'; + }); + + self::assertSame('result', $result); + self::assertSame($original, $core->getTimezone()); + } + + public function testExceptionAndResetRestoreOriginalTimezone(): void + { + $core = new CoreExtension(); + $original = new \DateTimeZone('UTC'); + $core->setTimezone($original); + $scope = new TwigTimezoneScope($core); + + try { + $scope->run(TimezoneId::fromString('Europe/Berlin'), static function (): void { + throw new \RuntimeException('failure'); + }); + self::fail('Expected exception.'); + } catch (\RuntimeException $exception) { + self::assertSame('failure', $exception->getMessage()); + } + self::assertSame($original, $core->getTimezone()); + + $scope->enter(TimezoneId::fromString('Europe/Berlin')); + $scope->enter(TimezoneId::fromString('Asia/Tokyo')); + $scope->reset(); + self::assertSame($original, $core->getTimezone()); + $scope->leave(); + self::assertSame($original, $core->getTimezone()); + } +} diff --git a/Tests/Bridge/Twig/TwigTimezoneSubscriberTest.php b/Tests/Bridge/Twig/TwigTimezoneSubscriberTest.php new file mode 100644 index 0000000..a1cf4c8 --- /dev/null +++ b/Tests/Bridge/Twig/TwigTimezoneSubscriberTest.php @@ -0,0 +1,54 @@ +setTimezone($original = new \DateTimeZone('UTC')); + $scope = new TwigTimezoneScope($core); + $subscriber = new TwigTimezoneSubscriber($scope); + $kernel = $this->createMock(HttpKernelInterface::class); + $main = new Request(); + $sub = new Request(); + + $subscriber->onTimezoneResolved($this->resolved($main, 'Europe/Berlin')); + self::assertSame('Europe/Berlin', $core->getTimezone()->getName()); + $subscriber->onKernelFinishRequest(new FinishRequestEvent($kernel, $sub, HttpKernelInterface::SUB_REQUEST)); + self::assertSame('Europe/Berlin', $core->getTimezone()->getName()); + $subscriber->onKernelFinishRequest(new FinishRequestEvent($kernel, $main, HttpKernelInterface::MAIN_REQUEST)); + self::assertSame($original, $core->getTimezone()); + + $second = new Request(); + $subscriber->onTimezoneResolved($this->resolved($second, 'Asia/Tokyo')); + self::assertSame('Asia/Tokyo', $core->getTimezone()->getName()); + $subscriber->onKernelTerminate(new TerminateEvent($kernel, $second, new Response())); + self::assertSame($original, $core->getTimezone()); + } + + private function resolved(Request $request, string $timezone): TimezoneResolvedEvent + { + return new TimezoneResolvedEvent( + $request, + new TimezoneResolution(TimezoneId::fromString($timezone), 'test', ResolutionKind::EXPLICIT), + ); + } +} diff --git a/Tests/Bridge/WebProfiler/TimezoneDataCollectorTest.php b/Tests/Bridge/WebProfiler/TimezoneDataCollectorTest.php new file mode 100644 index 0000000..25a5fda --- /dev/null +++ b/Tests/Bridge/WebProfiler/TimezoneDataCollectorTest.php @@ -0,0 +1,86 @@ + 'safe_resolver', 'priority' => 10]]); + $request = Request::create('/', 'POST', [], [], [], ['REMOTE_ADDR' => '203.0.113.8'], 'secret body'); + $resolution = new TimezoneResolution(TimezoneId::fromString('Europe/Berlin'), 'safe_source', ResolutionKind::INFERRED); + (new TimezoneResolutionTrace([ + new TimezoneResolutionAttempt('safe_resolver', ResolutionAttemptOutcome::RESOLVED, 'safe_source', 12), + ], $resolution))->attachTo($request); + $request->attributes->set(StoredPreferenceTimezoneResolver::READ_ATTRIBUTE, new TimezonePreferenceRead(PreferenceReadStatus::ABSENT)); + $request->attributes->set(BrowserTimezoneController::PREFERENCE_WRITTEN_ATTRIBUTE, true); + $request->attributes->set(InvalidPreferenceCleanupListener::PREFERENCE_CLEARED_ATTRIBUTE, true); + + $collector->collect($request, new Response(), new \RuntimeException('secret exception')); + $data = $collector->getDiagnostics(); + self::assertSame('Europe/Berlin', $data['effective_timezone']); + self::assertSame(12, $data['attempts'][0]['duration_microseconds']); + self::assertSame('absent', $data['preference_read_status']); + self::assertTrue($data['preference_written']); + self::assertTrue($data['preference_cleared']); + $serialized = serialize($data); + self::assertStringNotContainsString('203.0.113.8', $serialized); + self::assertStringNotContainsString('secret body', $serialized); + self::assertStringNotContainsString('secret exception', $serialized); + + $collector->reset(); + self::assertNull($collector->getDiagnostics()['effective_timezone']); + self::assertSame([], $collector->getDiagnostics()['attempts']); + } + + public function testProfilerTemplateHasValidTwigSyntax(): void + { + $source = file_get_contents(__DIR__.'/../../../Resources/views/Collector/timezone.html.twig'); + self::assertIsString($source); + $twig = new Environment(new ArrayLoader(['timezone' => $source])); + $twig->parse($twig->tokenize($twig->getLoader()->getSourceContext('timezone'))); + self::addToAssertionCount(1); + } + + public function testSerializationPreservesExactDiagnostics(): void + { + $collector = new TimezoneDataCollector('UTC', [['name' => 'request_attribute', 'priority' => 1000]]); + $request = new Request(); + $resolution = new TimezoneResolution(TimezoneId::fromString('Europe/Berlin'), 'request_attribute', ResolutionKind::EXPLICIT); + (new TimezoneResolutionTrace([ + new TimezoneResolutionAttempt('request_attribute', ResolutionAttemptOutcome::RESOLVED, 'request_attribute', 7), + ], $resolution))->attachTo($request); + $request->attributes->set(StoredPreferenceTimezoneResolver::READ_ATTRIBUTE, new TimezonePreferenceRead(PreferenceReadStatus::VALID, new \Lunetics\TimezoneBundle\Storage\TimezonePreference( + TimezoneId::fromString('Europe/Berlin'), + \Lunetics\TimezoneBundle\Storage\PreferenceSource::MANUAL, + new \DateTimeImmutable('2026-01-01T00:00:00Z'), + ))); + $collector->collect($request, new Response()); + $diagnostics = $collector->getDiagnostics(); + + $restored = unserialize(serialize($collector)); + + self::assertInstanceOf(TimezoneDataCollector::class, $restored); + self::assertSame($diagnostics, $restored->getDiagnostics()); + } +} diff --git a/Tests/Browser/timezone.test.mjs b/Tests/Browser/timezone.test.mjs new file mode 100644 index 0000000..1390308 --- /dev/null +++ b/Tests/Browser/timezone.test.mjs @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import {afterEach, test} from 'node:test'; +import {syncBrowserTimezone} from '../../Resources/public/timezone.js'; + +const originalFetch = globalThis.fetch; +const originalIntl = globalThis.Intl; +const originalWindow = globalThis.window; + +afterEach(() => { + globalThis.fetch = originalFetch; + globalThis.Intl = originalIntl; + globalThis.window = originalWindow; +}); + +function installBrowser(timezone) { + globalThis.Intl = {DateTimeFormat: () => ({resolvedOptions: () => ({timeZone: timezone})})}; + const events = []; + globalThis.window = {dispatchEvent: (event) => events.push(event)}; + globalThis.CustomEvent = class CustomEvent { + constructor(type, options) { + this.type = type; + this.detail = options.detail; + } + }; + return events; +} + +test('missing and unchanged browser zones avoid fetch', async () => { + let calls = 0; + globalThis.fetch = async () => { ++calls; }; + + installBrowser(undefined); + assert.equal(await syncBrowserTimezone({endpoint: '/timezone'}), false); + installBrowser('Europe/Berlin'); + assert.equal(await syncBrowserTimezone({endpoint: '/timezone', storedBrowserTimezone: 'Europe/Berlin'}), false); + assert.equal(calls, 0); +}); + +test('successful sync sends the exact post and dispatches the event', async () => { + const events = installBrowser('Europe/Berlin'); + const calls = []; + globalThis.fetch = async (...args) => { + calls.push(args); + return {ok: true}; + }; + + assert.equal(await syncBrowserTimezone({endpoint: '/_lunetics/timezone/browser', csrfToken: 'csrf-value'}), true); + assert.deepEqual(calls, [[ + '/_lunetics/timezone/browser', + { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRF-Token': 'csrf-value'}, + body: '{"timezone":"Europe/Berlin"}', + credentials: 'same-origin', + }, + ]]); + assert.equal(events.length, 1); + assert.equal(events[0].type, 'lunetics:timezone-synced'); + assert.deepEqual(events[0].detail, {timezone: 'Europe/Berlin'}); +}); + +test('custom CSRF header is used when configured', async () => { + installBrowser('Europe/Berlin'); + let options; + globalThis.fetch = async (_endpoint, requestOptions) => { + options = requestOptions; + return {ok: true}; + }; + + assert.equal(await syncBrowserTimezone({endpoint: '/timezone', csrfToken: 'csrf-value', csrfHeader: 'X-Timezone-CSRF'}), true); + assert.deepEqual(options.headers, {'Content-Type': 'application/json', 'X-Timezone-CSRF': 'csrf-value'}); +}); + +test('non-success response returns false without an event', async () => { + const events = installBrowser('Europe/Berlin'); + globalThis.fetch = async () => ({ok: false}); + + assert.equal(await syncBrowserTimezone({endpoint: '/timezone'}), false); + assert.deepEqual(events, []); +}); + +test('fetch rejection returns false without an event', async () => { + const events = installBrowser('Europe/Berlin'); + globalThis.fetch = async () => { throw new Error('offline'); }; + + assert.equal(await syncBrowserTimezone({endpoint: '/timezone'}), false); + assert.deepEqual(events, []); +}); diff --git a/Tests/Context/TimezoneExecutionContextTest.php b/Tests/Context/TimezoneExecutionContextTest.php new file mode 100644 index 0000000..a8f8154 --- /dev/null +++ b/Tests/Context/TimezoneExecutionContextTest.php @@ -0,0 +1,66 @@ +run($berlin, function () use ($context, $berlin, $utc): void { + $current = $context->current(); + self::assertNotNull($current); + self::assertTrue($current->equals($berlin)); + $context->run($utc, function () use ($context, $utc): void { + $current = $context->current(); + self::assertNotNull($current); + self::assertTrue($current->equals($utc)); + }); + $current = $context->current(); + self::assertNotNull($current); + self::assertTrue($current->equals($berlin)); + }); + self::assertNull($context->current()); + try { + $context->run($utc, static fn () => throw new \RuntimeException('expected')); + } catch (\RuntimeException) { + } + self::assertNull($context->current()); + $context->run($utc, function () use ($context): void { $context->reset(); }); + self::assertNull($context->current()); + } + + public function testProviderUsesExecutionContextThenMainRequestThenDefault(): void + { + $context = new TimezoneExecutionContext(); + $stack = new RequestStack(); + $default = TimezoneId::fromString('UTC'); + $provider = new CurrentTimezoneProvider($context, $stack, $default); + self::assertSame(ResolutionKind::DEFAULT, $provider->getResolution()->kind); + + $main = new Request(); + $mainResolution = new TimezoneResolution(TimezoneId::fromString('Europe/Berlin'), 'test', ResolutionKind::INFERRED); + $main->attributes->set(CurrentTimezoneProvider::RESOLUTION_ATTRIBUTE, $mainResolution); + $stack->push($main); + $stack->push(new Request()); + self::assertSame($mainResolution, $provider->getResolution()); + + $context->run(TimezoneId::fromString('Asia/Tokyo'), function () use ($provider): void { + self::assertSame('Asia/Tokyo', $provider->getTimezone()->value()); + }); + self::assertSame(ResolutionKind::DEFAULT, $provider->getResolutionForRequest(new Request())->kind); + } +} diff --git a/Tests/Controller/BrowserTimezoneControllerTest.php b/Tests/Controller/BrowserTimezoneControllerTest.php new file mode 100644 index 0000000..f29eb8a --- /dev/null +++ b/Tests/Controller/BrowserTimezoneControllerTest.php @@ -0,0 +1,223 @@ +controller($storage, $dispatcher))($this->request($body, $contentType)); + + self::assertSame($status, $response->getStatusCode()); + self::assertSame(0, $storage->reads); + self::assertSame([], $dispatcher->events); + } + + /** @return iterable */ + public static function rejectedRequests(): iterable + { + yield 'missing content type' => [null, '{"timezone":"Europe/Berlin"}', 415]; + yield 'non-json content type' => ['text/plain', '{"timezone":"Europe/Berlin"}', 415]; + yield 'body over limit' => ['application/json', str_repeat(' ', 1025), 413]; + yield 'malformed json' => ['application/json', '{', 400]; + yield 'list instead of object' => ['application/json', '["Europe/Berlin"]', 400]; + yield 'missing timezone' => ['application/json', '{}', 400]; + yield 'additional property' => ['application/json', '{"timezone":"Europe/Berlin","extra":true}', 400]; + yield 'non-string timezone' => ['application/json', '{"timezone":1}', 400]; + yield 'invalid timezone' => ['application/json', '{"timezone":"Mars/Olympus"}', 422]; + } + + public function testAcceptsVendorJsonAndWritesAndDispatchesExactlyOnce(): void + { + $storage = new RecordingStorage(); + $dispatcher = new RecordingDispatcher(); + $request = $this->request('{"timezone":"Europe/Berlin"}', 'application/vnd.lunetics+json; charset=utf-8'); + + $response = ($this->controller($storage, $dispatcher))($request); + + self::assertSame(204, $response->getStatusCode()); + self::assertCount(1, $storage->writes); + self::assertSame('Europe/Berlin', $storage->writes[0]->timezone->value()); + self::assertSame(PreferenceSource::BROWSER, $storage->writes[0]->source); + self::assertTrue($request->attributes->getBoolean(BrowserTimezoneController::PREFERENCE_WRITTEN_ATTRIBUTE)); + self::assertCount(1, $dispatcher->events); + self::assertInstanceOf(TimezonePreferenceChangedEvent::class, $dispatcher->events[0]); + self::assertNull($dispatcher->events[0]->previous); + self::assertSame($storage->writes[0], $dispatcher->events[0]->current); + } + + #[DataProvider('suppressedPreferences')] + public function testStoredPreferenceCanSuppressWrite(PreferenceSource $source, string $timezone): void + { + $storage = new RecordingStorage(new TimezonePreferenceRead(PreferenceReadStatus::VALID, $this->preference($timezone, $source))); + $dispatcher = new RecordingDispatcher(); + $request = $this->request('{"timezone":"Europe/Berlin"}', 'application/json'); + + $response = ($this->controller($storage, $dispatcher))($request); + + self::assertSame(204, $response->getStatusCode()); + self::assertSame([], $storage->writes); + self::assertFalse($request->attributes->has(BrowserTimezoneController::PREFERENCE_WRITTEN_ATTRIBUTE)); + self::assertSame([], $dispatcher->events); + } + + /** @return iterable */ + public static function suppressedPreferences(): iterable + { + yield 'manual preference always wins' => [PreferenceSource::MANUAL, 'America/New_York']; + yield 'same browser timezone is unchanged' => [PreferenceSource::BROWSER, 'Europe/Berlin']; + } + + #[DataProvider('storageFailures')] + public function testStorageFailuresReturnServiceUnavailableAndDispatchNothing(bool $failRead, bool $failWrite): void + { + $storage = new RecordingStorage(failRead: $failRead, failWrite: $failWrite); + $dispatcher = new RecordingDispatcher(); + $request = $this->request('{"timezone":"Europe/Berlin"}', 'application/json'); + + $response = ($this->controller($storage, $dispatcher))($request); + + self::assertSame(503, $response->getStatusCode()); + self::assertFalse($request->attributes->has(BrowserTimezoneController::PREFERENCE_WRITTEN_ATTRIBUTE)); + self::assertSame([], $dispatcher->events); + } + + /** @return iterable */ + public static function storageFailures(): iterable + { + yield 'read failure' => [true, false]; + yield 'write failure' => [false, true]; + } + + public function testConfiguredCsrfProtectionRejectsInvalidTokenBeforeStorage(): void + { + $storage = new RecordingStorage(); + $dispatcher = new RecordingDispatcher(); + $csrf = $this->createMock(CsrfTokenManagerInterface::class); + $csrf->expects(self::once())->method('isTokenValid')->with(self::callback( + static fn (CsrfToken $token): bool => 'browser-test' === $token->getId() && 'bad' === $token->getValue(), + ))->willReturn(false); + $controller = new BrowserTimezoneController($storage, $this->clock(), $dispatcher, $csrf, 'browser-test', 'X-Timezone-Csrf'); + $request = $this->request('{"timezone":"Europe/Berlin"}', 'application/json'); + $request->headers->set('X-Timezone-Csrf', 'bad'); + + self::assertSame(403, $controller($request)->getStatusCode()); + self::assertSame(0, $storage->reads); + self::assertSame([], $dispatcher->events); + } + + public function testCsrfProtectionUsesDefaultTokenIdAndHeader(): void + { + $storage = new RecordingStorage(); + $dispatcher = new RecordingDispatcher(); + $csrf = $this->createMock(CsrfTokenManagerInterface::class); + $csrf->expects(self::once())->method('isTokenValid')->with(self::callback( + static fn (CsrfToken $token): bool => 'lunetics_timezone.preference' === $token->getId() && 'default-token' === $token->getValue(), + ))->willReturn(true); + $controller = new BrowserTimezoneController($storage, $this->clock(), $dispatcher, $csrf); + $request = $this->request('{"timezone":"Europe/Berlin"}', 'application/json'); + $request->headers->set('X-CSRF-Token', 'default-token'); + + self::assertSame(204, $controller($request)->getStatusCode()); + } + + private function controller(RecordingStorage $storage, RecordingDispatcher $dispatcher): BrowserTimezoneController + { + return new BrowserTimezoneController($storage, $this->clock(), $dispatcher); + } + + private function clock(): ClockInterface + { + return new class implements ClockInterface { + public function now(): \DateTimeImmutable + { + return new \DateTimeImmutable('2026-02-03T04:05:06Z'); + } + }; + } + + private function request(string $body, ?string $contentType): Request + { + $server = null === $contentType ? [] : ['CONTENT_TYPE' => $contentType]; + + return Request::create('/_lunetics/timezone/browser', 'POST', [], [], [], $server, $body); + } + + private function preference(string $timezone, PreferenceSource $source): TimezonePreference + { + return new TimezonePreference(TimezoneId::fromString($timezone), $source, new \DateTimeImmutable('2026-01-01T00:00:00Z')); + } +} + +final class RecordingStorage implements TimezonePreferenceStorageInterface +{ + public int $reads = 0; + /** @var list */ + public array $writes = []; + + public function __construct( + private readonly ?TimezonePreferenceRead $result = null, + private readonly bool $failRead = false, + private readonly bool $failWrite = false, + ) { + } + + public function read(Request $request): TimezonePreferenceRead + { + ++$this->reads; + if ($this->failRead) { + throw TimezoneStorageException::operationFailed('read', new \RuntimeException('unavailable')); + } + + return $this->result ?? new TimezonePreferenceRead(PreferenceReadStatus::ABSENT); + } + + public function write(Request $request, Response $response, TimezonePreference $preference): void + { + if ($this->failWrite) { + throw TimezoneStorageException::operationFailed('write', new \RuntimeException('unavailable')); + } + $this->writes[] = $preference; + } + + public function clear(Request $request, Response $response): void + { + } +} + +final class RecordingDispatcher implements EventDispatcherInterface +{ + /** @var list */ + public array $events = []; + + public function dispatch(object $event, ?string $eventName = null): object + { + $this->events[] = $event; + + return $event; + } +} diff --git a/Tests/DependencyInjection/LuneticsTimezoneBundleTest.php b/Tests/DependencyInjection/LuneticsTimezoneBundleTest.php new file mode 100644 index 0000000..7141757 --- /dev/null +++ b/Tests/DependencyInjection/LuneticsTimezoneBundleTest.php @@ -0,0 +1,552 @@ +getPath()); + } + + public function testMinimalConfigurationCompilesAndPreservesBuiltInOrder(): void + { + $container = $this->container(); + $container->getCompilerPassConfig()->setRemovingPasses([]); + $container->compile(); + + $tagged = $container->findTaggedServiceIds(TimezoneCompilerPass::RESOLVER_TAG); + $priorities = []; + foreach ($tagged as $tags) { + self::assertIsArray($tags[0]); + self::assertIsInt($tags[0]['priority']); + $priorities[] = $tags[0]['priority']; + } + rsort($priorities); + self::assertSame([1000, 925, 800, 100], $priorities); + } + + public function testEmptyLocaleMappingKeyFailsDuringContainerBuild(): void + { + $container = $this->container(['resolution' => ['locale_mapping' => ['mapping' => ['' => 'UTC']]]]); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('non-empty locale keys'); + $container->compile(); + } + + public function testNormalizedLocaleMappingCollisionFailsDuringContainerBuild(): void + { + $container = $this->container(['resolution' => ['locale_mapping' => ['mapping' => ['de-DE' => 'Europe/Berlin', 'de_DE' => 'UTC']]]]); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('collides after normalization'); + $container->compile(); + } + + public function testDuplicateExplicitResolverIndicesFailCompilation(): void + { + $container = $this->container(); + $container->setDefinition('duplicate', new Definition(NullResolver::class)) + ->addTag(TimezoneCompilerPass::RESOLVER_TAG, ['index' => 'locale', 'priority' => 100]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('tag index "locale" is registered more than once'); + $container->compile(); + } + + public function testResolverTagWithoutIndexFailsCompilation(): void + { + $container = $this->container(); + $container->setDefinition('missing_index', new Definition(NullResolver::class)) + ->addTag(TimezoneCompilerPass::RESOLVER_TAG, ['priority' => 100]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Timezone resolver "missing_index" must declare a non-empty string "index"'); + $container->compile(); + } + + public function testCustomResolverWithoutPriorityAppearsInDebugCatalogAtZero(): void + { + $container = $this->container(); + $container->setDefinition('custom_without_priority', new Definition(NullResolver::class)) + ->addTag(TimezoneCompilerPass::RESOLVER_TAG, ['index' => 'custom']); + $container->compile(); + + $catalog = $container->getDefinition(DebugTimezoneCommand::class)->getArgument(1); + self::assertIsArray($catalog); + self::assertContains(['name' => 'custom', 'priority' => 0], $catalog); + } + + public function testResolverTagWithNonIntegerPriorityFailsCompilation(): void + { + $container = $this->container(); + $container->setDefinition('invalid_priority', new Definition(NullResolver::class)) + ->addTag(TimezoneCompilerPass::RESOLVER_TAG, ['index' => 'custom', 'priority' => '100']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Timezone resolver "invalid_priority" must declare an integer "priority"'); + $container->compile(); + } + + public function testCustomStorageMustImplementContract(): void + { + $container = $this->container(['persistence' => ['storage' => 'application.storage']]); + $container->setDefinition('application.storage', new Definition(\stdClass::class)); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(TimezonePreferenceStorageInterface::class); + $container->compile(); + } + + public function testCircularCustomStorageAliasFailsWithDeterministicMessage(): void + { + $container = $this->container(['persistence' => ['storage' => 'application.storage.a']]); + $container->setAlias('application.storage.a', 'application.storage.b'); + $container->setAlias('application.storage.b', 'application.storage.a'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf( + 'Alias cycle detected while resolving configured timezone preference storage service "%s": application.storage.a -> application.storage.b -> application.storage.a.', + TimezonePreferenceStorageInterface::class, + )); + $container->compile(); + } + + public function testInternalClockFallsBackWithoutDefiningGlobalClockAlias(): void + { + $container = $this->container(); + $container->compile(); + + self::assertFalse($container->hasAlias(ClockInterface::class)); + self::assertSame(SystemClock::class, (string) $container->getAlias(TimezoneCompilerPass::CLOCK_SERVICE)); + } + + public function testInternalClockPrefersApplicationClockWithoutChangingGlobalAlias(): void + { + $container = $this->container([], static function (ContainerBuilder $container): void { + $container->setDefinition('application.clock', new Definition(FixedApplicationClock::class)); + $container->setAlias(ClockInterface::class, 'application.clock'); + }); + $container->compile(); + + self::assertSame('application.clock', (string) $container->getAlias(ClockInterface::class)); + self::assertSame('application.clock', (string) $container->getAlias(TimezoneCompilerPass::CLOCK_SERVICE)); + } + + public function testUnknownIntegrationOptionIsRejected(): void + { + $container = $this->container(['integrations' => ['mesenger' => true]]); + + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('Unrecognized option "mesenger" under "lunetics_timezone.integrations"'); + $container->compile(); + } + + public function testWhitespaceMaxMindDatabaseIsRejected(): void + { + $container = $this->container(['resolution' => ['maxmind' => ['database' => " \t "]]]); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('MaxMind database must be null or a non-empty string.'); + $container->compile(); + } + + public function testWhitespaceMaxMindReaderIsRejected(): void + { + $container = $this->container(['resolution' => ['maxmind' => ['reader' => " \n "]]]); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('MaxMind reader must be null or a non-empty string.'); + $container->compile(); + } + + public function testInvalidCookieNameIsRejectedByConfiguration(): void + { + $container = $this->container(['persistence' => ['cookie' => ['name' => 'invalid cookie']]]); + + $this->expectException(\InvalidArgumentException::class); + $container->compile(); + } + + public function testBrowserCsrfFailsClearlyWhenOptionalPackageIsMissing(): void + { + $container = $this->container(['browser' => ['enabled' => true]]); + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('security.csrf.token_manager'); + $container->compile(); + } + + public function testBrowserCsrfWiresOnlyWhenTokenManagerServiceExists(): void + { + $container = $this->container(['browser' => ['enabled' => true]], static function (ContainerBuilder $container): void { + self::registerStubExtension($container, 'csrf_stub', static fn (ContainerBuilder $container) => $container->setDefinition('security.csrf.token_manager', new Definition(\stdClass::class))); + }); + $container->compile(); + + self::assertTrue($container->hasDefinition(BrowserTimezoneController::class)); + $definition = $container->getDefinition(BrowserTimezoneController::class); + self::assertTrue($definition->isPublic()); + self::assertTrue($definition->hasTag('controller.service_arguments')); + } + + public function testUserAutoRequiresActualTokenStorageServiceAndInjectsConfiguredAccessor(): void + { + $withoutSecurity = $this->container(); + $withoutSecurity->compile(); + self::assertFalse($withoutSecurity->hasDefinition(UserTimezoneResolver::class)); + + $container = $this->container([ + 'resolution' => ['user' => ['enabled' => 'auto', 'accessor' => 'application.user_timezone', 'priority' => 901]], + ], static function (ContainerBuilder $container): void { + self::registerStubExtension($container, 'security_stub', static function (ContainerBuilder $container): void { + $container->setDefinition('application.token_storage', new Definition(\stdClass::class)); + $container->setAlias('security.token_storage', 'application.token_storage'); + $container->setDefinition('application.user_timezone', new Definition(NullUserTimezoneAccessor::class)); + }); + }); + $container->compile(); + + $definition = $container->getDefinition(UserTimezoneResolver::class); + self::assertInstanceOf(Reference::class, $definition->getArgument(0)); + self::assertSame('application.token_storage', (string) $definition->getArgument(0)); + self::assertInstanceOf(Reference::class, $definition->getArgument(1)); + self::assertSame('application.user_timezone', (string) $definition->getArgument(1)); + $tags = $definition->getTag(TimezoneCompilerPass::RESOLVER_TAG); + self::assertIsArray($tags[0]); + self::assertSame(901, $tags[0]['priority']); + } + + public function testDiscoverableUserAccessorMustImplementContractThroughAlias(): void + { + $container = $this->container([ + 'resolution' => ['user' => ['enabled' => 'auto', 'accessor' => 'application.user_timezone']], + ], static function (ContainerBuilder $container): void { + self::registerStubExtension($container, 'security_stub', static function (ContainerBuilder $container): void { + $container->setDefinition('application.token_storage', new Definition(\stdClass::class)); + $container->setAlias('security.token_storage', 'application.token_storage'); + $container->setDefinition('application.invalid_accessor', new Definition(\stdClass::class)); + $container->setAlias('application.user_timezone', 'application.invalid_accessor'); + }); + }); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(UserTimezoneAccessorInterface::class); + $container->compile(); + } + + public function testExplicitUserIntegrationRequiresTokenStorageService(): void + { + $container = $this->container(['resolution' => ['user' => ['enabled' => true]]]); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('security.token_storage'); + $container->compile(); + } + + public function testOidcRequiresServiceAndWiresClaimAndPriority(): void + { + $invalid = $this->container(['resolution' => ['oidc' => ['enabled' => true]]]); + try { + $invalid->compile(); + self::fail('Expected missing OIDC service to fail.'); + } catch (\LogicException $exception) { + self::assertStringContainsString('resolution.oidc.service', $exception->getMessage()); + } + + $container = $this->container([ + 'resolution' => ['oidc' => ['enabled' => true, 'service' => 'application.oidc', 'claim' => 'tz', 'priority' => 851]], + ], static fn (ContainerBuilder $container) => self::registerStubExtension($container, 'oidc_stub', static fn (ContainerBuilder $container) => $container->setDefinition('application.oidc', new Definition(NullOidcClaimsProvider::class)))); + $container->compile(); + + $definition = $container->getDefinition(OidcTimezoneResolver::class); + self::assertInstanceOf(Reference::class, $definition->getArgument(0)); + self::assertSame('application.oidc', (string) $definition->getArgument(0)); + self::assertSame('tz', $definition->getArgument(1)); + $tags = $definition->getTag(TimezoneCompilerPass::RESOLVER_TAG); + self::assertIsArray($tags[0]); + self::assertSame(851, $tags[0]['priority']); + } + + public function testDiscoverableOidcServiceMustImplementContract(): void + { + $container = $this->container([ + 'resolution' => ['oidc' => ['enabled' => true, 'service' => 'application.oidc']], + ], static fn (ContainerBuilder $container) => self::registerStubExtension($container, 'oidc_stub', static fn (ContainerBuilder $container) => $container->setDefinition('application.oidc', new Definition(\stdClass::class)))); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(OidcClaimsProviderInterface::class); + $container->compile(); + } + + public function testMaxMindReaderModeAliasesContractAndDatabaseModeIsLazy(): void + { + $reader = $this->container([ + 'resolution' => ['maxmind' => ['enabled' => true, 'reader' => 'application.maxmind', 'priority' => 401]], + ], static fn (ContainerBuilder $container) => self::registerStubExtension($container, 'maxmind_stub', static fn (ContainerBuilder $container) => $container->setDefinition('application.maxmind', new Definition(NullMaxMindCityReader::class)))); + $reader->compile(); + self::assertSame('application.maxmind', (string) $reader->getAlias(MaxMindCityReaderInterface::class)); + $tags = $reader->getDefinition(MaxMindTimezoneResolver::class)->getTag(TimezoneCompilerPass::RESOLVER_TAG); + self::assertIsArray($tags[0]); + self::assertSame(401, $tags[0]['priority']); + + $database = $this->container(['resolution' => ['maxmind' => ['enabled' => true, 'database' => '/does/not/need/to/exist.mmdb']]]); + $database->compile(); + self::assertSame(LazyGeoIp2CityReader::class, (string) $database->getAlias(MaxMindCityReaderInterface::class)); + self::assertTrue($database->hasDefinition(MaxMindDatabaseCheckCommand::class)); + } + + public function testDiscoverableMaxMindReaderMustImplementContract(): void + { + $container = $this->container([ + 'resolution' => ['maxmind' => ['enabled' => true, 'reader' => 'application.maxmind']], + ], static function (ContainerBuilder $container): void { + self::registerStubExtension($container, 'maxmind_stub', static function (ContainerBuilder $container): void { + $container->setDefinition('application.invalid_maxmind', new Definition(\stdClass::class)); + $container->setAlias('application.maxmind', 'application.invalid_maxmind'); + }); + }); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(MaxMindCityReaderInterface::class); + $container->compile(); + } + + public function testCallableMaxMindReaderWrapperRemainsSupported(): void + { + $container = $this->container([ + 'resolution' => ['maxmind' => ['enabled' => true, 'reader' => 'application.maxmind']], + ], static fn (ContainerBuilder $container) => self::registerStubExtension($container, 'maxmind_stub', static fn (ContainerBuilder $container) => $container->setDefinition('application.maxmind', new Definition(CallableMaxMindCityReader::class, ['trim'])))); + + $container->compile(); + + self::assertSame('application.maxmind', (string) $container->getAlias(MaxMindCityReaderInterface::class)); + } + + public function testMaxMindEnabledRequiresExactlyOneSource(): void + { + $container = $this->container(['resolution' => ['maxmind' => ['enabled' => true]]]); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('exactly one'); + $container->compile(); + } + + public function testTwigAutoUsesRegisteredExtensionAndCoreExtensionFactory(): void + { + $withoutTwig = $this->container(); + $withoutTwig->compile(); + self::assertFalse($withoutTwig->hasDefinition(TwigTimezoneScope::class)); + + $container = $this->container([], static function (ContainerBuilder $container): void { + self::registerStubExtension($container, 'twig', static fn (ContainerBuilder $container) => $container->setDefinition('twig', new Definition('Twig\\Environment'))); + }); + $container->compile(); + + $core = $container->getDefinition('lunetics_timezone.twig.core_extension'); + $factory = $core->getFactory(); + self::assertIsArray($factory); + self::assertSame('getExtension', $factory[1]); + self::assertSame('Twig\\Extension\\CoreExtension', $core->getArgument(0)); + self::assertTrue($container->getDefinition(TwigTimezoneScope::class)->hasTag('kernel.reset')); + self::assertTrue($container->hasDefinition(TwigTimezoneSubscriber::class)); + } + + public function testExplicitTwigRequiresExtensionAndService(): void + { + $container = $this->container(['integrations' => ['twig' => true]]); + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Twig container extension'); + $container->compile(); + } + + public function testFormAndMessengerDefinitionsAreOptIn(): void + { + $container = $this->container(['integrations' => ['form' => true, 'messenger' => true]]); + $container->compile(); + + self::assertTrue($container->getDefinition(TimezoneTypeExtension::class)->hasTag('form.type_extension')); + self::assertSame(DispatchTimezoneMiddleware::class, (string) $container->getAlias('lunetics_timezone.messenger.dispatch_middleware')); + self::assertTrue($container->getAlias('lunetics_timezone.messenger.dispatch_middleware')->isPublic()); + self::assertSame(WorkerTimezoneMiddleware::class, (string) $container->getAlias('lunetics_timezone.messenger.worker_middleware')); + self::assertFalse($container->hasDefinition('messenger.bus.default')); + } + + public function testProfilerAutoRequiresExtensionAndDebugAndUsesResolverCatalog(): void + { + $container = $this->container([ + 'resolution' => ['header' => ['enabled' => true, 'priority' => 925]], + ], static fn (ContainerBuilder $container) => self::registerStubExtension($container, 'web_profiler')); + $container->compile(); + + $definition = $container->getDefinition(TimezoneDataCollector::class); + self::assertSame([ + ['name' => 'request_attribute', 'priority' => 1000], + ['name' => 'header', 'priority' => 925], + ['name' => 'stored_manual', 'priority' => 925], + ['name' => 'stored_browser', 'priority' => 800], + ['name' => 'locale', 'priority' => 100], + ], $definition->getArgument(1)); + $tags = $definition->getTag('data_collector'); + self::assertIsArray($tags[0]); + self::assertSame('@LuneticsTimezone/Collector/timezone.html.twig', $tags[0]['template']); + self::assertSame('lunetics_timezone', $tags[0]['id']); + + $notDebug = $this->container([], static function (ContainerBuilder $container): void { + $container->setParameter('kernel.debug', false); + self::registerStubExtension($container, 'web_profiler'); + }); + $notDebug->compile(); + self::assertFalse($notDebug->hasDefinition(TimezoneDataCollector::class)); + } + + public function testExplicitProfilerRequiresWebProfilerExtension(): void + { + $container = $this->container(['integrations' => ['profiler' => true]]); + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('web-profiler-bundle'); + $container->compile(); + } + + public function testDebugCommandIsRegisteredWithDeterministicCatalog(): void + { + $container = $this->container(['resolution' => ['oidc' => ['enabled' => true, 'service' => 'application.oidc', 'priority' => 925]]], static fn (ContainerBuilder $container) => self::registerStubExtension($container, 'oidc_stub', static fn (ContainerBuilder $container) => $container->setDefinition('application.oidc', new Definition(NullOidcClaimsProvider::class)))); + $container->compile(); + + self::assertSame([ + ['name' => 'request_attribute', 'priority' => 1000], + ['name' => 'stored_manual', 'priority' => 925], + ['name' => 'oidc', 'priority' => 925], + ['name' => 'stored_browser', 'priority' => 800], + ['name' => 'locale', 'priority' => 100], + ], $container->getDefinition(DebugTimezoneCommand::class)->getArgument(1)); + } + + /** @param array $config */ + private function container(array $config = [], ?callable $configure = null): ContainerBuilder + { + $container = new ContainerBuilder(); + $container->setParameter('kernel.environment', 'test'); + $container->setParameter('kernel.debug', true); + $container->setParameter('kernel.build_dir', sys_get_temp_dir()); + $container->setParameter('kernel.cache_dir', sys_get_temp_dir()); + $container->setParameter('kernel.project_dir', dirname(__DIR__, 2)); + $container->setDefinition(RequestStack::class, new Definition(RequestStack::class)); + $container->setDefinition('event_dispatcher', new Definition(EventDispatcher::class)); + $container->setAlias(EventDispatcherInterface::class, 'event_dispatcher'); + if (null !== $configure) { + $configure($container); + } + $bundle = new LuneticsTimezoneBundle(); + $extension = $bundle->getContainerExtension(); + self::assertNotNull($extension); + $container->registerExtension($extension); + $container->loadFromExtension($extension->getAlias(), $config); + $bundle->build($container); + $container->getCompilerPassConfig()->setRemovingPasses([]); + + return $container; + } + + private static function registerStubExtension(ContainerBuilder $container, string $alias, ?callable $load = null): void + { + $container->registerExtension(new StubExtension($alias, $load)); + $container->loadFromExtension($alias); + } +} + +final class StubExtension extends Extension +{ + private readonly ?\Closure $loader; + + public function __construct(private readonly string $stubAlias, ?callable $loader = null) + { + $this->loader = null === $loader ? null : $loader(...); + } + + public function getAlias(): string + { + return $this->stubAlias; + } + + public function load(array $configs, ContainerBuilder $container): void + { + ($this->loader ?? static function (): void {})($container); + } +} + +final class NullOidcClaimsProvider implements OidcClaimsProviderInterface +{ + public function claimsForRequest(Request $request): array + { + return []; + } +} + +final class NullUserTimezoneAccessor implements UserTimezoneAccessorInterface +{ + public function getTimezoneForUser(object $user): null + { + return null; + } +} + +final class NullMaxMindCityReader implements MaxMindCityReaderInterface +{ + public function timezoneForIp(string $ipAddress): null + { + return null; + } +} + +final class NullResolver implements TimezoneResolverInterface +{ + public function resolve(Request $request): null + { + return null; + } +} + +final class FixedApplicationClock implements ClockInterface +{ + public function now(): \DateTimeImmutable + { + return new \DateTimeImmutable('2026-01-01T00:00:00Z'); + } +} diff --git a/Tests/DependencyInjection/LuneticsTimezoneExtensionTest.php b/Tests/DependencyInjection/LuneticsTimezoneExtensionTest.php deleted file mode 100644 index 8503244..0000000 --- a/Tests/DependencyInjection/LuneticsTimezoneExtensionTest.php +++ /dev/null @@ -1,111 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that is distributed with this source code. - */ - -namespace Lunetics\TimezoneBundle\Tests\DependencyInjection; - -use Symfony\Component\DependencyInjection\ContainerBuilder; -use Lunetics\TimezoneBundle\DependencyInjection\LuneticsTimezoneExtension; -use Symfony\Component\Yaml\Parser; - -class LuneticsTimezoneExtensionTest extends \PHPUnit_Framework_TestCase -{ - protected $configuration; - - /** - * @expectedException \Symfony\Component\Config\Definition\Exception\InvalidConfigurationException - */ - public function testBundleLoadThrowsExceptionUnlessDetectorsOrderIsSet() - { - $loader = new LuneticsTimezoneExtension(); - $config = $this->getEmptyConfig(); - unset($config['guesser']['order']); - $loader->load(array($config), new ContainerBuilder()); - } - - /** - * @return ContainerBuilder - */ - protected function createEmptyConfiguration() - { - $this->configuration = new ContainerBuilder(); - $loader = new LuneticsTimezoneExtension; - $config = $this->getEmptyConfig(); - $loader->load(array($config), $this->configuration); - $this->assertTrue($this->configuration instanceof ContainerBuilder); - } - - /** - * @return ContainerBuilder - */ - protected function createFullConfiguration() - { - $this->configuration = new ContainerBuilder(); - $loader = new LuneticsTimezoneExtension; - $config = $this->getFullConfig(); - $loader->load(array($config), $this->configuration); - $this->assertTrue($this->configuration instanceof ContainerBuilder); - } - - /** - * getEmptyConfig - * - * @return array - */ - protected function getEmptyConfig() - { - $yaml = <<parse($yaml); - } - - protected function getFullConfig() - { - $yaml = <<parse($yaml); - } - - private function assertAlias($value, $key) - { - $this->assertEquals($value, (string) $this->configuration->getAlias($key), sprintf('%s alias is correct', $key)); - } - - private function assertParameter($value, $key) - { - $this->assertEquals($value, $this->configuration->getParameter($key), sprintf('%s parameter is correct', $key)); - } - - private function assertHasDefinition($id) - { - $this->assertTrue(($this->configuration->hasDefinition($id) ?: $this->configuration->hasAlias($id))); - } - - private function assertNotHasDefinition($id) - { - $this->assertFalse(($this->configuration->hasDefinition($id) ?: $this->configuration->hasAlias($id))); - } - - protected function tearDown() - { - unset($this->configuration); - } -} diff --git a/Tests/Distribution/ExportPolicyTest.php b/Tests/Distribution/ExportPolicyTest.php new file mode 100644 index 0000000..8ec75a6 --- /dev/null +++ b/Tests/Distribution/ExportPolicyTest.php @@ -0,0 +1,49 @@ + '' !== trim($line) && !str_starts_with(ltrim($line), '#'), + )); + + foreach ([ + '/Resources/doc/scope.md export-ignore', + '/Resources/doc/v2-implementation-plan.md export-ignore', + '/LICENSE export-ignore', + ] as $forbiddenRule) { + self::assertNotContains($forbiddenRule, $rules, sprintf('%s must be included in distribution archives.', strtok($forbiddenRule, ' '))); + } + + foreach ([ + '/.github export-ignore', + '/.github/** export-ignore', + '/.firecrawl export-ignore', + '/.firecrawl/** export-ignore', + '/.claude export-ignore', + '/.claude/** export-ignore', + '/Tests export-ignore', + '/Tests/** export-ignore', + '/.gitattributes export-ignore', + '/.gitignore export-ignore', + '/.travis.yml export-ignore', + '/phpunit.xml.dist export-ignore', + '/phpstan.neon.dist export-ignore', + '/package.json export-ignore', + ] as $requiredRule) { + self::assertContains($requiredRule, $rules, sprintf('Missing distribution exclusion: %s.', $requiredRule)); + } + } +} diff --git a/Tests/EventListener/PreferenceDiagnosticsFlagsTest.php b/Tests/EventListener/PreferenceDiagnosticsFlagsTest.php new file mode 100644 index 0000000..741e399 --- /dev/null +++ b/Tests/EventListener/PreferenceDiagnosticsFlagsTest.php @@ -0,0 +1,254 @@ +fixedClock(); + $storage = new SessionTimezoneStorage(); + $session = new Session(new MockArraySessionStorage()); + $session->start(); + $session->set('_lunetics_timezone', ['v' => 1]); + $request = $this->browserRequest(); + $request->setSession($session); + $events = []; + $dispatcher = $this->recordingDispatcher($events); + + (new StoredPreferenceTimezoneResolver($storage))->resolve($request); + $cachedRead = $request->attributes->get(StoredPreferenceTimezoneResolver::READ_ATTRIBUTE); + self::assertInstanceOf(TimezonePreferenceRead::class, $cachedRead); + self::assertSame(PreferenceReadStatus::INVALID, $cachedRead->status); + $response = (new BrowserTimezoneController($storage, $clock, $dispatcher))($request); + (new InvalidPreferenceCleanupListener($storage, PersistenceFailureStrategy::CONTINUE))->onKernelResponse( + new ResponseEvent($this->createStub(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST, $response), + ); + + $this->assertSuccessfulReplacement($request, $response, $events); + $read = $storage->read($request); + self::assertSame(PreferenceReadStatus::VALID, $read->status); + self::assertSame('Europe/Berlin', $read->preference?->timezone->value()); + } + + public function testFreshCookiePreferenceIsNotClearedBecauseResolverCachedExpiredPreference(): void + { + $clock = $this->fixedClock(); + $storage = new CookieTimezoneStorage('test-only-secret', $clock, maxAge: 3600); + $expiredResponse = new Response(); + $storage->write( + Request::create('https://example.test'), + $expiredResponse, + new TimezonePreference(TimezoneId::fromString('America/New_York'), PreferenceSource::BROWSER, new \DateTimeImmutable('2025-12-31T22:59:59Z')), + ); + $expiredCookie = $expiredResponse->headers->getCookies()[0]; + $request = $this->browserRequest('https://example.test/_lunetics/timezone/browser'); + $request->cookies->set($expiredCookie->getName(), $expiredCookie->getValue()); + $events = []; + $dispatcher = $this->recordingDispatcher($events); + + (new StoredPreferenceTimezoneResolver($storage))->resolve($request); + $cachedRead = $request->attributes->get(StoredPreferenceTimezoneResolver::READ_ATTRIBUTE); + self::assertInstanceOf(TimezonePreferenceRead::class, $cachedRead); + self::assertSame(PreferenceReadStatus::EXPIRED, $cachedRead->status); + $response = (new BrowserTimezoneController($storage, $clock, $dispatcher))($request); + (new InvalidPreferenceCleanupListener($storage, PersistenceFailureStrategy::CONTINUE))->onKernelResponse( + new ResponseEvent($this->createStub(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST, $response), + ); + + $this->assertSuccessfulReplacement($request, $response, $events); + $cookies = $response->headers->getCookies(); + self::assertCount(1, $cookies); + $freshCookie = $cookies[0]; + self::assertInstanceOf(Cookie::class, $freshCookie); + self::assertNotSame('', $freshCookie->getValue()); + self::assertGreaterThan($clock->now()->getTimestamp(), $freshCookie->getExpiresTime()); + $followUp = Request::create('https://example.test'); + $followUp->cookies->set($freshCookie->getName(), $freshCookie->getValue()); + $read = $storage->read($followUp); + self::assertSame(PreferenceReadStatus::VALID, $read->status); + self::assertSame('Europe/Berlin', $read->preference?->timezone->value()); + } + + #[DataProvider('writeOutcomes')] + public function testWrittenFlagRequiresAnActualSuccessfulWrite(bool $fail, bool $expectedFlag): void + { + $storage = $this->storage($fail, false); + $clock = $this->createStub(ClockInterface::class); + $clock->method('now')->willReturn(new \DateTimeImmutable('2026-01-01T00:00:00Z')); + $dispatcher = $this->createStub(EventDispatcherInterface::class); + $controller = new BrowserTimezoneController($storage, $clock, $dispatcher); + $request = Request::create('/', 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], '{"timezone":"Europe/Berlin"}'); + + $controller($request); + + self::assertSame($expectedFlag, true === $request->attributes->get(BrowserTimezoneController::PREFERENCE_WRITTEN_ATTRIBUTE)); + } + + /** @return iterable */ + public static function writeOutcomes(): iterable + { + yield 'successful' => [false, true]; + yield 'failed' => [true, false]; + } + + #[DataProvider('clearOutcomes')] + public function testClearedFlagRequiresAnActualSuccessfulCleanup(bool $fail, bool $expectedFlag): void + { + $request = Request::create('/'); + $request->attributes->set(StoredPreferenceTimezoneResolver::READ_ATTRIBUTE, new TimezonePreferenceRead(PreferenceReadStatus::INVALID)); + $listener = new InvalidPreferenceCleanupListener($this->storage(false, $fail), PersistenceFailureStrategy::CONTINUE); + $event = new ResponseEvent($this->createStub(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST, new Response()); + + $listener->onKernelResponse($event); + + self::assertSame($expectedFlag, true === $request->attributes->get(InvalidPreferenceCleanupListener::PREFERENCE_CLEARED_ATTRIBUTE)); + } + + /** @return iterable */ + public static function clearOutcomes(): iterable + { + yield 'successful' => [false, true]; + yield 'failed' => [true, false]; + } + + public function testSubRequestDoesNotInspectOrCleanCopiedInvalidPreference(): void + { + $request = Request::create('/'); + $request->attributes->set(StoredPreferenceTimezoneResolver::READ_ATTRIBUTE, new TimezonePreferenceRead(PreferenceReadStatus::INVALID)); + $storage = new RecordingCleanupStorage(); + $listener = new InvalidPreferenceCleanupListener($storage, PersistenceFailureStrategy::CONTINUE); + $event = new ResponseEvent($this->createStub(HttpKernelInterface::class), $request, HttpKernelInterface::SUB_REQUEST, new Response()); + + $listener->onKernelResponse($event); + + self::assertSame(0, $storage->clearCalls); + self::assertFalse($request->attributes->has(InvalidPreferenceCleanupListener::PREFERENCE_CLEARED_ATTRIBUTE)); + } + + public function testThrowStrategyPropagatesTypedClearFailureOnMainRequest(): void + { + $request = Request::create('/'); + $request->attributes->set(StoredPreferenceTimezoneResolver::READ_ATTRIBUTE, new TimezonePreferenceRead(PreferenceReadStatus::INVALID)); + $listener = new InvalidPreferenceCleanupListener($this->storage(false, true), PersistenceFailureStrategy::THROW); + $event = new ResponseEvent($this->createStub(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST, new Response()); + + $this->expectException(TimezoneStorageException::class); + $listener->onKernelResponse($event); + } + + private function fixedClock(): ClockInterface + { + return new class implements ClockInterface { + public function now(): \DateTimeImmutable + { + return new \DateTimeImmutable('2026-01-01T00:00:00Z'); + } + }; + } + + private function browserRequest(string $uri = '/_lunetics/timezone/browser'): Request + { + return Request::create($uri, 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], '{"timezone":"Europe/Berlin"}'); + } + + /** @param list $events */ + private function recordingDispatcher(array &$events): EventDispatcher + { + $dispatcher = new EventDispatcher(); + $dispatcher->addListener(TimezonePreferenceChangedEvent::class, static function (TimezonePreferenceChangedEvent $event) use (&$events): void { + $events[] = $event; + }); + + return $dispatcher; + } + + /** @param list $events */ + private function assertSuccessfulReplacement(Request $request, Response $response, array $events): void + { + self::assertSame(204, $response->getStatusCode()); + self::assertCount(1, $events); + self::assertNull($events[0]->previous); + self::assertNotNull($events[0]->current); + self::assertSame('Europe/Berlin', $events[0]->current->timezone->value()); + self::assertSame(PreferenceSource::BROWSER, $events[0]->current->source); + self::assertTrue($request->attributes->getBoolean(BrowserTimezoneController::PREFERENCE_WRITTEN_ATTRIBUTE)); + self::assertFalse($request->attributes->has(InvalidPreferenceCleanupListener::PREFERENCE_CLEARED_ATTRIBUTE)); + } + + private function storage(bool $failWrite, bool $failClear): TimezonePreferenceStorageInterface + { + return new class($failWrite, $failClear) implements TimezonePreferenceStorageInterface { + public function __construct(private readonly bool $failWrite, private readonly bool $failClear) + { + } + + public function read(Request $request): TimezonePreferenceRead + { + return new TimezonePreferenceRead(PreferenceReadStatus::ABSENT); + } + + public function write(Request $request, Response $response, TimezonePreference $preference): void + { + if ($this->failWrite) { + throw TimezoneStorageException::operationFailed('write', new \RuntimeException()); + } + } + + public function clear(Request $request, Response $response): void + { + if ($this->failClear) { + throw TimezoneStorageException::operationFailed('clear', new \RuntimeException()); + } + } + }; + } +} + +final class RecordingCleanupStorage implements TimezonePreferenceStorageInterface +{ + public int $clearCalls = 0; + + public function read(Request $request): TimezonePreferenceRead + { + return new TimezonePreferenceRead(PreferenceReadStatus::ABSENT); + } + + public function write(Request $request, Response $response, TimezonePreference $preference): void + { + } + + public function clear(Request $request, Response $response): void + { + ++$this->clearCalls; + } +} diff --git a/Tests/EventListener/ResolveTimezoneListenerV2Test.php b/Tests/EventListener/ResolveTimezoneListenerV2Test.php new file mode 100644 index 0000000..d617b5c --- /dev/null +++ b/Tests/EventListener/ResolveTimezoneListenerV2Test.php @@ -0,0 +1,95 @@ + ['onKernelRequest', 1]], ResolveTimezoneListener::getSubscribedEvents()); + } + + public function testMainRequestResolvesAttachesDiagnosticsAndDispatchesOnce(): void + { + [$listener, $resolver, $dispatcher] = $this->listener(); + $request = Request::create('/'); + + $listener->onKernelRequest(new RequestEvent($this->createStub(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST)); + + self::assertSame(1, $resolver->calls); + $resolution = $request->attributes->get(CurrentTimezoneProvider::RESOLUTION_ATTRIBUTE); + self::assertInstanceOf(TimezoneResolution::class, $resolution); + self::assertSame('Europe/Berlin', $resolution->timezone->value()); + self::assertInstanceOf(TimezoneResolutionTrace::class, TimezoneResolutionTrace::fromRequest($request)); + self::assertCount(1, $dispatcher->events); + self::assertInstanceOf(TimezoneResolvedEvent::class, $dispatcher->events[0]); + self::assertSame($resolution, $dispatcher->events[0]->resolution); + } + + public function testSubRequestDoesNotResolveAttachDiagnosticsOrDispatch(): void + { + [$listener, $resolver, $dispatcher] = $this->listener(); + $request = Request::create('/'); + + $listener->onKernelRequest(new RequestEvent($this->createStub(HttpKernelInterface::class), $request, HttpKernelInterface::SUB_REQUEST)); + + self::assertSame(0, $resolver->calls); + self::assertFalse($request->attributes->has(CurrentTimezoneProvider::RESOLUTION_ATTRIBUTE)); + self::assertNull(TimezoneResolutionTrace::fromRequest($request)); + self::assertSame([], $dispatcher->events); + } + + /** @return array{ResolveTimezoneListener, RecordingTimezoneResolver, RecordingEventDispatcher} */ + private function listener(): array + { + $resolver = new RecordingTimezoneResolver(); + $chain = new TimezoneResolverChain(['recording' => $resolver], TimezoneId::fromString('UTC'), ResolutionFailureStrategy::CONTINUE); + $dispatcher = new RecordingEventDispatcher(); + + return [new ResolveTimezoneListener($chain, $dispatcher), $resolver, $dispatcher]; + } +} + +final class RecordingTimezoneResolver implements TimezoneResolverInterface +{ + public int $calls = 0; + + public function resolve(Request $request): TimezoneResolution + { + ++$this->calls; + + return new TimezoneResolution(TimezoneId::fromString('Europe/Berlin'), 'recording', ResolutionKind::EXPLICIT); + } +} + +final class RecordingEventDispatcher implements EventDispatcherInterface +{ + /** @var list */ + public array $events = []; + + public function dispatch(object $event, ?string $eventName = null): object + { + $this->events[] = $event; + + return $event; + } +} diff --git a/Tests/Integration/BundleKernelSmokeTest.php b/Tests/Integration/BundleKernelSmokeTest.php new file mode 100644 index 0000000..8ca9474 --- /dev/null +++ b/Tests/Integration/BundleKernelSmokeTest.php @@ -0,0 +1,279 @@ +kernel?->shutdown(); + restore_exception_handler(); + if (null !== $this->runtimeDirectory && is_dir($this->runtimeDirectory)) { + $this->removeDirectory($this->runtimeDirectory); + } + $this->kernel = null; + $this->runtimeDirectory = null; + } + + public function testBundleWorksInARealDebugKernel(): void + { + $this->runtimeDirectory = sys_get_temp_dir().'/lunetics_timezone_kernel_'.bin2hex(random_bytes(8)); + $this->kernel = new BundleSmokeKernel('test', true, dirname(__DIR__, 2), $this->runtimeDirectory); + $this->kernel->boot(); + $container = $this->kernel->getContainer(); + + $provider = $container->get(CurrentTimezoneProviderInterface::class); + self::assertInstanceOf(CurrentTimezoneProviderInterface::class, $provider); + self::assertSame('UTC', (string) $provider->getTimezone()); + self::assertSame('default', $provider->getResolution()->source); + + $request = new Request(); + $resolverChain = $container->get('test.timezone_resolver_chain'); + self::assertInstanceOf(TimezoneResolverChain::class, $resolverChain); + $resolverChain->resolve($request); + $trace = TimezoneResolutionTrace::fromRequest($request); + self::assertNotNull($trace); + self::assertSame([ + 'request_attribute', + 'stored_manual', + 'user', + 'stored_browser', + 'locale', + 'configured_default', + ], array_column($trace->attempts, 'resolver')); + + self::assertInstanceOf(DispatchTimezoneMiddleware::class, $container->get('lunetics_timezone.messenger.dispatch_middleware')); + self::assertInstanceOf(WorkerTimezoneMiddleware::class, $container->get('lunetics_timezone.messenger.worker_middleware')); + + $clock = $container->get('test.clock'); + self::assertNotInstanceOf(SystemClock::class, $clock); + self::assertInstanceOf(Clock::class, $clock); + self::assertSame($clock, $container->get('test.timezone_clock')); + + $router = $container->get('router'); + self::assertInstanceOf(RouterInterface::class, $router); + $router->getContext()->setMethod('POST'); + $route = $router->match('/_lunetics/timezone/browser'); + self::assertSame('lunetics_timezone_browser', $route['_route']); + + $browserRequest = Request::create( + '/_lunetics/timezone/browser', + 'POST', + [], + [], + [], + ['CONTENT_TYPE' => 'application/json'], + '{"timezone":"Europe/Berlin"}', + ); + $browserResponse = $this->kernel->handle($browserRequest); + self::assertSame(403, $browserResponse->getStatusCode()); + $this->kernel->terminate($browserRequest, $browserResponse); + + $session = new Session(new MockArraySessionStorage()); + $csrfRequest = new Request(); + $csrfRequest->setSession($session); + $requestStack = $container->get('test.request_stack'); + self::assertInstanceOf(RequestStack::class, $requestStack); + $requestStack->push($csrfRequest); + $csrfTokenManager = $container->get('test.csrf_token_manager'); + self::assertInstanceOf(CsrfTokenManagerInterface::class, $csrfTokenManager); + $csrfToken = $csrfTokenManager->getToken('lunetics_timezone.preference')->getValue(); + self::assertSame($csrfRequest, $requestStack->pop()); + + $browserRequest = Request::create( + '/_lunetics/timezone/browser', + 'POST', + [], + [], + [], + ['CONTENT_TYPE' => 'application/json', 'HTTP_X_CSRF_TOKEN' => $csrfToken], + '{"timezone":"Europe/Berlin"}', + ); + $browserRequest->setSession($session); + $browserResponse = $this->kernel->handle($browserRequest); + self::assertSame(204, $browserResponse->getStatusCode()); + $this->kernel->terminate($browserRequest, $browserResponse); + + $profiler = $container->get('profiler'); + self::assertInstanceOf(Profiler::class, $profiler); + self::assertTrue($profiler->has('lunetics_timezone')); + $debugToken = $browserResponse->headers->get('X-Debug-Token'); + self::assertNotNull($debugToken); + $profile = $profiler->loadProfile($debugToken); + self::assertNotNull($profile); + $collector = $profile->getCollector('lunetics_timezone'); + self::assertInstanceOf(TimezoneDataCollector::class, $collector); + $diagnostics = $collector->getDiagnostics(); + self::assertSame('UTC', $diagnostics['effective_timezone']); + self::assertSame('default', $diagnostics['effective_source']); + self::assertTrue($diagnostics['preference_written']); + + $twig = $container->get('test.twig'); + self::assertInstanceOf(Environment::class, $twig); + $template = $twig->load('@LuneticsTimezone/Collector/timezone.html.twig'); + self::assertNotSame('', $template->getSourceContext()->getCode()); + + $assetMapper = $container->get('test.asset_mapper'); + self::assertInstanceOf(AssetMapperInterface::class, $assetMapper); + self::assertNotNull($assetMapper->getAsset('bundles/luneticstimezone/timezone.js')); + } + + public function testBrowserPersistenceFailureWithoutFrameworkSessionsReturnsServiceUnavailable(): void + { + $this->runtimeDirectory = sys_get_temp_dir().'/lunetics_timezone_kernel_'.bin2hex(random_bytes(8)); + $this->kernel = new BundleSmokeKernel('test', true, dirname(__DIR__, 2), $this->runtimeDirectory, false, false); + $this->kernel->boot(); + + $browserRequest = Request::create( + '/_lunetics/timezone/browser', + 'POST', + [], + [], + [], + ['CONTENT_TYPE' => 'application/json'], + '{"timezone":"Europe/Berlin"}', + ); + $browserResponse = $this->kernel->handle($browserRequest); + self::assertSame(503, $browserResponse->getStatusCode()); + $this->kernel->terminate($browserRequest, $browserResponse); + } + + private function removeDirectory(string $directory): void + { + $items = scandir($directory); + if (false === $items) { + return; + } + foreach ($items as $item) { + if ('.' === $item || '..' === $item) { + continue; + } + $path = $directory.'/'.$item; + if (is_dir($path) && !is_link($path)) { + $this->removeDirectory($path); + } else { + unlink($path); + } + } + rmdir($directory); + } +} + +final class BundleSmokeKernel extends Kernel +{ + use MicroKernelTrait; + + public function __construct( + string $environment, + bool $debug, + private readonly string $projectDirectory, + private readonly string $runtimeDirectory, + private readonly bool $frameworkSessionsEnabled = true, + private readonly bool $browserCsrfEnabled = true, + ) { + parent::__construct($environment, $debug); + } + + public function getProjectDir(): string + { + return $this->projectDirectory; + } + + public function getCacheDir(): string + { + return $this->runtimeDirectory.'/cache'; + } + + public function getLogDir(): string + { + return $this->runtimeDirectory.'/log'; + } + + public function registerBundles(): iterable + { + yield new FrameworkBundle(); + yield new SecurityBundle(); + yield new TwigBundle(); + yield new WebProfilerBundle(); + yield new LuneticsTimezoneBundle(); + } + + protected function configureContainer(ContainerConfigurator $container, LoaderInterface $loader, ContainerBuilder $builder): void + { + $frameworkConfig = [ + 'secret' => 'bundle-smoke-test-secret', + 'test' => true, + 'csrf_protection' => $this->browserCsrfEnabled, + 'form' => true, + 'router' => ['utf8' => true], + 'profiler' => ['enabled' => true, 'collect' => true], + 'asset_mapper' => ['enabled' => true], + ]; + if ($this->frameworkSessionsEnabled) { + $frameworkConfig['session'] = ['storage_factory_id' => 'session.storage.factory.mock_file']; + } + $container->extension('framework', $frameworkConfig); + $container->extension('security', [ + 'providers' => ['users' => ['memory' => null]], + 'firewalls' => ['main' => ['lazy' => true, 'provider' => 'users']], + ]); + $container->extension('twig', ['default_path' => $this->projectDirectory.'/Resources/views']); + $container->extension('web_profiler', ['toolbar' => false, 'intercept_redirects' => false]); + $container->extension('lunetics_timezone', [ + 'browser' => ['enabled' => true, 'csrf' => ['enabled' => $this->browserCsrfEnabled]], + 'integrations' => ['twig' => true, 'form' => true, 'messenger' => true, 'profiler' => true], + ]); + $services = $container->services(); + $services->alias('test.twig', 'twig')->public(); + $services->alias('test.asset_mapper', 'asset_mapper')->public(); + $services->alias('test.timezone_resolver_chain', TimezoneResolverChain::class)->public(); + $services->alias('test.clock', ClockInterface::class)->public(); + $services->alias('test.timezone_clock', TimezoneCompilerPass::CLOCK_SERVICE)->public(); + $services->alias('test.request_stack', 'request_stack')->public(); + if ($this->browserCsrfEnabled) { + $services->alias('test.csrf_token_manager', 'security.csrf.token_manager')->public(); + } + } + + protected function configureRoutes(RoutingConfigurator $routes): void + { + $routes->import($this->projectDirectory.'/Resources/config/routes.php'); + } +} diff --git a/Tests/Resolution/TimezoneResolverChainTest.php b/Tests/Resolution/TimezoneResolverChainTest.php new file mode 100644 index 0000000..e91b3dc --- /dev/null +++ b/Tests/Resolution/TimezoneResolverChainTest.php @@ -0,0 +1,259 @@ +chain([ + 'first' => $this->resolver(static fn (): ?TimezoneResolution => null), + 'second' => $this->resolver(static fn (): TimezoneResolution => self::resolution('Europe/Berlin', 'second')), + 'not_called' => $this->resolver(static function (): never { + throw new \LogicException('Must not be called.'); + }), + ]); + + $resolution = $chain->resolve($request); + $trace = TimezoneResolutionTrace::fromRequest($request); + + self::assertSame('Europe/Berlin', $resolution->timezone->value()); + self::assertNotNull($trace); + self::assertSame($resolution, $trace->selected); + self::assertSame(['first', 'second'], array_column($trace->attempts, 'resolver')); + self::assertSame( + [ResolutionAttemptOutcome::NO_RESULT, ResolutionAttemptOutcome::RESOLVED], + array_column($trace->attempts, 'outcome'), + ); + self::assertSame([null, 'second'], array_column($trace->attempts, 'source')); + foreach ($trace->attempts as $attempt) { + self::assertGreaterThanOrEqual(0, $attempt->durationMicroseconds); + } + } + + public function testUsesASyntheticDefaultWithoutAResolverPriority(): void + { + $request = Request::create('/'); + $resolution = $this->chain([ + 'empty' => $this->resolver(static fn (): ?TimezoneResolution => null), + ])->resolve($request); + $trace = TimezoneResolutionTrace::fromRequest($request); + + self::assertSame(ResolutionKind::DEFAULT, $resolution->kind); + self::assertSame('UTC', $resolution->timezone->value()); + self::assertNotNull($trace); + self::assertSame('configured_default', $trace->attempts[1]->resolver); + self::assertSame(ResolutionAttemptOutcome::DEFAULTED, $trace->attempts[1]->outcome); + self::assertSame(0, $trace->attempts[1]->durationMicroseconds); + } + + public function testContinueSkipsOnlyDeclaredDomainAndResolverFailures(): void + { + $request = Request::create('/'); + $chain = $this->chain([ + 'invalid' => $this->resolver(static function (): never { + throw InvalidTimezoneException::invalidIdentifier(); + }), + 'failed' => $this->resolver(static function (): never { + throw new TimezoneResolverException('Adapter failed without raw input.'); + }), + 'valid' => $this->resolver(static fn (): TimezoneResolution => self::resolution('UTC', 'valid')), + ]); + + self::assertSame('valid', $chain->resolve($request)->source); + $trace = TimezoneResolutionTrace::fromRequest($request); + + self::assertNotNull($trace); + self::assertSame( + [ResolutionAttemptOutcome::INVALID, ResolutionAttemptOutcome::FAILED, ResolutionAttemptOutcome::RESOLVED], + array_column($trace->attempts, 'outcome'), + ); + } + + public function testThrowPolicyRethrowsDeclaredFailureAndAttachesPartialTrace(): void + { + $request = Request::create('/'); + $failure = new TimezoneResolverException('Adapter failed.'); + $chain = $this->chain([ + 'failed' => $this->resolver(static function () use ($failure): never { + throw $failure; + }), + ], ResolutionFailureStrategy::THROW); + + try { + $chain->resolve($request); + self::fail('Expected the declared resolver failure to be rethrown.'); + } catch (TimezoneResolverException $actual) { + self::assertSame($failure, $actual); + } + + $trace = TimezoneResolutionTrace::fromRequest($request); + self::assertNotNull($trace); + self::assertNull($trace->selected); + self::assertSame(ResolutionAttemptOutcome::FAILED, $trace->attempts[0]->outcome); + self::assertGreaterThanOrEqual(0, $trace->attempts[0]->durationMicroseconds); + } + + public function testAttemptRejectsANegativeDuration(): void + { + $this->expectException(\InvalidArgumentException::class); + new TimezoneResolutionAttempt('resolver', ResolutionAttemptOutcome::NO_RESULT, null, -1); + } + + public function testProgrammerErrorsAlwaysBubbleEvenInContinueMode(): void + { + $chain = $this->chain([ + 'broken' => $this->resolver(static function (): never { + throw new \LogicException('Programming error.'); + }), + ]); + + $this->expectException(\LogicException::class); + $chain->resolve(Request::create('/')); + } + + public function testLogsBoundedStructuredOutcomesWithoutExceptionDetails(): void + { + $logger = new RecordingLogger(); + $secret = 'secret previous-exception message'; + $chain = $this->chain([ + 'empty' => $this->resolver(static fn (): ?TimezoneResolution => null), + 'invalid' => $this->resolver(static function (): never { + throw InvalidTimezoneException::invalidIdentifier(); + }), + 'failed' => $this->resolver(static function () use ($secret): never { + throw TimezoneResolverException::userAccessorFailed(new \RuntimeException($secret)); + }), + 'selected' => $this->resolver(static fn (): TimezoneResolution => self::resolution('Europe/Berlin', 'selected')), + ], logger: $logger); + + $chain->resolve(Request::create('/')); + + self::assertSame( + [LogLevel::DEBUG, LogLevel::WARNING, LogLevel::ERROR, LogLevel::INFO], + array_column($logger->records, 'level'), + ); + self::assertSame( + ['no_result', 'invalid', 'failed', 'resolved'], + array_column(array_column($logger->records, 'context'), 'outcome'), + ); + self::assertSame( + ['resolver', 'outcome', 'duration_microseconds'], + array_keys($logger->records[2]['context']), + ); + self::assertStringNotContainsString($secret, serialize($logger->records)); + } + + public function testLogsConfiguredDefaultAndLogsThrowFailureBeforeRethrow(): void + { + $defaultLogger = new RecordingLogger(); + $this->chain([], logger: $defaultLogger)->resolve(Request::create('/')); + + self::assertSame(LogLevel::INFO, $defaultLogger->records[0]['level']); + self::assertSame('defaulted', $defaultLogger->records[0]['context']['outcome']); + self::assertSame('UTC', $defaultLogger->records[0]['context']['timezone']); + + $throwLogger = new RecordingLogger(); + $request = Request::create('/'); + $chain = $this->chain([ + 'failed' => $this->resolver(static function (): never { + throw new TimezoneResolverException('Technical failure.'); + }), + ], ResolutionFailureStrategy::THROW, $throwLogger); + + try { + $chain->resolve($request); + self::fail('Expected failure.'); + } catch (TimezoneResolverException) { + self::assertSame(LogLevel::ERROR, $throwLogger->records[0]['level']); + self::assertSame('failed', $throwLogger->records[0]['context']['outcome']); + self::assertNotNull(TimezoneResolutionTrace::fromRequest($request)); + } + } + + /** + * @param array $resolvers + */ + private function chain( + array $resolvers, + ResolutionFailureStrategy $strategy = ResolutionFailureStrategy::CONTINUE, + ?RecordingLogger $logger = null, + ): TimezoneResolverChain { + return new TimezoneResolverChain($resolvers, TimezoneId::fromString('UTC'), $strategy, $logger); + } + + /** @param callable(Request): ?TimezoneResolution $callback */ + private function resolver(callable $callback): TimezoneResolverInterface + { + return new class($callback) implements TimezoneResolverInterface { + /** @var \Closure(Request): ?TimezoneResolution */ + private \Closure $callback; + + /** @param callable(Request): ?TimezoneResolution $callback */ + public function __construct(callable $callback) + { + $this->callback = $callback(...); + } + + public function resolve(Request $request): ?TimezoneResolution + { + return ($this->callback)($request); + } + }; + } + + private static function resolution(string $timezone, string $source): TimezoneResolution + { + return new TimezoneResolution( + TimezoneId::fromString($timezone), + $source, + ResolutionKind::INFERRED, + ); + } +} + +final class RecordingLogger extends AbstractLogger +{ + /** @var list}> */ + public array $records = []; + + /** + * @param string|\Stringable $message + * @param array $context + */ + public function log($level, $message, array $context = []): void + { + if (!is_string($level)) { + throw new \InvalidArgumentException('The recording logger expects a string log level.'); + } + + $this->records[] = [ + 'level' => $level, + 'message' => (string) $message, + 'context' => $context, + ]; + } +} diff --git a/Tests/Resolver/CallableTimezoneResolverTest.php b/Tests/Resolver/CallableTimezoneResolverTest.php new file mode 100644 index 0000000..8e5a666 --- /dev/null +++ b/Tests/Resolver/CallableTimezoneResolverTest.php @@ -0,0 +1,93 @@ + 'Europe/Berlin', + 'tenant_callable', + ResolutionKind::AUTHENTICATED, + ); + $valueResolver = new CallableTimezoneResolver( + static fn (Request $_request): TimezoneId => TimezoneId::fromString('UTC'), + 'fallback_callable', + ); + + $stringResolution = $stringResolver->resolve($request); + $valueResolution = $valueResolver->resolve($request); + + self::assertNotNull($stringResolution); + self::assertSame('Europe/Berlin', $stringResolution->timezone->value()); + self::assertSame('tenant_callable', $stringResolution->source); + self::assertSame(ResolutionKind::AUTHENTICATED, $stringResolution->kind); + self::assertSame('UTC', $valueResolution?->timezone->value()); + } + + public function testPassesThroughACompleteResolutionAndNull(): void + { + $expected = new TimezoneResolution( + TimezoneId::fromString('UTC'), + 'complete', + ResolutionKind::EXPLICIT, + ); + $request = Request::create('/'); + + self::assertSame( + $expected, + (new CallableTimezoneResolver( + static fn (Request $_request): TimezoneResolution => $expected, + 'ignored_for_complete_result', + ))->resolve($request), + ); + self::assertNull((new CallableTimezoneResolver( + static fn (Request $_request): ?string => null, + 'empty_callable', + ))->resolve($request)); + } + + public function testInvalidStringsUseTheSingleTimezoneValidationPath(): void + { + $resolver = new CallableTimezoneResolver( + static fn (Request $_request): string => 'invalid', + 'invalid_callable', + ); + + $this->expectException(InvalidTimezoneException::class); + $resolver->resolve(Request::create('/')); + } + + public function testRejectsUnsupportedReturnTypesAsDeclaredResolverFailures(): void + { + $resolver = new CallableTimezoneResolver(static fn (Request $_request): int => 42, 'invalid_callable'); + + $this->expectException(TimezoneResolverException::class); + $resolver->resolve(Request::create('/')); + } + + public function testDoesNotHideProgrammerErrorsFromTheCallable(): void + { + $resolver = new CallableTimezoneResolver(static function (Request $_request): never { + throw new \LogicException('Broken application callback.'); + }, 'broken_callable'); + + $this->expectException(\LogicException::class); + $resolver->resolve(Request::create('/')); + } +} diff --git a/Tests/Resolver/HeaderTimezoneResolverTest.php b/Tests/Resolver/HeaderTimezoneResolverTest.php new file mode 100644 index 0000000..cb121a8 --- /dev/null +++ b/Tests/Resolver/HeaderTimezoneResolverTest.php @@ -0,0 +1,105 @@ +requestWithHeader('Europe/Berlin'); + + $resolution = (new HeaderTimezoneResolver(trustMode: HeaderTrustMode::ANY))->resolve($request); + + self::assertNotNull($resolution); + self::assertSame('Europe/Berlin', $resolution->timezone->value()); + self::assertSame('trusted_header', $resolution->source); + } + + public function testAllowlistUsesRawRemoteAddressRatherThanForwardedClientIp(): void + { + $request = $this->requestWithHeader('UTC', '10.20.30.40'); + $request->headers->set('X-Forwarded-For', '203.0.113.10'); + $resolver = new HeaderTimezoneResolver( + trustMode: HeaderTrustMode::ALLOWLIST, + trustedSources: ['10.20.30.0/24'], + ); + + self::assertSame('UTC', $resolver->resolve($request)?->timezone->value()); + + $request->server->set('REMOTE_ADDR', '192.0.2.1'); + self::assertNull($resolver->resolve($request)); + } + + public function testFrameworkModeRequiresAFrameworkTrustedProxy(): void + { + Request::setTrustedProxies(['10.0.0.1'], Request::HEADER_X_FORWARDED_FOR); + $trusted = $this->requestWithHeader('UTC', '10.0.0.1'); + $untrusted = $this->requestWithHeader('UTC', '10.0.0.2'); + $resolver = new HeaderTimezoneResolver(trustMode: HeaderTrustMode::FRAMEWORK); + + self::assertSame('UTC', $resolver->resolve($trusted)?->timezone->value()); + self::assertNull($resolver->resolve($untrusted)); + } + + /** @return iterable */ + public static function invalidHeaderValues(): iterable + { + yield 'empty' => [' ']; + yield 'comma separated' => ['UTC, Europe/Berlin']; + yield 'unknown timezone' => ['Invalid/Timezone']; + yield 'too long' => [str_repeat('A', 256)]; + } + + #[DataProvider('invalidHeaderValues')] + public function testRejectsMalformedOrInvalidHeaderValues(string $value): void + { + $this->expectException(InvalidTimezoneException::class); + + (new HeaderTimezoneResolver(trustMode: HeaderTrustMode::ANY))->resolve($this->requestWithHeader($value)); + } + + public function testRejectsMultipleHeaderLines(): void + { + $request = Request::create('/'); + $request->headers->set('X-Timezone', ['UTC', 'Europe/Berlin']); + + $this->expectException(InvalidTimezoneException::class); + (new HeaderTimezoneResolver(trustMode: HeaderTrustMode::ANY))->resolve($request); + } + + public function testReturnsNullWhenTrustedRequestHasNoHeader(): void + { + self::assertNull((new HeaderTimezoneResolver(trustMode: HeaderTrustMode::ANY))->resolve(Request::create('/'))); + } + + public function testAllowlistModeRequiresSources(): void + { + $this->expectException(\InvalidArgumentException::class); + + new HeaderTimezoneResolver(trustMode: HeaderTrustMode::ALLOWLIST); + } + + private function requestWithHeader(string $value, string $remoteAddress = '127.0.0.1'): Request + { + $request = Request::create('/', server: ['REMOTE_ADDR' => $remoteAddress]); + $request->headers->set('X-Timezone', $value); + + return $request; + } +} diff --git a/Tests/Resolver/LocaleMappingTimezoneResolverTest.php b/Tests/Resolver/LocaleMappingTimezoneResolverTest.php new file mode 100644 index 0000000..1d7eb2c --- /dev/null +++ b/Tests/Resolver/LocaleMappingTimezoneResolverTest.php @@ -0,0 +1,66 @@ + TimezoneId::fromString('Europe/Berlin'), + ]); + $request = Request::create('/'); + $request->setLocale('de-DE'); + + $resolution = $resolver->resolve($request); + + self::assertNotNull($resolution); + self::assertSame('Europe/Berlin', $resolution->timezone->value()); + self::assertSame('locale_mapping', $resolution->source); + self::assertSame(ResolutionKind::INFERRED, $resolution->kind); + } + + public function testReturnsNullForAnUnmappedLocale(): void + { + $request = Request::create('/'); + $request->setLocale('fr_FR'); + + self::assertNull((new LocaleMappingTimezoneResolver([]))->resolve($request)); + } + + public function testHyphenatedMappingKeyMatchesUnderscoreRequestLocale(): void + { + $request = Request::create('/'); + $request->setLocale('de_DE'); + + $resolution = (new LocaleMappingTimezoneResolver(['de-DE' => 'Europe/Berlin']))->resolve($request); + + self::assertNotNull($resolution); + self::assertSame('Europe/Berlin', $resolution->timezone->value()); + } + + public function testRejectsKeysThatCollideAfterNormalization(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('collides after normalization'); + + new LocaleMappingTimezoneResolver(['de-DE' => 'Europe/Berlin', 'de_DE' => 'Europe/Vienna']); + } + + public function testRequiresNonEmptyLocaleKeys(): void + { + $this->expectException(\InvalidArgumentException::class); + + new LocaleMappingTimezoneResolver(['' => 'Europe/Berlin']); + } +} diff --git a/Tests/Resolver/LocaleTimezoneResolverTest.php b/Tests/Resolver/LocaleTimezoneResolverTest.php new file mode 100644 index 0000000..2c8382f --- /dev/null +++ b/Tests/Resolver/LocaleTimezoneResolverTest.php @@ -0,0 +1,85 @@ +setLocale('de-DE'); + + $resolution = (new LocaleTimezoneResolver($source))->resolve($request); + + self::assertNotNull($resolution); + self::assertSame('DE', $source->requestedCountry); + self::assertSame('Europe/Berlin', $resolution->timezone->value()); + self::assertSame('locale_country', $resolution->source); + } + + public function testReturnsNullForZeroOrMultipleCountryTimezones(): void + { + $request = Request::create('/'); + $request->setLocale('de_DE'); + + self::assertNull((new LocaleTimezoneResolver(new RecordingCountryTimezoneSource([])))->resolve($request)); + self::assertNull((new LocaleTimezoneResolver(new RecordingCountryTimezoneSource([ + TimezoneId::fromString('Europe/Berlin'), + TimezoneId::fromString('Europe/Paris'), + ])))->resolve($request)); + } + + public function testReturnsNullWithoutQueryingTheSourceWhenLocaleHasNoCountry(): void + { + $source = new RecordingCountryTimezoneSource([TimezoneId::fromString('UTC')]); + $request = Request::create('/'); + $request->setLocale('de'); + + self::assertNull((new LocaleTimezoneResolver($source))->resolve($request)); + self::assertNull($source->requestedCountry); + } + + public function testProductionSourceMirrorsPhpTzdataWithoutHardcodedTimezoneResults(): void + { + $expected = \DateTimeZone::listIdentifiers(\DateTimeZone::PER_COUNTRY, 'DE'); + $actual = array_map( + static fn (TimezoneId $timezone): string => $timezone->value(), + (new PhpCountryTimezoneSource())->forCountry('DE'), + ); + + self::assertSame($expected, $actual); + } +} + +final class RecordingCountryTimezoneSource implements CountryTimezoneSourceInterface +{ + public ?string $requestedCountry = null; + + /** @param list $timezones */ + public function __construct(private readonly array $timezones) + { + } + + public function forCountry(string $countryCode): array + { + $this->requestedCountry = $countryCode; + + return $this->timezones; + } +} diff --git a/Tests/Resolver/MaxMindTimezoneResolverTest.php b/Tests/Resolver/MaxMindTimezoneResolverTest.php new file mode 100644 index 0000000..e7ce684 --- /dev/null +++ b/Tests/Resolver/MaxMindTimezoneResolverTest.php @@ -0,0 +1,77 @@ + 'Europe/Berlin')); + $resolution = $resolver->resolve($this->requestFrom('8.8.8.8')); + self::assertNotNull($resolution); + self::assertSame('Europe/Berlin', $resolution->timezone->value()); + self::assertSame('maxmind_city', $resolution->source); + self::assertSame(ResolutionKind::INFERRED, $resolution->kind); + + self::assertNull((new MaxMindTimezoneResolver(new CallableMaxMindCityReader(static fn (string $ip): null => null)))->resolve($this->requestFrom('1.1.1.1'))); + } + + /** @return iterable */ + public static function nonPublicAddresses(): iterable + { + yield 'loopback' => ['127.0.0.1']; + yield 'private' => ['10.0.0.1']; + yield 'link local' => ['169.254.1.1']; + yield 'multicast' => ['224.0.0.1']; + yield 'reserved documentation range' => ['192.0.2.1']; + yield 'ipv6 loopback' => ['::1']; + yield 'ipv6 link local' => ['fe80::1']; + } + + #[DataProvider('nonPublicAddresses')] + public function testNonPublicAddressesNeverCallReader(string $ipAddress): void + { + $reader = new class implements MaxMindCityReaderInterface { + public bool $called = false; + public function timezoneForIp(string $ipAddress): null { $this->called = true; return null; } + }; + self::assertNull((new MaxMindTimezoneResolver($reader))->resolve($this->requestFrom($ipAddress))); + self::assertFalse($reader->called); + } + + public function testReaderFailureIsRedactedAndCallableDoesNotCatchErrors(): void + { + $resolver = new MaxMindTimezoneResolver(new CallableMaxMindCityReader( + static fn (string $ip): never => throw new \RuntimeException('lookup failed for '.$ip), + )); + try { + $resolver->resolve($this->requestFrom('8.8.8.8')); + self::fail('Expected resolver exception.'); + } catch (TimezoneResolverException $exception) { + self::assertStringNotContainsString('8.8.8.8', $exception->getMessage()); + } + + $broken = new CallableMaxMindCityReader(static fn (string $ip): never => throw new \TypeError('broken')); + $this->expectException(\TypeError::class); + (new MaxMindTimezoneResolver($broken))->resolve($this->requestFrom('8.8.4.4')); + } + + private function requestFrom(string $ipAddress): Request + { + return Request::create('/', server: ['REMOTE_ADDR' => $ipAddress]); + } +} diff --git a/Tests/Resolver/OidcTimezoneResolverTest.php b/Tests/Resolver/OidcTimezoneResolverTest.php new file mode 100644 index 0000000..39be83b --- /dev/null +++ b/Tests/Resolver/OidcTimezoneResolverTest.php @@ -0,0 +1,83 @@ +}> */ + public static function absentClaims(): iterable + { + yield 'missing' => [[]]; + yield 'null' => [['zoneinfo' => null]]; + yield 'wrong type' => [['zoneinfo' => ['Europe/Berlin']]]; + } + + /** @param array $claims */ + #[DataProvider('absentClaims')] + public function testMissingOrNonStringClaimReturnsNull(array $claims): void + { + self::assertNull((new OidcTimezoneResolver($this->provider($claims)))->resolve(new Request())); + } + + public function testValidConfigurableClaimProducesAuthenticatedResolution(): void + { + $resolution = (new OidcTimezoneResolver($this->provider(['tz' => 'Europe/Berlin']), 'tz'))->resolve(new Request()); + self::assertNotNull($resolution); + self::assertSame('Europe/Berlin', $resolution->timezone->value()); + self::assertSame('oidc_claim_11aebd1febd43cd1', $resolution->source); + self::assertSame(ResolutionKind::AUTHENTICATED, $resolution->kind); + } + + public function testDefaultZoneinfoClaimUsesDistinctSource(): void + { + $resolution = (new OidcTimezoneResolver($this->provider(['zoneinfo' => 'Europe/Berlin'])))->resolve(new Request()); + + self::assertNotNull($resolution); + self::assertSame('Europe/Berlin', $resolution->timezone->value()); + self::assertSame('oidc_zoneinfo', $resolution->source); + } + + public function testInvalidClaimUsesValueObjectValidation(): void + { + $this->expectException(InvalidTimezoneException::class); + (new OidcTimezoneResolver($this->provider(['zoneinfo' => 'private-value'])))->resolve(new Request()); + } + + public function testProviderFailureIsWrappedWithoutClaimsInMessage(): void + { + $provider = new class implements OidcClaimsProviderInterface { + public function claimsForRequest(Request $request): array { throw new \RuntimeException('zoneinfo=Secret/Claim'); } + }; + + try { + (new OidcTimezoneResolver($provider))->resolve(new Request()); + self::fail('Expected resolver exception.'); + } catch (TimezoneResolverException $exception) { + self::assertSame('The OIDC claims provider failed.', $exception->getMessage()); + self::assertStringNotContainsString('Secret', $exception->getMessage()); + } + } + + /** @param array $claims */ + private function provider(array $claims): OidcClaimsProviderInterface + { + return new class($claims) implements OidcClaimsProviderInterface { + /** @param array $claims */ + public function __construct(private readonly array $claims) {} + public function claimsForRequest(Request $request): array { return $this->claims; } + }; + } +} diff --git a/Tests/Resolver/RequestAttributeTimezoneResolverTest.php b/Tests/Resolver/RequestAttributeTimezoneResolverTest.php new file mode 100644 index 0000000..4ccdf0a --- /dev/null +++ b/Tests/Resolver/RequestAttributeTimezoneResolverTest.php @@ -0,0 +1,69 @@ + */ + public static function supportedValues(): iterable + { + yield 'string' => ['Europe/Berlin']; + yield 'DateTimeZone' => [new \DateTimeZone('Europe/Berlin')]; + yield 'TimezoneId' => [TimezoneId::fromString('Europe/Berlin')]; + } + + #[DataProvider('supportedValues')] + public function testResolvesSupportedAttributeValues(mixed $value): void + { + $request = Request::create('/'); + $request->attributes->set('_timezone', $value); + + $resolution = (new RequestAttributeTimezoneResolver())->resolve($request); + + self::assertNotNull($resolution); + self::assertSame('Europe/Berlin', $resolution->timezone->value()); + self::assertSame('request_attribute', $resolution->source); + self::assertSame(ResolutionKind::EXPLICIT, $resolution->kind); + } + + public function testReturnsNullWhenTheAttributeIsMissingOrNull(): void + { + $request = Request::create('/'); + $resolver = new RequestAttributeTimezoneResolver('tenant_timezone'); + + self::assertNull($resolver->resolve($request)); + + $request->attributes->set('tenant_timezone', null); + self::assertNull($resolver->resolve($request)); + } + + public function testRejectsUnsupportedAttributeTypesWithoutCoercion(): void + { + $request = Request::create('/'); + $request->attributes->set('_timezone', 123); + + $this->expectException(InvalidTimezoneException::class); + (new RequestAttributeTimezoneResolver())->resolve($request); + } + + public function testRejectsInvalidTimezoneStringsThroughTheValueObject(): void + { + $request = Request::create('/'); + $request->attributes->set('_timezone', 'Not/A_Timezone'); + + $this->expectException(InvalidTimezoneException::class); + (new RequestAttributeTimezoneResolver())->resolve($request); + } +} diff --git a/Tests/Resolver/StoredPreferenceTimezoneResolverTest.php b/Tests/Resolver/StoredPreferenceTimezoneResolverTest.php new file mode 100644 index 0000000..24ce2c1 --- /dev/null +++ b/Tests/Resolver/StoredPreferenceTimezoneResolverTest.php @@ -0,0 +1,63 @@ +resolve($request)); + $cachedRead = $request->attributes->get(StoredPreferenceTimezoneResolver::READ_ATTRIBUTE); + self::assertInstanceOf(TimezonePreferenceRead::class, $cachedRead); + self::assertSame(PreferenceReadStatus::VALID, $cachedRead->status); + self::assertSame('persisted_browser', (new StoredPreferenceTimezoneResolver($storage, PreferenceSource::BROWSER))->resolve($request)?->source); + } + + public function testTwoSourceResolversReadStorageOnlyOncePerRequest(): void + { + $storage = new class implements TimezonePreferenceStorageInterface { + public int $reads = 0; + public function read(Request $request): TimezonePreferenceRead { ++$this->reads; return new TimezonePreferenceRead(PreferenceReadStatus::ABSENT); } + public function write(Request $request, Response $response, TimezonePreference $preference): void {} + public function clear(Request $request, Response $response): void {} + }; + $request = new Request(); + (new StoredPreferenceTimezoneResolver($storage, PreferenceSource::MANUAL))->resolve($request); + (new StoredPreferenceTimezoneResolver($storage, PreferenceSource::BROWSER))->resolve($request); + self::assertSame(1, $storage->reads); + } + + public function testPersistenceFailurePolicyIsAppliedInsideResolver(): void + { + $storage = new class implements TimezonePreferenceStorageInterface { + public function read(Request $request): TimezonePreferenceRead { throw new TimezoneStorageException('unavailable'); } + public function write(Request $request, Response $response, TimezonePreference $preference): void {} + public function clear(Request $request, Response $response): void {} + }; + self::assertNull((new StoredPreferenceTimezoneResolver($storage, null, PersistenceFailureStrategy::CONTINUE))->resolve(new Request())); + + $this->expectException(TimezoneStorageException::class); + (new StoredPreferenceTimezoneResolver($storage, null, PersistenceFailureStrategy::THROW))->resolve(new Request()); + } +} diff --git a/Tests/Resolver/UserTimezoneResolverTest.php b/Tests/Resolver/UserTimezoneResolverTest.php new file mode 100644 index 0000000..9eb001a --- /dev/null +++ b/Tests/Resolver/UserTimezoneResolverTest.php @@ -0,0 +1,143 @@ +resolve(new Request())); + } + + public function testUsesTimezoneAwareUserBeforeExplicitAccessor(): void + { + $user = new TimezoneAwareTestUser('Europe/Berlin'); + $accessor = new class implements UserTimezoneAccessorInterface { + public bool $called = false; + public function getTimezoneForUser(object $user): string { $this->called = true; return 'UTC'; } + }; + + $resolution = (new UserTimezoneResolver($this->storageFor($user), $accessor))->resolve(new Request()); + + self::assertNotNull($resolution); + self::assertSame('Europe/Berlin', $resolution->timezone->value()); + self::assertSame('authenticated_user', $resolution->source); + self::assertSame(ResolutionKind::AUTHENTICATED, $resolution->kind); + self::assertFalse($accessor->called); + } + + public function testUsesExplicitAccessorForAnOtherwiseUnsupportedUser(): void + { + $user = new PlainTestUser(); + $accessor = new class implements UserTimezoneAccessorInterface { + public function getTimezoneForUser(object $user): TimezoneId { return TimezoneId::fromString('UTC'); } + }; + + self::assertSame('UTC', (new UserTimezoneResolver($this->storageFor($user), $accessor))->resolve(new Request())?->timezone->value()); + self::assertNull((new UserTimezoneResolver($this->storageFor($user)))->resolve(new Request())); + } + + public function testInvalidUserTimezoneUsesValueObjectValidation(): void + { + $this->expectException(InvalidTimezoneException::class); + (new UserTimezoneResolver($this->storageFor(new TimezoneAwareTestUser('not-a-timezone'))))->resolve(new Request()); + } + + public function testAccessorExceptionsAreWrappedButErrorsAreNotSwallowed(): void + { + $user = new PlainTestUser(); + $failing = new class implements UserTimezoneAccessorInterface { + public function getTimezoneForUser(object $user): never { throw new \RuntimeException('secret'); } + }; + + try { + (new UserTimezoneResolver($this->storageFor($user), $failing))->resolve(new Request()); + self::fail('Expected resolver exception.'); + } catch (TimezoneResolverException $exception) { + self::assertSame('The user timezone accessor failed.', $exception->getMessage()); + self::assertInstanceOf(\RuntimeException::class, $exception->getPrevious()); + } + + $broken = new class implements UserTimezoneAccessorInterface { + public function getTimezoneForUser(object $user): never { throw new \TypeError('programmer error'); } + }; + $this->expectException(\TypeError::class); + (new UserTimezoneResolver($this->storageFor($user), $broken))->resolve(new Request()); + } + + public function testTimezoneAwareUserExceptionsAreWrappedWithoutLeakingMessage(): void + { + try { + (new UserTimezoneResolver($this->storageFor(new FailingTimezoneAwareTestUser())))->resolve(new Request()); + self::fail('Expected resolver exception.'); + } catch (TimezoneResolverException $exception) { + self::assertSame('The user timezone accessor failed.', $exception->getMessage()); + self::assertStringNotContainsString('secret', $exception->getMessage()); + self::assertInstanceOf(\RuntimeException::class, $exception->getPrevious()); + self::assertSame('secret timezone failure', $exception->getPrevious()->getMessage()); + } + } + + public function testTimezoneAwareUserErrorsRemainUnwrapped(): void + { + $this->expectException(\TypeError::class); + $this->expectExceptionMessage('programmer error'); + (new UserTimezoneResolver($this->storageFor(new BrokenTimezoneAwareTestUser())))->resolve(new Request()); + } + + private function storageFor(UserInterface $user): TokenStorage + { + $storage = new TokenStorage(); + $storage->setToken(new UsernamePasswordToken($user, 'main')); + return $storage; + } +} + +final class TimezoneAwareTestUser implements UserInterface, TimezoneAwareUserInterface +{ + public function __construct(private readonly TimezoneId|string|null $timezone) {} + public function getTimezone(): TimezoneId|string|null { return $this->timezone; } + public function getRoles(): array { return []; } + public function eraseCredentials(): void {} + public function getUserIdentifier(): string { return 'aware'; } +} + +final class PlainTestUser implements UserInterface +{ + public function getRoles(): array { return []; } + public function eraseCredentials(): void {} + public function getUserIdentifier(): string { return 'plain'; } +} + +final class FailingTimezoneAwareTestUser implements UserInterface, TimezoneAwareUserInterface +{ + public function getTimezone(): never { throw new \RuntimeException('secret timezone failure'); } + public function getRoles(): array { return []; } + public function eraseCredentials(): void {} + public function getUserIdentifier(): string { return 'failing-aware'; } +} + +final class BrokenTimezoneAwareTestUser implements UserInterface, TimezoneAwareUserInterface +{ + public function getTimezone(): never { throw new \TypeError('programmer error'); } + public function getRoles(): array { return []; } + public function eraseCredentials(): void {} + public function getUserIdentifier(): string { return 'broken-aware'; } +} diff --git a/Tests/Resources/BrowserRouteTest.php b/Tests/Resources/BrowserRouteTest.php new file mode 100644 index 0000000..c1a4590 --- /dev/null +++ b/Tests/Resources/BrowserRouteTest.php @@ -0,0 +1,24 @@ +get('lunetics_timezone_browser'); + self::assertNotNull($route); + self::assertSame('/_lunetics/timezone/browser', $route->getPath()); + self::assertSame(['POST'], $route->getMethods()); + self::assertSame(BrowserTimezoneController::class, $route->getDefault('_controller')); + } +} diff --git a/Tests/Storage/CookieTimezoneStorageTest.php b/Tests/Storage/CookieTimezoneStorageTest.php new file mode 100644 index 0000000..b79202d --- /dev/null +++ b/Tests/Storage/CookieTimezoneStorageTest.php @@ -0,0 +1,200 @@ +storage(new MutableClock('2026-01-01T00:00:00Z'))->read(new Request())->status); + } + + public function testSignedCookieRoundTripsAndTamperingIsRejected(): void + { + $clock = new MutableClock('2026-01-01T00:00:00Z'); + $storage = $this->storage($clock); + $response = new Response(); + $preference = $this->preference('America/New_York', PreferenceSource::MANUAL, $clock->now()); + + $storage->write(Request::create('https://example.test'), $response, $preference); + $cookie = $this->onlyCookie($response); + $value = $cookie->getValue(); + self::assertIsString($value); + $read = $storage->read($this->requestWithCookie($value)); + + self::assertSame(PreferenceReadStatus::VALID, $read->status); + self::assertNotNull($read->preference); + self::assertTrue($preference->timezone->equals($read->preference->timezone)); + self::assertSame($preference->source, $read->preference->source); + self::assertEquals($preference->recordedAt, $read->preference->recordedAt); + + $last = substr($value, -1); + $tampered = substr($value, 0, -1).('A' === $last ? 'B' : 'A'); + self::assertSame(PreferenceReadStatus::INVALID, $storage->read($this->requestWithCookie($tampered))->status); + } + + /** @param array|bool|float|int|string|null $value */ + #[DataProvider('malformedCookies')] + public function testMalformedAndOversizeCookiesAreRejected(array|bool|float|int|string|null $value): void + { + $clock = new MutableClock('2026-01-01T00:00:00Z'); + $request = new Request(); + $request->cookies->set('_lunetics_timezone', $value); + + self::assertSame(PreferenceReadStatus::INVALID, $this->storage($clock, maxEncodedSize: 32)->read($request)->status); + } + + /** @return iterable|string}> */ + public static function malformedCookies(): iterable + { + yield 'not an envelope' => ['not-an-envelope']; + yield 'invalid base64url' => ['abc.%%%']; + yield 'oversize' => [str_repeat('x', 33)]; + yield 'non-string cookie input' => [['nested' => 'value']]; + } + + public function testExpiredAndFutureDatedCookiesAreRejected(): void + { + $clock = new MutableClock('2026-01-01T00:00:00Z'); + $storage = $this->storage($clock, maxAge: 3600, futureSkew: 60); + + $expired = $this->encodedCookie($storage, $clock, new \DateTimeImmutable('2025-12-31T22:59:59Z')); + self::assertSame(PreferenceReadStatus::EXPIRED, $storage->read($this->requestWithCookie($expired))->status); + + $future = $this->encodedCookie($storage, $clock, new \DateTimeImmutable('2026-01-01T00:01:01Z')); + self::assertSame(PreferenceReadStatus::INVALID, $storage->read($this->requestWithCookie($future))->status); + } + + public function testSignedCookieRejectsNonCanonicalParseableTimestamp(): void + { + $payload = json_encode(['v' => 1, 'timezone' => 'Europe/Berlin', 'source' => 'browser', 'recorded_at' => '2026-01-01 00:00:00 UTC'], JSON_THROW_ON_ERROR); + $payload64 = $this->base64UrlEncode($payload); + $key = hash_hkdf('sha256', 'test-only-secret', 32, 'lunetics-timezone-cookie-v1'); + $cookie = $payload64.'.'.$this->base64UrlEncode(hash_hmac('sha256', $payload64, $key, true)); + + self::assertSame(PreferenceReadStatus::INVALID, $this->storage(new MutableClock('2026-01-01T00:00:00Z'))->read($this->requestWithCookie($cookie))->status); + } + + #[DataProvider('schemes')] + public function testSecureAutoFollowsRequestScheme(string $url, bool $secure): void + { + $clock = new MutableClock('2026-01-01T00:00:00Z'); + $response = new Response(); + $this->storage($clock)->write(Request::create($url), $response, $this->preference('Europe/Berlin', PreferenceSource::BROWSER, $clock->now())); + + self::assertSame($secure, $this->onlyCookie($response)->isSecure()); + } + + /** @return iterable */ + public static function schemes(): iterable + { + yield 'http' => ['http://example.test', false]; + yield 'https' => ['https://example.test', true]; + } + + public function testWriteAndClearUseConfiguredCookieAttributes(): void + { + $clock = new MutableClock('2026-01-01T00:00:00Z'); + $storage = new CookieTimezoneStorage('test-only-secret', $clock, 'tz_pref', 7200, '/account', 'example.test', true, false, Cookie::SAMESITE_STRICT); + $request = Request::create('https://example.test/account'); + $writtenResponse = new Response(); + + $storage->write($request, $writtenResponse, $this->preference('Europe/Berlin', PreferenceSource::BROWSER, $clock->now())); + $written = $this->onlyCookie($writtenResponse); + self::assertSame('tz_pref', $written->getName()); + self::assertSame('/account', $written->getPath()); + self::assertSame('example.test', $written->getDomain()); + self::assertTrue($written->isSecure()); + self::assertFalse($written->isHttpOnly()); + self::assertSame(Cookie::SAMESITE_STRICT, $written->getSameSite()); + self::assertSame($clock->now()->getTimestamp() + 7200, $written->getExpiresTime()); + + $clearedResponse = new Response(); + $storage->clear($request, $clearedResponse); + $cleared = $this->onlyCookie($clearedResponse); + self::assertSame('tz_pref', $cleared->getName()); + self::assertLessThanOrEqual(time(), $cleared->getExpiresTime()); + self::assertSame('/account', $cleared->getPath()); + self::assertSame('example.test', $cleared->getDomain()); + self::assertTrue($cleared->isSecure()); + self::assertFalse($cleared->isHttpOnly()); + self::assertSame(Cookie::SAMESITE_STRICT, $cleared->getSameSite()); + } + + public function testConstructorRejectsInvalidCookieName(): void + { + $this->expectException(\InvalidArgumentException::class); + new CookieTimezoneStorage('test-only-secret', new MutableClock('2026-01-01T00:00:00Z'), name: 'invalid cookie'); + } + + private function storage(MutableClock $clock, int $maxAge = 31536000, int $futureSkew = 60, int $maxEncodedSize = 4096): CookieTimezoneStorage + { + return new CookieTimezoneStorage('test-only-secret', $clock, maxAge: $maxAge, futureSkew: $futureSkew, maxEncodedSize: $maxEncodedSize); + } + + private function preference(string $timezone, PreferenceSource $source, \DateTimeImmutable $recordedAt): TimezonePreference + { + return new TimezonePreference(TimezoneId::fromString($timezone), $source, $recordedAt); + } + + private function encodedCookie(CookieTimezoneStorage $storage, MutableClock $clock, \DateTimeImmutable $recordedAt): string + { + $response = new Response(); + $storage->write(Request::create('https://example.test'), $response, $this->preference('Europe/Berlin', PreferenceSource::BROWSER, $recordedAt)); + + $value = $this->onlyCookie($response)->getValue(); + self::assertIsString($value); + + return $value; + } + + private function requestWithCookie(string $value): Request + { + $request = new Request(); + $request->cookies->set('_lunetics_timezone', $value); + + return $request; + } + + private function base64UrlEncode(string $value): string + { + return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + } + + private function onlyCookie(Response $response): Cookie + { + $cookies = $response->headers->getCookies(); + self::assertCount(1, $cookies); + + return $cookies[0]; + } +} + +final class MutableClock implements ClockInterface +{ + private \DateTimeImmutable $time; + + public function __construct(string $time) + { + $this->time = new \DateTimeImmutable($time); + } + + public function now(): \DateTimeImmutable + { + return $this->time; + } +} diff --git a/Tests/Storage/StorageDomainTest.php b/Tests/Storage/StorageDomainTest.php new file mode 100644 index 0000000..17c7def --- /dev/null +++ b/Tests/Storage/StorageDomainTest.php @@ -0,0 +1,137 @@ +recordedAt->getTimezone()->getName()); + + $this->expectException(\InvalidArgumentException::class); + new TimezonePreferenceRead(PreferenceReadStatus::VALID); + } + + public function testSessionReadDoesNotStartSession(): void + { + $session = new Session(new MockArraySessionStorage()); + $request = new Request(); + $request->setSession($session); + + self::assertSame(PreferenceReadStatus::ABSENT, (new SessionTimezoneStorage())->read($request)->status); + self::assertFalse($session->isStarted()); + } + + public function testLazySessionReadsAndClearsExistingCookieBackedPreference(): void + { + $preference = ['v' => 1, 'timezone' => 'Europe/Berlin', 'source' => 'browser', 'recorded_at' => '2025-01-01T10:00:00+00:00']; + $storage = new SessionTimezoneStorage(); + + $readSession = $this->lazySessionRequest($preference, true); + $read = $storage->read($readSession['request']); + self::assertSame(PreferenceReadStatus::VALID, $read->status); + self::assertTrue($readSession['session']->isStarted()); + + $clearSession = $this->lazySessionRequest($preference, true); + $storage->clear($clearSession['request'], new Response()); + self::assertTrue($clearSession['session']->isStarted()); + self::assertFalse($clearSession['session']->has('_lunetics_timezone')); + } + + public function testLazySessionWithoutCookieIsInstantiatedButNotStartedForReadOrClear(): void + { + $preference = ['v' => 1, 'timezone' => 'Europe/Berlin', 'source' => 'browser', 'recorded_at' => '2025-01-01T10:00:00+00:00']; + $storage = new SessionTimezoneStorage(); + + foreach (['read', 'clear'] as $operation) { + $lazy = $this->lazySessionRequest($preference, false); + if ('read' === $operation) { + self::assertSame(PreferenceReadStatus::ABSENT, $storage->read($lazy['request'])->status); + } else { + $storage->clear($lazy['request'], new Response()); + } + self::assertSame(1, $lazy['factoryCalls']()); + self::assertFalse($lazy['session']->isStarted()); + } + } + + public function testSessionRejectsNonCanonicalParseableTimestamp(): void + { + $session = new Session(new MockArraySessionStorage()); + $session->start(); + $session->set('_lunetics_timezone', ['v' => 1, 'timezone' => 'Europe/Berlin', 'source' => 'browser', 'recorded_at' => '2025-01-01 10:00:00 UTC']); + $request = new Request(); + $request->setSession($session); + + self::assertSame(PreferenceReadStatus::INVALID, (new SessionTimezoneStorage())->read($request)->status); + } + + public function testSessionRoundTripAndInvalidEnvelope(): void + { + $session = new Session(new MockArraySessionStorage()); + $session->start(); + $request = new Request(); + $request->setSession($session); + $storage = new SessionTimezoneStorage(); + $preference = new TimezonePreference(TimezoneId::fromString('Europe/Berlin'), PreferenceSource::BROWSER, new \DateTimeImmutable('2025-01-01T10:00:00Z')); + $storage->write($request, new Response(), $preference); + self::assertSame(PreferenceReadStatus::VALID, $storage->read($request)->status); + + $session->set('_lunetics_timezone', ['v' => 1]); + self::assertSame(PreferenceReadStatus::INVALID, $storage->read($request)->status); + } + + public function testSessionWriteWithoutSessionWrapsSessionNotFoundException(): void + { + $preference = new TimezonePreference(TimezoneId::fromString('Europe/Berlin'), PreferenceSource::MANUAL, new \DateTimeImmutable('2026-01-01T00:00:00Z')); + + try { + (new SessionTimezoneStorage())->write(new Request(), new Response(), $preference); + self::fail('Expected storage exception.'); + } catch (TimezoneStorageException $exception) { + self::assertSame('Timezone preference storage write failed.', $exception->getMessage()); + self::assertInstanceOf(SessionNotFoundException::class, $exception->getPrevious()); + } + } + + /** @param array{v: int, timezone: string, source: string, recorded_at: string} $preference + * @return array{request: Request, session: Session, factoryCalls: \Closure(): int} + */ + private function lazySessionRequest(array $preference, bool $withCookie): array + { + $sessionStorage = new MockArraySessionStorage(); + $sessionStorage->setSessionData(['_sf2_attributes' => ['_lunetics_timezone' => $preference]]); + $session = new Session($sessionStorage); + $factoryCalls = 0; + $request = new Request(); + $request->setSessionFactory(static function () use ($session, &$factoryCalls): Session { + ++$factoryCalls; + + return $session; + }); + if ($withCookie) { + $request->cookies->set($session->getName(), 'existing-session-id'); + } + + return ['request' => $request, 'session' => $session, 'factoryCalls' => static function () use (&$factoryCalls): int { + return $factoryCalls; + }]; + } +} diff --git a/Tests/Timezone/TimezoneIdTest.php b/Tests/Timezone/TimezoneIdTest.php new file mode 100644 index 0000000..7cf7de6 --- /dev/null +++ b/Tests/Timezone/TimezoneIdTest.php @@ -0,0 +1,67 @@ +value()); + self::assertSame('Europe/Berlin', (string) $timezone); + self::assertSame('Europe/Berlin', $timezone->toDateTimeZone()->getName()); + self::assertTrue($timezone->equals(TimezoneId::fromDateTimeZone(new \DateTimeZone('Europe/Berlin')))); + + $backwardCompatibleAliases = array_values(array_diff( + \DateTimeZone::listIdentifiers(\DateTimeZone::ALL_WITH_BC), + \DateTimeZone::listIdentifiers(\DateTimeZone::ALL), + )); + + if ([] !== $backwardCompatibleAliases) { + $alias = $backwardCompatibleAliases[0]; + self::assertSame($alias, TimezoneId::fromString($alias)->value()); + } + } + + /** @return iterable */ + public static function invalidIdentifiers(): iterable + { + yield 'empty' => ['']; + yield 'unknown' => ['Moon/Tranquility']; + yield 'raw offset' => ['+02:00']; + yield 'leading whitespace' => [' Europe/Berlin']; + yield 'trailing whitespace' => ['Europe/Berlin ']; + } + + #[DataProvider('invalidIdentifiers')] + public function testRejectsUnsupportedIdentifiers(string $identifier): void + { + $this->expectException(InvalidTimezoneException::class); + + TimezoneId::fromString($identifier); + } + + public function testNativeSerializationRevalidatesTheIdentifier(): void + { + $timezone = TimezoneId::fromString('Europe/Berlin'); + $unserialized = unserialize(serialize($timezone)); + + self::assertInstanceOf(TimezoneId::class, $unserialized); + self::assertTrue($timezone->equals($unserialized)); + + $tampered = str_replace('Europe/Berlin', 'Invalid/Zonee', serialize($timezone)); + + $this->expectException(InvalidTimezoneException::class); + unserialize($tampered); + } +} diff --git a/Tests/TimezoneGuesser/GeoTimezoneGuesserTest.php b/Tests/TimezoneGuesser/GeoTimezoneGuesserTest.php deleted file mode 100644 index c4b9d17..0000000 --- a/Tests/TimezoneGuesser/GeoTimezoneGuesserTest.php +++ /dev/null @@ -1,101 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that is distributed with this source code. - */ - - -namespace Lunetics\TimezoneBundle\Tests\TimezoneGuesser; - -use Symfony\Component\HttpFoundation\Request; - -use Lunetics\TimezoneBundle\TimezoneGuesser\GeoTimezoneGuesser; - -/** - * GeoTimezoneGuesser Tests - * - * @author Matthias Breddin - */ -class GeoTimezoneGuesserTest extends \PHPUnit_Framework_TestCase -{ - - public function setUp() - { - if(!extension_loaded('geoip') || !geoip_db_avail(GEOIP_CITY_EDITION_REV0) OR !geoip_db_avail(GEOIP_CITY_EDITION_REV1)) { - $this->markTestSkipped(); - } - } - - /** - * @dataProvider getRequestWithValidIp - * - * @param $request - */ - public function testValidIp($request) - { - $guesser = new GeoTimezoneGuesser; - $this->assertInternalType('string', $guesser->guessTimezone($request)); - $this->assertInternalType('string', $guesser->getIdentifiedTimezone()); - } - - /** - * @dataProvider getRequestWithInvalidIp - * - * @param $request - */ - public function testInvalidIp($request) - { - $guesser = new GeoTimezoneGuesser; - $this->assertFalse($guesser->guessTimezone($request)); - } - - public function getRequestWithValidIp() - { - $ipList = array( - '69.147.83.199', - '79.125.119.210', - '188.94.27.25', - '181.64.200.235', - '174.84.251.45' - ); - - return $this->getDataProvider($ipList); - } - - public function getRequestWithInvalidIp() - { - $ipList = array( - '10.0.0.1', - '127.0.0.1', - '192.168.0.1' - ); - - return $this->getDataProvider($ipList); - } - - public function getDataProvider($ips) - { - $data = array(); - foreach ($ips as $ip) { - $request = $this->getRequestMock($ip); - $data[] = array($request); - } - - return $data; - } - - public function getRequestMock($ip) - { - - $request = $this->getMock('Symfony\Component\HttpFoundation\Request', array('getClientIp')); - $request->expects($this->any()) - ->method('getClientIp') - ->will($this->returnValue($ip)); - - return $request; - } -} \ No newline at end of file diff --git a/Tests/TimezoneGuesser/LocaleTimezoneGuesserTest.php b/Tests/TimezoneGuesser/LocaleTimezoneGuesserTest.php deleted file mode 100644 index 17631c4..0000000 --- a/Tests/TimezoneGuesser/LocaleTimezoneGuesserTest.php +++ /dev/null @@ -1,87 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that is distributed with this source code. - */ - -namespace Lunetics\TimezoneBundle\Tests\TimezoneGuesser; - -use Symfony\Component\HttpFoundation\Request; - -use Lunetics\TimezoneBundle\TimezoneGuesser\LocaleTimezoneGuesser; - -/** - * LocaleTimezoneGuesser Tests - * - * @author Matthias Breddin - */ -class LocaleTimezoneGuesserTest extends \PHPUnit_Framework_TestCase -{ - - public function setUp() - { - if (!extension_loaded('geoip')) { - $this->markTestSkipped(); - } - } - - /** - * @dataProvider getRequestWithValidLocale - * - * @param $locale - */ - public function testValidLocale($locale) - { - $guesser = new LocaleTimezoneGuesser; - $request = $this->getRequestMock($locale); - $this->assertInternalType('string', $guesser->guessTimezone($request)); - $this->assertInternalType('string', $guesser->getIdentifiedTimezone()); - } - - /** - * @dataProvider getRequestWithInvalidLocale - * - * @param $locale - */ - public function testInvalidLocale($locale) - { - $guesser = new LocaleTimezoneGuesser; - $request = $this->getRequestMock($locale); - $this->assertFalse($guesser->guessTimezone($request)); - } - - public function getRequestWithValidLocale() - { - return array( - array('de_DE'), - array('it_IT'), - array('ch_IE') - ); - } - - public function getRequestWithInvalidLocale() - { - return array( - array('en_US'), - array('de'), - array('en'), - array('it'), - ); - } - - - public function getRequestMock($locale) - { - - $request = $this->getMock('Symfony\Component\HttpFoundation\Request', array('getLocale')); - $request->expects($this->any()) - ->method('getLocale') - ->will($this->returnValue($locale)); - - return $request; - } -} \ No newline at end of file diff --git a/Tests/TimezoneGuesser/LocalemapperGuesserTest.php b/Tests/TimezoneGuesser/LocalemapperGuesserTest.php deleted file mode 100644 index 1385525..0000000 --- a/Tests/TimezoneGuesser/LocalemapperGuesserTest.php +++ /dev/null @@ -1,109 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that is distributed with this source code. - */ - -namespace Lunetics\TimezoneBundle\Tests\TimezoneGuesser; - -use Symfony\Component\HttpFoundation\Request; - -use Symfony\Component\Yaml\Parser; -use Lunetics\TimezoneBundle\TimezoneGuesser\LocalemapperTimezoneGuesser; - -/** - * LocaleTimezoneGuesser Tests - * - * @author Matthias Breddin - */ -class LocalemapperTimezoneGuesserTest extends \PHPUnit_Framework_TestCase -{ - - /** - * @dataProvider getRequestWithValidLocale - * - * @param string $locale - */ - public function testValidLocale($locale) - { - $guesser = $this->getLocaleMapperGuesser(); - $request = $this->getRequestMock($locale); - $this->assertInternalType('string', $guesser->guessTimezone($request)); - $this->assertInternalType('string', $guesser->getIdentifiedTimezone()); - } - - /** - * @dataProvider getRequestWithInvalidLocale - * - * @param string $locale - */ - public function testInvalidLocale($locale) - { - $guesser = $this->getLocaleMapperGuesser(); - $request = $this->getRequestMock($locale); - $this->assertFalse($guesser->guessTimezone($request)); - } - - /** - * @return LocalemapperTimezoneGuesser - */ - public function getLocaleMapperGuesser() - { - $localeMapper = new Parser(); - - return new LocalemapperTimezoneGuesser($localeMapper->parse(file_get_contents(__DIR__ . '/../../Resources/config/LocaleMapper.yml'))); - } - - /** - * Returns an Array with Locales in the LocaleMapper.yml - * - * @return array - */ - public function getRequestWithValidLocale() - { - return array( - array('de_DE'), - array('en_US'), - array('it_IT'), - array('ru_RU'), - array('de'), - array('en'), - array('zh') - ); - } - - /** - * Returns an Array with Locales NOT in the LocaleMapper.yml - * - * @return array - */ - public function getRequestWithInvalidLocale() - { - return array( - array('ar_AR'), - array('at') - ); - } - - /** - * Mocks an Request - * - * @param string $locale - * - * @return \PHPUnit_Framework_MockObject_MockObject - */ - public function getRequestMock($locale) - { - - $request = $this->getMock('Symfony\Component\HttpFoundation\Request', array('getLocale')); - $request->expects($this->any()) - ->method('getLocale') - ->will($this->returnValue($locale)); - - return $request; - } -} \ No newline at end of file diff --git a/Tests/TimezoneGuesser/TimezoneGuesserManagerTest.php b/Tests/TimezoneGuesser/TimezoneGuesserManagerTest.php deleted file mode 100644 index 00bdb51..0000000 --- a/Tests/TimezoneGuesser/TimezoneGuesserManagerTest.php +++ /dev/null @@ -1,52 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that is distributed with this source code. - */ - - -namespace Lunetics\TimezoneBundle\Tests\TimezoneGuesser; - -use Symfony\Component\HttpFoundation\Request; - -use Lunetics\TimezoneBundle\TimezoneGuesser\TimezoneGuesserManager; - -/** - * TimezoneGuesserManager Tests - * - * @author Matthias Breddin - */ -class TimezoneGuesserManagerTest extends \PHPUnit_Framework_TestCase -{ - /** - * @expectedException \InvalidArgumentException - */ - public function testTimezoneGuessingInvalidGuesser() - { - $guesserManager = new TimezoneGuesserManager(array('foo')); - $guesserManager->addGuesser($this->getGuesserMock(), 'bar'); - $guesserManager->runTimezoneGuessing($this->getRequest()); - } - - /** - * @return \PHPUnit_Framework_MockObject_MockObject - */ - public function getGuesserMock() - { - return $this->getMock('Lunetics\TimezoneBundle\TimezoneGuesser\TimezoneGuesserInterface'); - } - - /** - * @return \Symfony\Component\HttpFoundation\Request - */ - public function getRequest() - { - $request = Request::create('/hello-world', 'GET'); - - return $request; - } -} diff --git a/Tests/TimezoneProvider/TimezoneProviderTest.php b/Tests/TimezoneProvider/TimezoneProviderTest.php deleted file mode 100644 index 623d3cc..0000000 --- a/Tests/TimezoneProvider/TimezoneProviderTest.php +++ /dev/null @@ -1,96 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that is distributed with this source code. - */ - - -namespace Lunetics\TimezoneBundle\Tests\TimezoneProvider; - -use Lunetics\TimezoneBundle\TimezoneProvider\TimezoneProvider; -use Lunetics\TimezoneBundle\Validator\TimezoneValidator; -use Symfony\Component\HttpFoundation\Request; - -use Lunetics\TimezoneBundle\TimezoneGuesser\GeoTimezoneGuesser; -use Symfony\Component\Validator\ConstraintViolationList; - -/** - * GeoTimezoneGuesser Tests - * - * @author Matthias Breddin - */ -class TimezoneProvidertTest extends \PHPUnit_Framework_TestCase -{ - public function setUp() - { - \PHPUnit_Framework_Error_Deprecated::$enabled = FALSE; - } - - public function testDefaultTimezone() - { - $timezoneProvider = new TimezoneProvider($this->getValidatorMock()); - $this->assertInternalType('string', $timezoneProvider->getTimezone()); - $this->assertEquals('UTC', $timezoneProvider->getTimezone()); - } - - public function testCustomDefaultTimezone() - { - $timezoneProvider = new TimezoneProvider($this->getValidatorMock(), 'Europe/Berlin'); - $this->assertInternalType('string', $timezoneProvider->getTimezone()); - $this->assertEquals('Europe/Berlin', $timezoneProvider->getTimezone()); - } - - public function testGetTimezone() - { - $timezoneProvider = new TimezoneProvider($this->getValidatorMock()); - $this->assertInternalType('string', $timezoneProvider->getTimezone()); - $this->assertEquals('UTC', $timezoneProvider->getTimezone()); - } - - public function testSetTimezone() - { - $timezoneProvider = new TimezoneProvider($this->getValidatorMock()); - $this->assertInternalType('string', $timezoneProvider->getTimezone()); - $this->assertEquals('UTC', $timezoneProvider->getTimezone()); - - $return = $timezoneProvider->setTimezone('Europe/Berlin'); - $this->assertInstanceOf(get_class($timezoneProvider), $return); - $this->assertEquals('Europe/Berlin', $timezoneProvider->getTimezone()); - } - - /** - * @expectedException Lunetics\TimezoneBundle\Exception\TimezoneException - */ - public function testSetTimezoneInvalid() - { - $timezoneProvider = new TimezoneProvider($this->getValidatorMock(true)); - $timezoneProvider->setTimezone('foobar'); - } - - public function testGetTimezoneDateTimeObject() - { - $timezoneProvider = new TimezoneProvider($this->getValidatorMock()); - $object = $timezoneProvider->getDateTimezoneObject(); - $this->assertInstanceOf('\DateTimezone', $object); - $this->assertEquals('UTC', $object->getName()); - } - - protected function getValidatorMock($returnError = false) - { - $mock = $this->getMockBuilder('Symfony\Component\Validator\ValidatorInterface')->disableOriginalConstructor()->getMock(); - $mock->expects($this->any()) - ->method('validateValue') - ->willReturn($returnError ? new ConstraintViolationList(array($this->getConstraintViolationMock())) : new ConstraintViolationList()); - - return $mock; - } - - protected function getConstraintViolationMock() - { - return $this->getMockBuilder('Symfony\Component\Validator\ConstraintViolation')->disableOriginalConstructor()->getMock(); - } -} \ No newline at end of file diff --git a/Tests/Validator/TimezoneValidatorTest.php b/Tests/Validator/TimezoneValidatorTest.php deleted file mode 100644 index eb37aee..0000000 --- a/Tests/Validator/TimezoneValidatorTest.php +++ /dev/null @@ -1,115 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that is distributed with this source code. - */ - -namespace Lunetics\TimezoneBundle\Tests\Validator; - -use Lunetics\TimezoneBundle\Validator\Timezone; -use Lunetics\TimezoneBundle\Validator\TimezoneValidator; - -/** - * Test for the Timezone Validator - * - * @author Matthias Breddin - */ -class TimezoneValidatorTest extends \PHPUnit_Framework_TestCase -{ - protected $context; - - /** - * Setup - */ - public function setUp() - { - \PHPUnit_Framework_Error_Deprecated::$enabled = FALSE; - $this->context = $this->getContext(); - } - - /** - * Provider for Valid Timezones - * - * @return array - */ - public function validTimezones() - { - return array( - array('Europe/Berlin'), - array('UTC'), - array('America/New_York') - ); - } - - /** - * Test if Timezone is valid - * - * @param string $timezone - * - * @dataProvider validTimezones - */ - public function testTimezoneIsValid($timezone) - { - $constraint = new Timezone(); - $this->context->expects($this->never()) - ->method('addViolation'); - $this->getTimezoneValidator()->validate($timezone, $constraint); - } - - /** - * Provider for Invalid Timezones - * - * @return array - */ - public function invalidTimezones() - { - return array( - array('GMT'), - array('NONE'), - array('EST') - ); - } - - /** - * Test if Timezone is invalid - * - * @param string $timezone - * - * @dataProvider invalidTimezones - */ - public function testTimezoneIsInvalid($timezone) - { - $constraint = new Timezone(); - $this->context->expects($this->once()) - ->method('addViolation') - ->with($this->equalTo($constraint->message), $this->equalTo(array('%string%' => $timezone))); - $this->getTimezoneValidator()->validate($timezone, $constraint); - } - - /** - * Returns an Executioncontext - * - * @return \PHPUnit_Framework_MockObject_MockObject - */ - private function getContext() - { - return $this->getMockBuilder('Symfony\Component\Validator\Context\ExecutionContext')->disableOriginalConstructor()->getMock(); - } - - /** - * Returns the TimezoneValidator - * - * @return \Lunetics\TimezoneBundle\Validator\TimezoneValidator - */ - private function getTimezoneValidator() - { - $validator = new TimezoneValidator(); - $validator->initialize($this->context); - - return $validator; - } -} diff --git a/Tests/bootstrap.php b/Tests/bootstrap.php index 264ee48..6b9bf66 100644 --- a/Tests/bootstrap.php +++ b/Tests/bootstrap.php @@ -1,15 +1,10 @@ - * - * For the full copyright and license information, please view the LICENSE - * file that is distributed with this source code. - */ +declare(strict_types=1); -if (!is_file($autoloadFile = __DIR__.'/../vendor/autoload.php') && !is_file($autoloadFile = __DIR__.'/../../../../../autoload.php')) { - throw new \LogicException('Could not find autoload.php in vendor/. Did you run "composer install --dev"?'); +$autoload = __DIR__.'/../vendor/autoload.php'; +if (!is_file($autoload)) { + throw new LogicException('Run "composer install" before executing the tests.'); } -require $autoloadFile; \ No newline at end of file + +require $autoload; diff --git a/TimezoneBundleEvents.php b/TimezoneBundleEvents.php deleted file mode 100644 index 8f86d7d..0000000 --- a/TimezoneBundleEvents.php +++ /dev/null @@ -1,27 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that is distributed with this source code. - */ - -namespace Lunetics\TimezoneBundle; - -/** - * Defines aliases for Events in this bundle - */ -final class TimezoneBundleEvents -{ - /** - * The lunetics_timezone.change event is thrown each time the timezone changes. - * - * The event listener receives an Lunetics\TimezoneBundle\Event\FilterTimezoneEvent instance - * - * @var string - * - */ - const TIMEZONE_CHANGE = 'lunetics_timezone.change'; -} \ No newline at end of file diff --git a/TimezoneGuesser/GeoTimezoneGuesser.php b/TimezoneGuesser/GeoTimezoneGuesser.php deleted file mode 100644 index d4497d9..0000000 --- a/TimezoneGuesser/GeoTimezoneGuesser.php +++ /dev/null @@ -1,58 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that is distributed with this source code. - */ - -namespace Lunetics\TimezoneBundle\TimezoneGuesser; - -use Lunetics\TimezoneBundle\Exception\TimezoneGuesserException; -use Symfony\Component\HttpFoundation\Request; -use Lunetics\TimezoneBundle\TimezoneGuesser\TimezoneGuesserInterface; - -/** - * Guesser Class to identify the timezone by the geoip pecl extension - * - * @author Matthias Breddin - */ -class GeoTimezoneGuesser implements TimezoneGuesserInterface -{ - private $identifiedTimezone; - - /** - * {@inheritDoc} - */ - public function guessTimezone(Request $request) - { - try { - $ip = $request->getClientIp(); - // Returns false if IP is Private or localhost - if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE) || $ip == '127.0.0.1') { - return false; - } - $geoIpResult = geoip_record_by_name($ip); - if (!is_array($geoIpResult)) { - return false; - } - $countryCode = $geoIpResult['country_code']; - $region = $geoIpResult['region']; - $this->identifiedTimezone = geoip_time_zone_by_country_and_region($countryCode, isset($region) ? $region : null); - - return $this->identifiedTimezone; - } catch (\Exception $e) { - throw new TimezoneGuesserException($e->getMessage(), $e->getCode(), $e); - } - } - - /** - * {@inheritDoc} - */ - public function getIdentifiedTimezone() - { - return $this->identifiedTimezone; - } -} diff --git a/TimezoneGuesser/LocaleTimezoneGuesser.php b/TimezoneGuesser/LocaleTimezoneGuesser.php deleted file mode 100644 index b0000dd..0000000 --- a/TimezoneGuesser/LocaleTimezoneGuesser.php +++ /dev/null @@ -1,54 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that is distributed with this source code. - */ - -namespace Lunetics\TimezoneBundle\TimezoneGuesser; - -use Symfony\Component\HttpFoundation\Request; -use Lunetics\TimezoneBundle\TimezoneGuesser\TimezoneGuesserInterface; - -/** - * Guesser Class to identify the timezone by the Request Locale - * - * @author Matthias Breddin - */ -class LocaleTimezoneGuesser implements TimezoneGuesserInterface -{ - private $identifiedTimezone; - - /** - * {@inheritDoc} - */ - public function guessTimezone(Request $request) - { - if (extension_loaded('intl')) { - $countryCode = \Locale::getRegion($request->getLocale()); - } else { - $splittedLocale = explode('_', $request->getLocale()); - $countryCode = count($splittedLocale) > 1 && preg_match('/^[A-Z]{2}$/', $splittedLocale[1]) ? $splittedLocale[1] : null; - } - if (null !== $countryCode) { - // Needs the @, cause otherwise the geoip_region_by_name function will send a PHP Notice - $this->identifiedTimezone = @geoip_time_zone_by_country_and_region($countryCode); - if (false !== $this->identifiedTimezone) { - return $this->identifiedTimezone; - } - } - - return false; - } - - /** - * {@inheritDoc} - */ - public function getIdentifiedTimezone() - { - return $this->identifiedTimezone; - } -} diff --git a/TimezoneGuesser/LocalemapperTimezoneGuesser.php b/TimezoneGuesser/LocalemapperTimezoneGuesser.php deleted file mode 100644 index aa34cbd..0000000 --- a/TimezoneGuesser/LocalemapperTimezoneGuesser.php +++ /dev/null @@ -1,74 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that is distributed with this source code. - */ - -namespace Lunetics\TimezoneBundle\TimezoneGuesser; - -use Symfony\Component\HttpFoundation\Request; -use Lunetics\TimezoneBundle\TimezoneGuesser\TimezoneGuesserInterface; - -/** - * Guesser Class to identify the timezone by the Request Locale - * - * @author Matthias Breddin - */ -class LocalemapperTimezoneGuesser implements TimezoneGuesserInterface -{ - private $localeMapper; - private $identifiedTimezone; - - /** - * Constructur - * - * @param array $localeMapper - */ - public function __construct(array $localeMapper) - { - $this->localeMapper = $localeMapper; - } - - /** - * {@inheritDoc} - */ - public function guessTimezone(Request $request) - { - $locale = $request->getLocale(); - if (preg_match('/^[a-z]{2}_[A-Z]{2}$/', $locale)) { - $this->identifiedTimezone = $this->findLocale($locale, 'locales_full'); - } elseif (preg_match('/^[a-z]{2}$/', $locale)) { - $this->identifiedTimezone = $this->findLocale($locale, 'locales_lang_only'); - } - if (null !== $this->identifiedTimezone) { - return $this->identifiedTimezone; - } - - return false; - } - - /** - * Returns the timezone for a given locale for the given mapper - * - * @param string $locale Locale - * @param string $localeType Array Key of the list - * - * @return string - */ - protected function findLocale($locale, $localeType) - { - return isset($this->localeMapper[$localeType][$locale]) ? $this->localeMapper[$localeType][$locale] : null; - } - - /** - * {@inheritDoc} - */ - public function getIdentifiedTimezone() - { - return $this->identifiedTimezone; - } -} diff --git a/TimezoneGuesser/TimezoneGuesserInterface.php b/TimezoneGuesser/TimezoneGuesserInterface.php deleted file mode 100644 index 801dc5a..0000000 --- a/TimezoneGuesser/TimezoneGuesserInterface.php +++ /dev/null @@ -1,38 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that is distributed with this source code. - */ - -namespace Lunetics\TimezoneBundle\TimezoneGuesser; - -use Symfony\Component\HttpFoundation\Request; - -/** - * This describes the Interface for the TimezoneGuesser - * - * @author Christophe Willemsen - * @author Matthias Breddin - */ -interface TimezoneGuesserInterface -{ - /** - * Returns the Timezone could be guessed, false on no guessing - * - * @param \Symfony\Component\HttpFoundation\Request $request - * - * @return boolean - */ - public function guessTimezone(Request $request); - - /** - * Returns the Identified Timezone, null if no Timezone is set/found - * - * @return string|null - */ - public function getIdentifiedTimezone(); -} diff --git a/TimezoneGuesser/TimezoneGuesserManager.php b/TimezoneGuesser/TimezoneGuesserManager.php deleted file mode 100644 index 20b4e93..0000000 --- a/TimezoneGuesser/TimezoneGuesserManager.php +++ /dev/null @@ -1,109 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that is distributed with this source code. - */ - -namespace Lunetics\TimezoneBundle\TimezoneGuesser; - -use Lunetics\TimezoneBundle\Exception\TimezoneGuesserException; -use Psr\Log\NullLogger; -use Symfony\Component\HttpKernel\Log\LoggerInterface; -use Symfony\Component\HttpFoundation\Request; - -use Lunetics\TimezoneBundle\TimezoneGuesser\TimezoneGuesserInterface; - -/** - * Manager to utilize the Timezone guessing services - * - * @author Christophe Willemsen - * @author Matthias Breddin - */ -class TimezoneGuesserManager -{ - private $guessers; - - private $logger; - - /** - * Constructor - * - * @param array $order Order parameter from config - * @param LoggerInterface $logger Logger - */ - public function __construct(array $order, LoggerInterface $logger = null) - { - $this->order = $order; - $this->logger = $logger ? : new NullLogger(); - } - - /** - * Adds a Guesser to the Manager - * - * @param TimezoneGuesserInterface $guesser The Guesser Service - * @param string $alias Alias of the Service - */ - public function addGuesser(TimezoneGuesserInterface $guesser, $alias) - { - $this->guessers[$alias] = $guesser; - } - - /** - * Returns the guesser - * - * @param string $alias - * - * @return mixed - */ - public function getGuesser($alias) - { - if (array_key_exists($alias, $this->guessers)) { - return $this->guessers[$alias]; - } else { - return null; - } - } - - /** - * Loops through all the activated Timzone Guessers and - * calls the guessTimezone methode and passing the current request - * - * @param Request $request - * - * @throws \InvalidArgumentException - * - * @return bool false if no timezone is identified - * @return bool the timezone identified by the guessers - */ - public function runTimezoneGuessing(Request $request) - { - foreach ($this->order as $guesser) { - if (null === $this->getGuesser($guesser)) { - throw new \InvalidArgumentException(sprintf('Service "%s" does not exist.', $guesser)); - } - $this->logger->debug(sprintf('Timezone %s Guessing Service Loaded', ucfirst($guesser))); - $guesserService = $this->getGuesser($guesser); - - $guessed = false; - try { - $guessed = $guesserService->guessTimezone($request); - } catch (TimezoneGuesserException $e) { - // Some guessers like the GeoTimezoneGuesser may throw an exception. Log the problem without crashing. - $this->logger->error($e->getMessage()); - } - if (false !== $guessed) { - $timezone = $guesserService->getIdentifiedTimezone(); - $this->logger->info(sprintf('Timezone has been identified by the %s Guessing Service: ( %s )', ucfirst($guesser), $timezone)); - - return $timezone; - } - $this->logger->debug(sprintf('Timezone has not been identified by the %s Guessing Service', ucfirst($guesser))); - } - - return false; - } -} diff --git a/TimezoneProvider/TimezoneProvider.php b/TimezoneProvider/TimezoneProvider.php deleted file mode 100644 index 4a0b402..0000000 --- a/TimezoneProvider/TimezoneProvider.php +++ /dev/null @@ -1,90 +0,0 @@ -validator = $validator; - $this->logger = $logger ? : new NullLogger(); - $this->timezone = $defaultTimezone; - } - - /** - * Sets the timezone - * - * @param $timezone - * - * @return $this - * @throws \Lunetics\TimezoneBundle\Exception\TimezoneException - */ - public function setTimezone($timezone) - { - $errors = $this->validator->validate($timezone, new Timezone()); - if ($errors->count() > 0) { - $iterator = $errors->getIterator(); - while ($iterator->valid()) { - $this->logger->error($iterator->current()); - $iterator->next(); - } - - throw new TimezoneException(sprintf('Trying to set invalid timezone "%s"', $timezone)); - } - $this->timezone = $timezone; - $this->logger->info(sprintf('TimezoneProvider: Set timezone to [ %s ]', $this->timezone)); - - return $this; - } - - /** - * Returns the timezone - * - * @return string - */ - public function getTimezone() - { - return $this->timezone; - } - - /** - * @return \DateTimeZone - */ - public function getDateTimezoneObject() - { - return new \DateTimeZone($this->timezone); - } -} diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md new file mode 100644 index 0000000..5886448 --- /dev/null +++ b/UPGRADE-2.0.md @@ -0,0 +1,44 @@ +# Upgrading to LuneticsTimezoneBundle 2.0 + +V2 is a clean break. It contains no backward-compatibility layer, aliases, adapters, or deprecation bridge for 1.x APIs. Remove old usage before upgrading and migrate persisted preference data explicitly or let V2 create new values. + +## API migration + +- Replace `TimezoneGuesserInterface` implementations with `Lunetics\TimezoneBundle\Resolver\TimezoneResolverInterface`. Register each service explicitly with `lunetics_timezone.resolver`, a unique `index`, and the intended `priority`. +- Replace `TimezoneGuesserManager`, `GeoTimezoneGuesser`, `LocaleTimezoneGuesser`, and `LocalemapperTimezoneGuesser` configuration with V2 resolver configuration or a custom resolver. +- Replace `TimezoneProvider\TimezoneProvider` injection with `Context\CurrentTimezoneProviderInterface`. Use `getTimezone()`, `getDateTimeZone()`, or `getResolution()`. +- Replace `TimezoneBundleEvents`, `FilterTimezoneEvent`, and old timezone listeners with `TimezoneResolvedEvent`, `TimezonePreferenceChangedEvent`, or a custom resolver as appropriate. +- Remove the bundle's old `Validator\Timezone` constraint and validator. V2 validates `TimezoneId` at its own boundaries and does not require Symfony Validator. +- Replace old guesser configuration with the `resolution`, `persistence`, `browser`, and `integrations` trees documented in [installation](Resources/doc/installation.md). + +## Persistence and identity + +V2 storage implements `TimezonePreferenceStorageInterface`; its `read()` receives a `Request`, while `write()` and `clear()` receive both `Request` and `Response`. Built-in state uses the versioned envelope `v`, `timezone`, `source`, `recorded_at`. Old session/cookie values are not a supported V2 format. + +Authenticated users either implement `TimezoneAwareUserInterface::getTimezone(): TimezoneId|string|null` or use an explicit `UserTimezoneAccessorInterface`. OIDC claims require an explicit `OidcClaimsProviderInterface`; the default claim is `zoneinfo`, and no reflection/provider-specific integration remains. The public resolution source is `oidc_zoneinfo` for the default `zoneinfo` claim. Any customized claim uses `oidc_claim_`, where `` is the first 16 lowercase hexadecimal SHA-256 characters of the exact case-sensitive configured claim name. This bounded hash keeps raw/custom claim names out of diagnostics while remaining stable. + +## Optional integrations + +- Browser sync defaults off. Enable it, import `@LuneticsTimezoneBundle/Resources/config/routes.php` with type `php`, provide the CSRF token, and import `bundles/luneticstimezone/timezone.js` as a pure ES module. +- Messenger defaults off. Enable it and add `lunetics_timezone.messenger.dispatch_middleware` and `lunetics_timezone.messenger.worker_middleware` to your own bus configuration. The bundle never mutates buses. +- Form defaults off. Twig and profiler default to `auto`. +- MaxMind accepts GeoLite2 City or GeoIP2 City files, or an explicit custom City reader. Country/ASN databases are not supported. + +## Compatibility-relevant hardening + +- Configuration now rejects unknown `integrations` keys, invalid cookie identifiers, blank MaxMind database/reader identifiers, and configured-service alias cycles while building the container. +- The bundle's internal clock uses an existing application `Psr\Clock\ClockInterface` after all extensions have loaded or falls back to `SystemClock`; it no longer defines or replaces the application's global clock alias. +- Exceptions thrown by `TimezoneAwareUserInterface` and configured user accessors are converted to typed resolver failures, while unrelated callable-adapter exceptions/errors still bubble. `CallableTimezoneResolver` is shipped for explicitly tagged application callables; it is not auto-registered. +- Session-backed browser writes require Framework session configuration. A sessionless write is a typed storage failure and returns `503` from the browser endpoint. +- Profiler diagnostics survive profile serialization/reload. Distribution checks now cover clean `--no-dev` installation and retain linked scope/plan documentation plus the root `LICENSE` in archives; AssetMapper discovers the bundle asset without manual paths. + +## Migration sequence + +1. Remove all 1.x bundle configuration and obsolete API imports. +2. Register the V2 bundle and start from the minimal configuration. +3. Port custom resolution and storage services to the exact V2 contracts. +4. Choose how old preferences are discarded or transformed into the V2 envelope. +5. Wire optional route, CSRF, asset, Twig/Form/Messenger, OIDC, and MaxMind features explicitly. +6. Verify resolver order with `bin/console debug:timezone` and exercise application requests, workers, and preference writes. + +See the [authoritative V2 contracts and ADR](Resources/doc/v2-implementation-plan.md) for exact behavior. diff --git a/Validator/Timezone.php b/Validator/Timezone.php deleted file mode 100644 index 8a81df0..0000000 --- a/Validator/Timezone.php +++ /dev/null @@ -1,23 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that is distributed with this source code. - */ - -namespace Lunetics\TimezoneBundle\Validator; - -use Symfony\Component\Validator\Constraint; - -/** - * Timezone Constraint - * - * @Annotation - */ -class Timezone extends Constraint -{ - public $message = 'The timezone "%string%" is not a valid timezone'; -} \ No newline at end of file diff --git a/Validator/TimezoneValidator.php b/Validator/TimezoneValidator.php deleted file mode 100644 index 23915d1..0000000 --- a/Validator/TimezoneValidator.php +++ /dev/null @@ -1,36 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that is distributed with this source code. - */ - -namespace Lunetics\TimezoneBundle\Validator; - -use Symfony\Component\Validator\Constraint; -use Symfony\Component\Validator\ConstraintValidator; - -/** - * Timezone Validator Class - * - * @author Matthias Breddin - */ -class TimezoneValidator extends ConstraintValidator -{ - - /** - * Validates the Timezone - * - * @param string $timezone The timezone string - * @param Timezone|Constraint $constraint timezone Constraint - */ - public function validate($timezone, Constraint $constraint) - { - if (!in_array($timezone, \DateTimeZone::listIdentifiers())) { - $this->context->addViolation($constraint->message, array('%string%' => $timezone)); - } - } -} diff --git a/composer.json b/composer.json index e886b8e..00c545b 100644 --- a/composer.json +++ b/composer.json @@ -1,31 +1,67 @@ { "name": "lunetics/timezone-bundle", - "description": "A Bundle for detecting the timezone", - "keywords": ["timezone", "bundle", "time zone"], + "description": "User timezone detection and request-scoped timezone state for Symfony applications", + "keywords": ["symfony", "timezone", "time zone"], "type": "symfony-bundle", "license": "MIT", - "authors": [ - { - "name": "Lunetics", - "homepage": "http://www.lunetics.com" - } - ], - "minimum-stability": "dev", + "authors": [{"name": "Lunetics", "homepage": "https://www.lunetics.com"}], "require": { - "php": ">=5.3.3", - "symfony/framework-bundle": ">=2.2", - "symfony/yaml": "~2.1", - "symfony/validator": "~2.1" + "php": "^8.2", + "psr/clock": "^1", + "psr/log": "^3", + "symfony/config": "^6.4 || ^7.4 || ^8.0", + "symfony/dependency-injection": "^6.4 || ^7.4 || ^8.0", + "symfony/event-dispatcher-contracts": "^3.5", + "symfony/http-foundation": "^6.4 || ^7.4 || ^8.0", + "symfony/http-kernel": "^6.4 || ^7.4 || ^8.0", + "symfony/service-contracts": "^3.5" }, "require-dev": { - "ext-intl": "*" + "geoip2/geoip2": "^3.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-symfony": "^2.0", + "phpunit/phpunit": "^11.5", + "symfony/asset-mapper": "^6.4 || ^7.4 || ^8.0", + "symfony/console": "^6.4 || ^7.4 || ^8.0", + "symfony/event-dispatcher": "^6.4 || ^7.4 || ^8.0", + "symfony/form": "^6.4 || ^7.4 || ^8.0", + "symfony/framework-bundle": "^6.4 || ^7.4 || ^8.0", + "symfony/messenger": "^6.4 || ^7.4 || ^8.0", + "symfony/routing": "^6.4 || ^7.4 || ^8.0", + "symfony/security-bundle": "^6.4 || ^7.4 || ^8.0", + "symfony/security-core": "^6.4 || ^7.4 || ^8.0", + "symfony/security-csrf": "^6.4 || ^7.4 || ^8.0", + "symfony/serializer": "^6.4 || ^7.4 || ^8.0", + "symfony/twig-bundle": "^6.4 || ^7.4 || ^8.0", + "symfony/web-profiler-bundle": "^6.4 || ^7.4 || ^8.0", + "twig/twig": "^3.0" }, "suggest": { - "ext-intl": "Always a good idea :)", - "ext-geoip": "*" + "ext-intl": "Improves locale parsing for locale-based timezone resolution", + "geoip2/geoip2": "Enables MaxMind GeoIP timezone resolution", + "symfony/asset-mapper": "Exposes the browser timezone JavaScript asset", + "symfony/console": "Enables console integration", + "symfony/event-dispatcher": "Provides the default event dispatcher implementation", + "symfony/form": "Enables form timezone integration", + "symfony/framework-bundle": "Provides full Symfony framework integration", + "symfony/messenger": "Enables Messenger timezone context propagation", + "symfony/routing": "Enables the opt-in browser preference route", + "symfony/security-bundle": "Provides the token storage service used for authenticated user timezone resolution", + "symfony/security-core": "Enables authenticated user timezone resolution", + "symfony/security-csrf": "Protects the browser preference endpoint", + "symfony/twig-bundle": "Enables Twig timezone integration", + "symfony/web-profiler-bundle": "Enables timezone resolution diagnostics", + "twig/twig": "Enables Twig timezone helpers" }, "autoload": { - "psr-0": { "Lunetics\\TimezoneBundle": "" } + "psr-4": {"Lunetics\\TimezoneBundle\\": "src/"}, + "exclude-from-classmap": ["/Tests/"] }, - "target-dir": "Lunetics/TimezoneBundle" + "autoload-dev": {"psr-4": {"Lunetics\\TimezoneBundle\\Tests\\": "Tests/"}}, + "scripts": { + "analyse": "phpstan analyse --no-progress --memory-limit=512M", + "test": "phpunit", + "check": ["@analyse", "@test"] + }, + "config": {"allow-plugins": false, "sort-packages": true} } diff --git a/package.json b/package.json new file mode 100644 index 0000000..9782c3b --- /dev/null +++ b/package.json @@ -0,0 +1,7 @@ +{ + "private": true, + "type": "module", + "scripts": { + "test": "node --test Tests/Browser/timezone.test.mjs" + } +} diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..4a488ef --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,10 @@ +includes: + - vendor/phpstan/phpstan-symfony/extension.neon + +parameters: + level: max + paths: + - src + - Tests + tmpDir: var/phpstan + reportUnmatchedIgnoredErrors: true diff --git a/phpunit.xml.dist b/phpunit.xml.dist index be27123..fdc256b 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,18 +1,20 @@ - - - - ./Tests - - - - - ./ - - ./Tests - ./vendor - - - - \ No newline at end of file + + + + Tests + + + + + src + + + diff --git a/scripts/no-dev-smoke.php b/scripts/no-dev-smoke.php new file mode 100644 index 0000000..db2c422 --- /dev/null +++ b/scripts/no-dev-smoke.php @@ -0,0 +1,91 @@ +getPath()) { + throw new RuntimeException('Bundle root does not match the package root.'); +} + +foreach ([ + 'Resources/config/routes.php', + 'Resources/public/timezone.js', + 'Resources/views/Collector/timezone.html.twig', + 'Resources/doc/scope.md', + 'Resources/doc/v2-implementation-plan.md', + 'LICENSE', +] as $runtimeFile) { + if (!is_file($root.'/'.$runtimeFile)) { + throw new RuntimeException(sprintf('Missing shipped runtime file: %s.', $runtimeFile)); + } +} + +foreach ([ + 'Symfony\\Bundle\\FrameworkBundle\\FrameworkBundle', + 'Symfony\\Component\\Form\\AbstractTypeExtension', + 'Twig\\Environment', + 'GeoIp2\\Database\\Reader', +] as $developmentOnlyClass) { + if (class_exists($developmentOnlyClass)) { + throw new RuntimeException(sprintf('Development-only integration is present: %s.', $developmentOnlyClass)); + } +} + +$timezone = TimezoneId::fromString('UTC'); +$clock = new SystemClock(); +$storage = new SessionTimezoneStorage(); +if ('UTC' !== $timezone->value() || !$clock->now() instanceof DateTimeImmutable) { + throw new RuntimeException('Core timezone or clock construction failed.'); +} +if (PreferenceReadStatus::ABSENT !== $storage->read(new Request())->status) { + throw new RuntimeException('A sessionless request must produce an ABSENT preference read.'); +} + +$container = new ContainerBuilder(); +$container->setParameter('kernel.environment', 'prod'); +$container->setParameter('kernel.debug', false); +$container->setParameter('kernel.build_dir', sys_get_temp_dir().'/lunetics-timezone-bundle-no-dev/build'); +$container->setParameter('kernel.cache_dir', sys_get_temp_dir().'/lunetics-timezone-bundle-no-dev/cache'); +$container->setParameter('kernel.project_dir', $root); +$extension = $bundle->getContainerExtension(); +if (null === $extension) { + throw new RuntimeException('Bundle container extension is unavailable.'); +} +$container->registerExtension($extension); +$container->loadFromExtension($extension->getAlias(), ['integrations' => ['form' => true]]); +$bundle->build($container); + +try { + $container->compile(); +} catch (LogicException $failure) { + if (!str_contains($failure->getMessage(), 'symfony/form')) { + throw new RuntimeException('Missing form dependency failed without mentioning symfony/form.', 0, $failure); + } + + fwrite(STDOUT, "No-dev smoke passed.\n"); + exit(0); +} + +throw new RuntimeException('Enabling form integration without symfony/form did not fail.'); diff --git a/src/Bridge/Console/DebugTimezoneCommand.php b/src/Bridge/Console/DebugTimezoneCommand.php new file mode 100644 index 0000000..00d777c --- /dev/null +++ b/src/Bridge/Console/DebugTimezoneCommand.php @@ -0,0 +1,33 @@ + $resolvers */ + public function __construct(private readonly string $configuredDefault, private readonly array $resolvers) + { + parent::__construct(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $io->writeln(sprintf('Configured default: %s', $this->configuredDefault)); + $io->table(['Resolver', 'Priority'], array_map( + static fn (array $resolver): array => [$resolver['name'], (string) $resolver['priority']], + $this->resolvers, + )); + + return self::SUCCESS; + } +} diff --git a/src/Bridge/Form/TimezoneTypeExtension.php b/src/Bridge/Form/TimezoneTypeExtension.php new file mode 100644 index 0000000..6d6d048 --- /dev/null +++ b/src/Bridge/Form/TimezoneTypeExtension.php @@ -0,0 +1,30 @@ +setDefault('view_timezone', fn (Options $options): string => $this->provider->getTimezone()->value()); + } +} diff --git a/src/Bridge/MaxMind/CallableMaxMindCityReader.php b/src/Bridge/MaxMind/CallableMaxMindCityReader.php new file mode 100644 index 0000000..275a8ed --- /dev/null +++ b/src/Bridge/MaxMind/CallableMaxMindCityReader.php @@ -0,0 +1,29 @@ +reader = $reader(...); + } + + public function timezoneForIp(string $ipAddress): TimezoneId|string|null + { + try { + return ($this->reader)($ipAddress); + } catch (\Exception $exception) { + throw TimezoneResolverException::maxMindLookupFailed($exception); + } + } +} diff --git a/src/Bridge/MaxMind/GeoIp2CityReader.php b/src/Bridge/MaxMind/GeoIp2CityReader.php new file mode 100644 index 0000000..dd72b43 --- /dev/null +++ b/src/Bridge/MaxMind/GeoIp2CityReader.php @@ -0,0 +1,29 @@ +reader->city($ipAddress)->location->timeZone; + } catch (AddressNotFoundException) { + return null; + } catch (InvalidDatabaseException|\BadMethodCallException $exception) { + throw TimezoneResolverException::maxMindLookupFailed($exception); + } + } +} diff --git a/src/Bridge/MaxMind/LazyGeoIp2CityReader.php b/src/Bridge/MaxMind/LazyGeoIp2CityReader.php new file mode 100644 index 0000000..5655e09 --- /dev/null +++ b/src/Bridge/MaxMind/LazyGeoIp2CityReader.php @@ -0,0 +1,57 @@ +): Reader */ + private \Closure $readerFactory; + + /** @var list */ + private readonly array $locales; + + /** + * The optional factory is an injection seam for applications with custom + * Reader construction and for tests that do not carry an MMDB fixture. + * + * @param list $locales + * @param null|callable(string, list): Reader $readerFactory + */ + public function __construct( + private readonly string $databasePath, + array $locales = ['en'], + ?callable $readerFactory = null, + ) { + $this->locales = $locales; + $this->readerFactory = null === $readerFactory + ? self::openReader(...) + : $readerFactory(...); + } + + public function timezoneForIp(string $ipAddress): ?string + { + if (null === $this->adapter) { + try { + $this->adapter = new GeoIp2CityReader(($this->readerFactory)($this->databasePath, $this->locales)); + } catch (\Exception $exception) { + throw TimezoneResolverException::maxMindDatabaseFailed($exception); + } + } + + return $this->adapter->timezoneForIp($ipAddress); + } + + /** @param list $locales */ + private static function openReader(string $path, array $locales): Reader + { + return new Reader($path, $locales); + } +} diff --git a/src/Bridge/MaxMind/MaxMindCityReaderInterface.php b/src/Bridge/MaxMind/MaxMindCityReaderInterface.php new file mode 100644 index 0000000..8d46f61 --- /dev/null +++ b/src/Bridge/MaxMind/MaxMindCityReaderInterface.php @@ -0,0 +1,12 @@ +metadataFactory = null === $metadataFactory + ? static function (string $path): object { + $reader = new Reader($path); + + return $reader->metadata(); + } + : $metadataFactory(...); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + if (!is_readable($this->databasePath)) { + $output->writeln('The MaxMind database is not readable.'); + + return self::FAILURE; + } + + try { + $metadata = ($this->metadataFactory)($this->databasePath); + $databaseType = property_exists($metadata, 'databaseType') && is_string($metadata->databaseType) + ? $metadata->databaseType + : ''; + if (!str_contains($databaseType, 'City')) { + $output->writeln('The MaxMind database is not a City database.'); + + return self::FAILURE; + } + } catch (\Exception) { + $output->writeln('The MaxMind database could not be opened.'); + + return self::FAILURE; + } + + $output->writeln('The MaxMind City database is readable and valid.'); + + return self::SUCCESS; + } +} diff --git a/src/Bridge/Messenger/DispatchTimezoneMiddleware.php b/src/Bridge/Messenger/DispatchTimezoneMiddleware.php new file mode 100644 index 0000000..934e01f --- /dev/null +++ b/src/Bridge/Messenger/DispatchTimezoneMiddleware.php @@ -0,0 +1,26 @@ +last(TimezoneStamp::class)) { + $envelope = $envelope->with(new TimezoneStamp($this->provider->getTimezone()->value())); + } + + return $stack->next()->handle($envelope, $stack); + } +} diff --git a/src/Bridge/Messenger/TimezoneStamp.php b/src/Bridge/Messenger/TimezoneStamp.php new file mode 100644 index 0000000..87fe9f6 --- /dev/null +++ b/src/Bridge/Messenger/TimezoneStamp.php @@ -0,0 +1,39 @@ +timezone); + } + + /** @return array{timezone: string} */ + public function __serialize(): array + { + return ['timezone' => $this->timezone]; + } + + /** @param array $data */ + public function __unserialize(array $data): void + { + if (1 !== count($data) || !isset($data['timezone']) || !is_string($data['timezone'])) { + throw InvalidTimezoneException::invalidIdentifier(); + } + + $validated = TimezoneId::fromString($data['timezone']); + $this->timezone = $validated->value(); + } +} diff --git a/src/Bridge/Messenger/WorkerTimezoneMiddleware.php b/src/Bridge/Messenger/WorkerTimezoneMiddleware.php new file mode 100644 index 0000000..85f9349 --- /dev/null +++ b/src/Bridge/Messenger/WorkerTimezoneMiddleware.php @@ -0,0 +1,37 @@ +last(TimezoneStamp::class); + $timezone = $stamp instanceof TimezoneStamp ? $stamp->toTimezoneId() : $this->provider->getTimezone(); + + $result = $this->context->run( + $timezone, + static fn (): Envelope => $stack->next()->handle($envelope, $stack), + ); + + if (!$result instanceof Envelope) { + throw new \UnexpectedValueException('The timezone execution context must return the middleware envelope.'); + } + + return $result; + } +} diff --git a/src/Bridge/Twig/TwigTimezoneScope.php b/src/Bridge/Twig/TwigTimezoneScope.php new file mode 100644 index 0000000..c6aac73 --- /dev/null +++ b/src/Bridge/Twig/TwigTimezoneScope.php @@ -0,0 +1,56 @@ + */ + private array $stack = []; + + public function __construct(private readonly CoreExtension $coreExtension) + { + } + + public function enter(TimezoneId $timezone): void + { + $this->stack[] = $this->coreExtension->getTimezone(); + $this->coreExtension->setTimezone($timezone->value()); + } + + public function leave(): void + { + if ([] === $this->stack) { + return; + } + + $this->coreExtension->setTimezone(array_pop($this->stack)); + } + + public function run(TimezoneId $timezone, callable $callback): mixed + { + $this->enter($timezone); + + try { + return $callback(); + } finally { + $this->leave(); + } + } + + public function reset(): void + { + if ([] === $this->stack) { + return; + } + + $timezone = $this->stack[0]; + $this->stack = []; + $this->coreExtension->setTimezone($timezone); + } +} diff --git a/src/Bridge/Twig/TwigTimezoneSubscriber.php b/src/Bridge/Twig/TwigTimezoneSubscriber.php new file mode 100644 index 0000000..1a7b117 --- /dev/null +++ b/src/Bridge/Twig/TwigTimezoneSubscriber.php @@ -0,0 +1,44 @@ + 'onTimezoneResolved', + KernelEvents::FINISH_REQUEST => 'onKernelFinishRequest', + KernelEvents::TERMINATE => 'onKernelTerminate', + ]; + } + + public function onTimezoneResolved(TimezoneResolvedEvent $event): void + { + $this->scope->enter($event->resolution->timezone); + } + + public function onKernelFinishRequest(FinishRequestEvent $event): void + { + if ($event->isMainRequest()) { + $this->scope->leave(); + } + } + + public function onKernelTerminate(TerminateEvent $event): void + { + $this->scope->reset(); + } +} diff --git a/src/Bridge/WebProfiler/TimezoneDataCollector.php b/src/Bridge/WebProfiler/TimezoneDataCollector.php new file mode 100644 index 0000000..b15d06a --- /dev/null +++ b/src/Bridge/WebProfiler/TimezoneDataCollector.php @@ -0,0 +1,121 @@ + $configuredResolvers + */ + public function __construct(private readonly string $configuredDefault, private readonly array $configuredResolvers) + { + $this->reset(); + } + + public function collect(Request $request, Response $response, ?\Throwable $exception = null): void + { + $trace = TimezoneResolutionTrace::fromRequest($request); + $selected = $trace?->selected; + $read = $request->attributes->get(StoredPreferenceTimezoneResolver::READ_ATTRIBUTE); + + $attempts = []; + foreach (null === $trace ? [] : $trace->attempts as $attempt) { + $attempts[] = [ + 'resolver' => $attempt->resolver, + 'outcome' => $attempt->outcome->value, + 'source' => $attempt->source, + 'duration_microseconds' => $attempt->durationMicroseconds, + ]; + } + + $this->data = [ + 'effective_timezone' => $selected?->timezone->value(), + 'effective_source' => $selected?->source, + 'effective_kind' => $selected?->kind->value, + 'configured_default' => $this->configuredDefault, + 'configured_resolvers' => $this->configuredResolvers, + 'attempts' => $attempts, + 'preference_read' => $read instanceof TimezonePreferenceRead, + 'preference_read_status' => $read instanceof TimezonePreferenceRead ? $read->status->value : null, + 'preference_written' => true === $request->attributes->get(BrowserTimezoneController::PREFERENCE_WRITTEN_ATTRIBUTE), + 'preference_cleared' => true === $request->attributes->get(InvalidPreferenceCleanupListener::PREFERENCE_CLEARED_ATTRIBUTE), + ]; + } + + public function reset(): void + { + $this->data = [ + 'effective_timezone' => null, + 'effective_source' => null, + 'effective_kind' => null, + 'configured_default' => $this->configuredDefault, + 'configured_resolvers' => $this->configuredResolvers, + 'attempts' => [], + 'preference_read' => false, + 'preference_read_status' => null, + 'preference_written' => false, + 'preference_cleared' => false, + ]; + } + + public function getName(): string + { + return 'lunetics_timezone'; + } + + /** @return array{effective_timezone: ?string, effective_source: ?string, effective_kind: ?string, configured_default: string, configured_resolvers: list, attempts: list, preference_read: bool, preference_read_status: ?string, preference_written: bool, preference_cleared: bool} */ + public function getDiagnostics(): array + { + if (!is_array($this->data) || !self::isDiagnostics($this->data)) { + throw new \LogicException('Invalid timezone profiler diagnostics data.'); + } + + return $this->data; + } + + /** + * @param array $data + * @phpstan-assert-if-true array{effective_timezone: ?string, effective_source: ?string, effective_kind: ?string, configured_default: string, configured_resolvers: list, attempts: list, preference_read: bool, preference_read_status: ?string, preference_written: bool, preference_cleared: bool} $data + */ + private static function isDiagnostics(array $data): bool + { + if (array_keys($data) !== ['effective_timezone', 'effective_source', 'effective_kind', 'configured_default', 'configured_resolvers', 'attempts', 'preference_read', 'preference_read_status', 'preference_written', 'preference_cleared'] + || (null !== $data['effective_timezone'] && !is_string($data['effective_timezone'])) + || (null !== $data['effective_source'] && !is_string($data['effective_source'])) + || (null !== $data['effective_kind'] && !is_string($data['effective_kind'])) + || !is_string($data['configured_default']) + || !is_array($data['configured_resolvers']) + || !array_is_list($data['configured_resolvers']) + || !is_array($data['attempts']) + || !array_is_list($data['attempts']) + || !is_bool($data['preference_read']) + || (null !== $data['preference_read_status'] && !is_string($data['preference_read_status'])) + || !is_bool($data['preference_written']) + || !is_bool($data['preference_cleared'])) { + return false; + } + foreach ($data['configured_resolvers'] as $resolver) { + if (!is_array($resolver) || array_keys($resolver) !== ['name', 'priority'] || !is_string($resolver['name']) || !is_int($resolver['priority'])) { + return false; + } + } + foreach ($data['attempts'] as $attempt) { + if (!is_array($attempt) || array_keys($attempt) !== ['resolver', 'outcome', 'source', 'duration_microseconds'] || !is_string($attempt['resolver']) || !is_string($attempt['outcome']) || (null !== $attempt['source'] && !is_string($attempt['source'])) || !is_int($attempt['duration_microseconds'])) { + return false; + } + } + + return true; + } +} diff --git a/src/Clock/SystemClock.php b/src/Clock/SystemClock.php new file mode 100644 index 0000000..42ff4e9 --- /dev/null +++ b/src/Clock/SystemClock.php @@ -0,0 +1,15 @@ +context->current()) { + return new TimezoneResolution($timezone, 'execution_context', ResolutionKind::EXPLICIT); + } + $request = $this->requestStack->getMainRequest(); + return null === $request ? $this->defaultResolution() : $this->getResolutionForRequest($request); + } + + public function getResolutionForRequest(Request $request): TimezoneResolution + { + $resolution = $request->attributes->get(self::RESOLUTION_ATTRIBUTE); + return $resolution instanceof TimezoneResolution ? $resolution : $this->defaultResolution(); + } + + public function getTimezone(): TimezoneId + { + return $this->getResolution()->timezone; + } + + public function getDateTimeZone(): \DateTimeZone + { + return $this->getTimezone()->toDateTimeZone(); + } + + private function defaultResolution(): TimezoneResolution + { + return new TimezoneResolution($this->defaultTimezone, 'default', ResolutionKind::DEFAULT); + } +} diff --git a/src/Context/CurrentTimezoneProviderInterface.php b/src/Context/CurrentTimezoneProviderInterface.php new file mode 100644 index 0000000..69db726 --- /dev/null +++ b/src/Context/CurrentTimezoneProviderInterface.php @@ -0,0 +1,17 @@ + */ + private array $stack = []; + + public function run(TimezoneId $timezone, callable $callback): mixed + { + $this->stack[] = $timezone; + try { + return $callback(); + } finally { + array_pop($this->stack); + } + } + + public function current(): ?TimezoneId + { + return $this->stack[array_key_last($this->stack)] ?? null; + } + + public function reset(): void + { + $this->stack = []; + } +} diff --git a/src/Context/TimezoneExecutionContextInterface.php b/src/Context/TimezoneExecutionContextInterface.php new file mode 100644 index 0000000..57ee373 --- /dev/null +++ b/src/Context/TimezoneExecutionContextInterface.php @@ -0,0 +1,12 @@ + + */ + public function claimsForRequest(Request $request): array; +} diff --git a/src/Contract/User/TimezoneAwareUserInterface.php b/src/Contract/User/TimezoneAwareUserInterface.php new file mode 100644 index 0000000..a824b07 --- /dev/null +++ b/src/Contract/User/TimezoneAwareUserInterface.php @@ -0,0 +1,12 @@ +isJsonContentType($request->headers->get('Content-Type'))) { + return new Response('', Response::HTTP_UNSUPPORTED_MEDIA_TYPE); + } + $content = $request->getContent(); + if (strlen($content) > 1024) { + return new Response('', 413); + } + if (null !== $this->csrfTokenManager && !$this->csrfTokenManager->isTokenValid(new CsrfToken($this->csrfTokenId, (string) $request->headers->get($this->csrfHeader, '')))) { + return new Response('', Response::HTTP_FORBIDDEN); + } + try { + $data = json_decode($content, true, 8, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return new Response('', Response::HTTP_BAD_REQUEST); + } + if (!is_array($data) || array_is_list($data) || array_keys($data) !== ['timezone'] || !is_string($data['timezone'])) { + return new Response('', Response::HTTP_BAD_REQUEST); + } + try { + $timezone = TimezoneId::fromString($data['timezone']); + } catch (InvalidTimezoneException) { + return new Response('', Response::HTTP_UNPROCESSABLE_ENTITY); + } + $response = new Response('', Response::HTTP_NO_CONTENT); + try { + $read = $this->storage->read($request); + $previous = PreferenceReadStatus::VALID === $read->status ? $read->preference : null; + if (PreferenceSource::MANUAL === $previous?->source) { + return $response; + } + $current = new TimezonePreference($timezone, PreferenceSource::BROWSER, \DateTimeImmutable::createFromInterface($this->clock->now())); + if (null !== $previous && $previous->equals($current)) { + return $response; + } + $this->storage->write($request, $response, $current); + $request->attributes->set(self::PREFERENCE_WRITTEN_ATTRIBUTE, true); + } catch (PersistenceFailureExceptionInterface) { + return new Response('', Response::HTTP_SERVICE_UNAVAILABLE); + } + $this->dispatcher->dispatch(new TimezonePreferenceChangedEvent($request, $previous, $current)); + return $response; + } + + private function isJsonContentType(?string $contentType): bool + { + if (null === $contentType) { + return false; + } + $mediaType = strtolower(trim(explode(';', $contentType, 2)[0])); + return 'application/json' === $mediaType || (str_starts_with($mediaType, 'application/') && str_ends_with($mediaType, '+json')); + } +} diff --git a/src/DependencyInjection/Compiler/TimezoneCompilerPass.php b/src/DependencyInjection/Compiler/TimezoneCompilerPass.php new file mode 100644 index 0000000..885d50c --- /dev/null +++ b/src/DependencyInjection/Compiler/TimezoneCompilerPass.php @@ -0,0 +1,184 @@ +setAlias(self::CLOCK_SERVICE, $container->has(ClockInterface::class) ? ClockInterface::class : SystemClock::class); + $this->configureOptionalIntegrations($container); + + $indices = []; + foreach ($container->findTaggedServiceIds(self::RESOLVER_TAG) as $id => $tags) { + foreach ($tags as $attributes) { + if (!is_array($attributes)) { + throw new InvalidArgumentException(sprintf('Timezone resolver "%s" has invalid tag metadata.', $id)); + } + $index = $attributes['index'] ?? null; + if (!is_string($index) || '' === $index) { + throw new InvalidArgumentException(sprintf('Timezone resolver "%s" must declare a non-empty string "index" on every "%s" tag.', $id, self::RESOLVER_TAG)); + } + if (array_key_exists('priority', $attributes) && !is_int($attributes['priority'])) { + throw new InvalidArgumentException(sprintf('Timezone resolver "%s" must declare an integer "priority" when it is present on the "%s" tag.', $id, self::RESOLVER_TAG)); + } + if (isset($indices[$index])) { + throw new InvalidArgumentException(sprintf('Timezone resolver tag index "%s" is registered more than once by "%s" and "%s".', $index, $indices[$index], $id)); + } + $indices[$index] = $id; + } + } + + if (!$container->hasAlias(TimezonePreferenceStorageInterface::class)) { + return; + } + $id = $this->resolveServiceId($container, TimezonePreferenceStorageInterface::class, 'timezone preference storage'); + if (!$container->hasDefinition($id)) { + return; + } + $class = $container->getDefinition($id)->getClass(); + if (null !== $class && !is_a($class, TimezonePreferenceStorageInterface::class, true)) { + throw new InvalidArgumentException(sprintf('Configured timezone preference storage service "%s" must implement %s.', $id, TimezonePreferenceStorageInterface::class)); + } + } + + private function configureOptionalIntegrations(ContainerBuilder $container): void + { + /** @var array{enabled: 'auto'|bool, accessor: ?string, priority: int} $user */ + $user = $container->getParameter('lunetics_timezone.resolution.user'); + if (null !== $user['accessor']) { + $this->validateServiceContract($container, $user['accessor'], UserTimezoneAccessorInterface::class, 'user timezone accessor'); + } + if ($container->hasDefinition(OidcTimezoneResolver::class)) { + $oidcService = $container->getDefinition(OidcTimezoneResolver::class)->getArgument(0); + if ($oidcService instanceof Reference) { + $this->validateServiceContract($container, (string) $oidcService, OidcClaimsProviderInterface::class, 'OIDC claims provider'); + } + } + if ($container->hasAlias(MaxMindCityReaderInterface::class)) { + $this->validateServiceContract($container, MaxMindCityReaderInterface::class, MaxMindCityReaderInterface::class, 'MaxMind city reader'); + } + $tokenStorageAvailable = $container->has('security.token_storage'); + if (true === $user['enabled'] && !interface_exists('Symfony\\Component\\Security\\Core\\Authentication\\Token\\Storage\\TokenStorageInterface')) { + throw new \LogicException('User timezone resolution requires symfony/security-core. Install it or set resolution.user.enabled=false.'); + } + if (true === $user['enabled'] && !$tokenStorageAvailable) { + throw new \LogicException('User timezone resolution requires the "security.token_storage" service or alias. Enable Symfony Security or set resolution.user.enabled=false.'); + } + if (true === $user['enabled'] || ('auto' === $user['enabled'] && $tokenStorageAvailable)) { + $container->setDefinition(UserTimezoneResolver::class, (new Definition(UserTimezoneResolver::class, [ + new Reference('security.token_storage'), + null === $user['accessor'] ? null : new Reference($user['accessor']), + ]))->addTag(self::RESOLVER_TAG, ['priority' => $user['priority'], 'index' => 'user'])); + } + + $twig = $container->getParameter('lunetics_timezone.integrations.twig'); + $twigExtensionAvailable = $container->hasExtension('twig'); + if (true === $twig && (!class_exists('Twig\\Environment') || !$twigExtensionAvailable || !$container->has('twig'))) { + throw new \LogicException('Twig timezone integration requires twig/twig, symfony/twig-bundle, the Twig container extension, and the "twig" service.'); + } + if (true === $twig || ('auto' === $twig && $twigExtensionAvailable)) { + $container->setDefinition('lunetics_timezone.twig.core_extension', (new Definition('Twig\\Extension\\CoreExtension')) + ->setFactory([new Reference('twig'), 'getExtension'])->setArguments(['Twig\\Extension\\CoreExtension'])); + $container->setDefinition(TwigTimezoneScope::class, (new Definition(TwigTimezoneScope::class, [new Reference('lunetics_timezone.twig.core_extension')])) + ->addTag('kernel.reset', ['method' => 'reset'])); + $container->setDefinition(TwigTimezoneSubscriber::class, (new Definition(TwigTimezoneSubscriber::class, [new Reference(TwigTimezoneScope::class)])) + ->addTag('kernel.event_subscriber')); + } + + $profiler = $container->getParameter('lunetics_timezone.integrations.profiler'); + $profilerExtensionAvailable = $container->hasExtension('web_profiler'); + if (true === $profiler && (!class_exists('Symfony\\Component\\HttpKernel\\DataCollector\\DataCollector') || !$profilerExtensionAvailable)) { + throw new \LogicException('Profiler timezone integration requires symfony/web-profiler-bundle and its container extension.'); + } + $profilerEnabled = true === $profiler || ('auto' === $profiler && $profilerExtensionAvailable && true === $container->getParameter('kernel.debug')); + + if (true === $container->getParameter('lunetics_timezone.browser.csrf_enabled') + && (!interface_exists('Symfony\\Component\\Security\\Csrf\\CsrfTokenManagerInterface') || !$container->has('security.csrf.token_manager'))) { + throw new \LogicException('Browser timezone CSRF protection requires symfony/security-csrf and the "security.csrf.token_manager" service. Install and enable it or explicitly disable browser.csrf.'); + } + + /** @var list $catalog */ + $catalog = []; + $order = 0; + foreach ($container->findTaggedServiceIds(self::RESOLVER_TAG) as $id => $tags) { + foreach ($tags as $attributes) { + if (!is_array($attributes)) { + continue; + } + if (isset($attributes['index']) && is_string($attributes['index'])) { + $priority = $attributes['priority'] ?? 0; + if (!is_int($priority)) { + throw new InvalidArgumentException(sprintf('Timezone resolver "%s" must declare an integer "priority" when it is present on the "%s" tag.', $id, self::RESOLVER_TAG)); + } + $catalog[] = ['name' => $attributes['index'], 'priority' => $priority, 'order' => $order++]; + } + } + } + usort($catalog, static fn (array $left, array $right): int => [$right['priority'], $left['order']] <=> [$left['priority'], $right['order']]); + $resolverCatalog = array_map(static fn (array $resolver): array => ['name' => $resolver['name'], 'priority' => $resolver['priority']], $catalog); + if ($container->hasDefinition(DebugTimezoneCommand::class)) { + $container->getDefinition(DebugTimezoneCommand::class)->replaceArgument(1, $resolverCatalog); + } + if ($profilerEnabled) { + $configuredDefault = $container->getParameter('lunetics_timezone.default_timezone_value'); + $container->setDefinition(TimezoneDataCollector::class, (new Definition(TimezoneDataCollector::class, [$configuredDefault, $resolverCatalog])) + ->addTag('data_collector', ['template' => '@LuneticsTimezone/Collector/timezone.html.twig', 'id' => 'lunetics_timezone'])); + } + } + + private function validateServiceContract(ContainerBuilder $container, string $serviceId, string $contract, string $description): void + { + $resolvedId = $this->resolveServiceId($container, $serviceId, $description); + if (!$container->hasDefinition($resolvedId)) { + return; + } + + $class = $container->getDefinition($resolvedId)->getClass(); + if (null !== $class && !is_a($class, $contract, true)) { + throw new InvalidArgumentException(sprintf('Configured %s service "%s" (resolved to "%s") must implement %s.', $description, $serviceId, $resolvedId, $contract)); + } + } + + private function resolveServiceId(ContainerBuilder $container, string $serviceId, string $description): string + { + $resolvedId = $serviceId; + $path = []; + $seen = []; + while ($container->hasAlias($resolvedId)) { + if (isset($seen[$resolvedId])) { + $cycle = array_slice($path, $seen[$resolvedId]); + $cycle[] = $resolvedId; + throw new InvalidArgumentException(sprintf('Alias cycle detected while resolving configured %s service "%s": %s.', $description, $serviceId, implode(' -> ', $cycle))); + } + $seen[$resolvedId] = count($path); + $path[] = $resolvedId; + $resolvedId = (string) $container->getAlias($resolvedId); + } + + return $resolvedId; + } +} diff --git a/src/Event/TimezonePreferenceChangedEvent.php b/src/Event/TimezonePreferenceChangedEvent.php new file mode 100644 index 0000000..5aa685e --- /dev/null +++ b/src/Event/TimezonePreferenceChangedEvent.php @@ -0,0 +1,18 @@ + 'onKernelResponse']; + } + + public function onKernelResponse(ResponseEvent $event): void + { + if (!$event->isMainRequest()) { + return; + } + + $request = $event->getRequest(); + if (true === $request->attributes->get(BrowserTimezoneController::PREFERENCE_WRITTEN_ATTRIBUTE)) { + return; + } + + $read = $request->attributes->get(StoredPreferenceTimezoneResolver::READ_ATTRIBUTE); + if (!$read instanceof \Lunetics\TimezoneBundle\Storage\TimezonePreferenceRead || !in_array($read->status, [PreferenceReadStatus::INVALID, PreferenceReadStatus::EXPIRED], true)) { + return; + } + try { + $this->storage->clear($request, $event->getResponse()); + $request->attributes->set(self::PREFERENCE_CLEARED_ATTRIBUTE, true); + } catch (PersistenceFailureExceptionInterface $failure) { + if (PersistenceFailureStrategy::THROW === $this->failureStrategy) { + throw $failure; + } + } + } +} diff --git a/src/EventListener/ResolveTimezoneListener.php b/src/EventListener/ResolveTimezoneListener.php new file mode 100644 index 0000000..d487d24 --- /dev/null +++ b/src/EventListener/ResolveTimezoneListener.php @@ -0,0 +1,36 @@ + ['onKernelRequest', 1]]; + } + + public function onKernelRequest(RequestEvent $event): void + { + if (!$event->isMainRequest()) { + return; + } + $request = $event->getRequest(); + $resolution = $this->resolver->resolve($request); + $request->attributes->set(CurrentTimezoneProvider::RESOLUTION_ATTRIBUTE, $resolution); + $this->dispatcher->dispatch(new TimezoneResolvedEvent($request, $resolution)); + } +} diff --git a/src/Exception/InvalidTimezoneException.php b/src/Exception/InvalidTimezoneException.php new file mode 100644 index 0000000..2c98412 --- /dev/null +++ b/src/Exception/InvalidTimezoneException.php @@ -0,0 +1,18 @@ +, priority: int}, + * user: array{enabled: Toggle, accessor: ?string, priority: int}, + * oidc: array{enabled: bool, service: ?string, claim: string, priority: int}, + * maxmind: array{enabled: bool, database: ?string, reader: ?string, priority: int}, + * locale_mapping: array{enabled: Toggle, mapping: array, priority: int}, + * locale: array{enabled: bool, priority: int} + * }, + * persistence: array{ + * storage: string, + * failure_strategy: 'continue'|'throw', + * session: array{key: string}, + * cookie: array{name: string, max_age: int, path: string, domain: ?string, secure: 'auto'|bool, http_only: bool, same_site: 'lax'|'strict'|'none', secret: ?string, future_skew: int, max_size: int} + * }, + * browser: array{enabled: bool, csrf: array{enabled: bool, token_id: string, header: string}}, + * integrations: array{twig: Toggle, form: bool, messenger: bool, profiler: Toggle} + * } + */ +final class LuneticsTimezoneBundle extends AbstractBundle +{ + public function getPath(): string + { + return dirname(__DIR__); + } + + public function configure(DefinitionConfigurator $definition): void + { + /** @var ArrayNodeDefinition $rootNode */ + $rootNode = $definition->rootNode(); + $rootNode + ->children() + ->scalarNode('default_timezone')->defaultValue('UTC')->cannotBeEmpty()->end() + ->arrayNode('resolution')->addDefaultsIfNotSet()->children() + ->enumNode('failure_strategy')->values(['continue', 'throw'])->defaultValue('continue')->end() + ->arrayNode('request_attribute')->addDefaultsIfNotSet()->children() + ->booleanNode('enabled')->defaultTrue()->end() + ->scalarNode('attribute')->defaultValue('_timezone')->cannotBeEmpty()->end() + ->integerNode('priority')->defaultValue(1000)->end() + ->end()->end() + ->arrayNode('header')->addDefaultsIfNotSet()->children() + ->booleanNode('enabled')->defaultFalse()->end() + ->scalarNode('name')->defaultValue('X-Timezone')->cannotBeEmpty()->end() + ->enumNode('trust')->values(['framework', 'allowlist', 'any'])->defaultValue('framework')->end() + ->arrayNode('trusted_sources')->scalarPrototype()->cannotBeEmpty()->end()->defaultValue([])->end() + ->integerNode('priority')->defaultValue(950)->end() + ->end()->end() + ->arrayNode('user')->addDefaultsIfNotSet()->children() + ->enumNode('enabled')->values(['auto', true, false])->defaultValue('auto')->end() + ->scalarNode('accessor')->defaultNull()->end() + ->integerNode('priority')->defaultValue(900)->end() + ->end()->end() + ->arrayNode('oidc')->addDefaultsIfNotSet()->children() + ->booleanNode('enabled')->defaultFalse()->end() + ->scalarNode('service')->defaultNull()->end() + ->scalarNode('claim')->defaultValue('zoneinfo')->cannotBeEmpty()->end() + ->integerNode('priority')->defaultValue(850)->end() + ->end()->end() + ->arrayNode('maxmind')->addDefaultsIfNotSet()->children() + ->booleanNode('enabled')->defaultFalse()->end() + ->scalarNode('database')->defaultNull()->end() + ->scalarNode('reader')->defaultNull()->end() + ->integerNode('priority')->defaultValue(400)->end() + ->end()->end() + ->arrayNode('locale_mapping')->addDefaultsIfNotSet()->children() + ->enumNode('enabled')->values(['auto', true, false])->defaultValue('auto')->end() + ->arrayNode('mapping')->useAttributeAsKey('locale')->scalarPrototype()->cannotBeEmpty()->end()->defaultValue([])->end() + ->integerNode('priority')->defaultValue(200)->end() + ->end()->end() + ->arrayNode('locale')->addDefaultsIfNotSet()->children() + ->booleanNode('enabled')->defaultTrue()->end() + ->integerNode('priority')->defaultValue(100)->end() + ->end()->end() + ->end()->end() + ->arrayNode('persistence')->addDefaultsIfNotSet()->children() + ->scalarNode('storage')->defaultValue('session')->cannotBeEmpty()->end() + ->enumNode('failure_strategy')->values(['continue', 'throw'])->defaultValue('continue')->end() + ->arrayNode('session')->addDefaultsIfNotSet()->children() + ->scalarNode('key')->defaultValue('_lunetics_timezone')->cannotBeEmpty()->end() + ->end()->end() + ->arrayNode('cookie')->addDefaultsIfNotSet()->children() + ->scalarNode('name')->defaultValue('_lunetics_timezone')->cannotBeEmpty()->end() + ->integerNode('max_age')->min(1)->defaultValue(31536000)->end() + ->scalarNode('path')->defaultValue('/')->cannotBeEmpty()->end() + ->scalarNode('domain')->defaultNull()->end() + ->enumNode('secure')->values(['auto', true, false])->defaultValue('auto')->end() + ->booleanNode('http_only')->defaultTrue()->end() + ->enumNode('same_site')->values(['lax', 'strict', 'none'])->defaultValue('lax')->end() + ->scalarNode('secret')->defaultNull()->end() + ->integerNode('future_skew')->min(0)->defaultValue(60)->end() + ->integerNode('max_size')->min(1)->max(4096)->defaultValue(4096)->end() + ->end()->end() + ->end()->end() + ->arrayNode('browser')->addDefaultsIfNotSet()->children() + ->booleanNode('enabled')->defaultFalse()->end() + ->arrayNode('csrf')->canBeDisabled()->children() + ->scalarNode('token_id')->defaultValue('lunetics_timezone.preference')->cannotBeEmpty()->end() + ->scalarNode('header')->defaultValue('X-CSRF-Token')->cannotBeEmpty()->end() + ->end()->end() + ->end()->end() + ->arrayNode('integrations')->addDefaultsIfNotSet()->children() + ->enumNode('twig')->values(['auto', true, false])->defaultValue('auto')->end() + ->booleanNode('form')->defaultFalse()->end() + ->booleanNode('messenger')->defaultFalse()->end() + ->enumNode('profiler')->values(['auto', true, false])->defaultValue('auto')->end() + ->end()->end() + ->end(); + } + + /** @param Configuration $config */ + public function loadExtension(array $config, ContainerConfigurator $configurator, ContainerBuilder $container): void + { + self::validateConfig($config); + $services = $configurator->services()->defaults()->autowire()->autoconfigure(); + $services->set(SystemClock::class); + $services->alias(TimezoneCompilerPass::CLOCK_SERVICE, SystemClock::class); + $services->set(TimezoneExecutionContext::class)->tag('kernel.reset', ['method' => 'reset']); + $services->alias(TimezoneExecutionContextInterface::class, TimezoneExecutionContext::class); + $services->set('lunetics_timezone.default_timezone', TimezoneId::class) + ->factory([TimezoneId::class, 'fromString'])->args([$config['default_timezone']]); + $services->set(CurrentTimezoneProvider::class)->arg('$defaultTimezone', service('lunetics_timezone.default_timezone')); + $services->alias(CurrentTimezoneProviderInterface::class, CurrentTimezoneProvider::class)->public(); + + $storage = $config['persistence']['storage']; + if ('session' === $storage) { + $services->set(SessionTimezoneStorage::class)->arg('$key', $config['persistence']['session']['key']); + $services->alias(TimezonePreferenceStorageInterface::class, SessionTimezoneStorage::class); + } elseif ('cookie' === $storage) { + $cookie = $config['persistence']['cookie']; + $services->set(CookieTimezoneStorage::class) + ->args([$cookie['secret'], service(TimezoneCompilerPass::CLOCK_SERVICE)]) + ->arg('$name', $cookie['name'])->arg('$maxAge', $cookie['max_age'])->arg('$path', $cookie['path']) + ->arg('$domain', $cookie['domain'])->arg('$secure', 'auto' === $cookie['secure'] ? null : $cookie['secure']) + ->arg('$httpOnly', $cookie['http_only'])->arg('$sameSite', $cookie['same_site']) + ->arg('$futureSkew', $cookie['future_skew'])->arg('$maxEncodedSize', $cookie['max_size']); + $services->alias(TimezonePreferenceStorageInterface::class, CookieTimezoneStorage::class); + } else { + $services->alias(TimezonePreferenceStorageInterface::class, $storage); + } + + $resolution = $config['resolution']; + $container->setParameter('lunetics_timezone.resolution.user', $resolution['user']); + $container->setParameter('lunetics_timezone.default_timezone_value', $config['default_timezone']); + $container->setParameter('lunetics_timezone.integrations.twig', $config['integrations']['twig']); + $container->setParameter('lunetics_timezone.integrations.profiler', $config['integrations']['profiler']); + $container->setParameter('lunetics_timezone.browser.csrf_enabled', $config['browser']['enabled'] && $config['browser']['csrf']['enabled']); + /** @var list $resolverCatalog */ + $resolverCatalog = []; + $catalogOrder = 0; + $addToCatalog = static function (string $name, int $priority) use (&$resolverCatalog, &$catalogOrder): void { + $resolverCatalog[] = ['name' => $name, 'priority' => $priority, 'order' => $catalogOrder++]; + }; + if ($resolution['request_attribute']['enabled']) { + $services->set(RequestAttributeTimezoneResolver::class)->arg('$attribute', $resolution['request_attribute']['attribute']) + ->tag(TimezoneCompilerPass::RESOLVER_TAG, ['priority' => $resolution['request_attribute']['priority'], 'index' => 'request_attribute']); + $addToCatalog('request_attribute', $resolution['request_attribute']['priority']); + } + if ($resolution['header']['enabled']) { + $services->set(HeaderTimezoneResolver::class)->args([$resolution['header']['name'], HeaderTrustMode::from($resolution['header']['trust']), $resolution['header']['trusted_sources']]) + ->tag(TimezoneCompilerPass::RESOLVER_TAG, ['priority' => $resolution['header']['priority'], 'index' => 'header']); + $addToCatalog('header', $resolution['header']['priority']); + } + $persistenceStrategy = PersistenceFailureStrategy::from($config['persistence']['failure_strategy']); + $services->set('lunetics_timezone.resolver.stored_manual', StoredPreferenceTimezoneResolver::class) + ->args([service(TimezonePreferenceStorageInterface::class), PreferenceSource::MANUAL, $persistenceStrategy]) + ->tag(TimezoneCompilerPass::RESOLVER_TAG, ['priority' => 925, 'index' => 'stored_manual']); + $addToCatalog('stored_manual', 925); + + if ($resolution['oidc']['enabled']) { + $oidcService = $resolution['oidc']['service']; + if (!is_string($oidcService) || '' === trim($oidcService)) { + throw new \LogicException('OIDC timezone resolution requires a non-empty resolution.oidc.service implementing OidcClaimsProviderInterface.'); + } + $services->set(OidcTimezoneResolver::class) + ->args([service($oidcService), $resolution['oidc']['claim']]) + ->tag(TimezoneCompilerPass::RESOLVER_TAG, ['priority' => $resolution['oidc']['priority'], 'index' => 'oidc']); + $addToCatalog('oidc', $resolution['oidc']['priority']); + } + + $services->set('lunetics_timezone.resolver.stored_browser', StoredPreferenceTimezoneResolver::class) + ->args([service(TimezonePreferenceStorageInterface::class), PreferenceSource::BROWSER, $persistenceStrategy]) + ->tag(TimezoneCompilerPass::RESOLVER_TAG, ['priority' => 800, 'index' => 'stored_browser']); + $addToCatalog('stored_browser', 800); + + if ($resolution['maxmind']['enabled']) { + $reader = $resolution['maxmind']['reader']; + if (null !== $resolution['maxmind']['database']) { + if (!class_exists('GeoIp2\\Database\\Reader')) { + throw new \LogicException('MaxMind database resolution requires geoip2/geoip2. Install it or configure resolution.maxmind.reader.'); + } + $services->set(LazyGeoIp2CityReader::class)->arg('$databasePath', $resolution['maxmind']['database']); + $services->alias(MaxMindCityReaderInterface::class, LazyGeoIp2CityReader::class); + if (class_exists('Symfony\\Component\\Console\\Command\\Command')) { + $services->set(MaxMindDatabaseCheckCommand::class)->arg('$databasePath', $resolution['maxmind']['database']); + } + } else { + $services->alias(MaxMindCityReaderInterface::class, (string) $reader); + } + $services->set(MaxMindTimezoneResolver::class) + ->tag(TimezoneCompilerPass::RESOLVER_TAG, ['priority' => $resolution['maxmind']['priority'], 'index' => 'maxmind']); + $addToCatalog('maxmind', $resolution['maxmind']['priority']); + } + $mappingEnabled = true === $resolution['locale_mapping']['enabled'] || ('auto' === $resolution['locale_mapping']['enabled'] && [] !== $resolution['locale_mapping']['mapping']); + if ($mappingEnabled) { + $services->set(LocaleMappingTimezoneResolver::class)->arg('$mapping', $resolution['locale_mapping']['mapping']) + ->tag(TimezoneCompilerPass::RESOLVER_TAG, ['priority' => $resolution['locale_mapping']['priority'], 'index' => 'locale_mapping']); + $addToCatalog('locale_mapping', $resolution['locale_mapping']['priority']); + } + if ($resolution['locale']['enabled']) { + $services->set(PhpCountryTimezoneSource::class); + $services->alias(CountryTimezoneSourceInterface::class, PhpCountryTimezoneSource::class); + $services->set(LocaleTimezoneResolver::class) + ->tag(TimezoneCompilerPass::RESOLVER_TAG, ['priority' => $resolution['locale']['priority'], 'index' => 'locale']); + $addToCatalog('locale', $resolution['locale']['priority']); + } + usort($resolverCatalog, static fn (array $left, array $right): int => [$right['priority'], $left['order']] <=> [$left['priority'], $right['order']]); + $resolverCatalog = array_map(static fn (array $resolver): array => ['name' => $resolver['name'], 'priority' => $resolver['priority']], $resolverCatalog); + $services->set(TimezoneResolverChain::class)->args([ + new TaggedIteratorArgument(TimezoneCompilerPass::RESOLVER_TAG, 'index', null, true), + service('lunetics_timezone.default_timezone'), + ResolutionFailureStrategy::from($resolution['failure_strategy']), + service('logger')->nullOnInvalid(), + ]); + $services->set(ResolveTimezoneListener::class); + $services->set(InvalidPreferenceCleanupListener::class)->arg('$failureStrategy', $persistenceStrategy); + + if (class_exists('Symfony\\Component\\Console\\Command\\Command')) { + $services->set(DebugTimezoneCommand::class)->args([$config['default_timezone'], $resolverCatalog]); + } + + if ($config['integrations']['form']) { + if (!class_exists('Symfony\\Component\\Form\\AbstractTypeExtension')) { + throw new \LogicException('Form timezone integration requires symfony/form. Install it or set integrations.form=false.'); + } + $services->set(TimezoneTypeExtension::class)->tag('form.type_extension'); + } + + if ($config['integrations']['messenger']) { + if (!interface_exists('Symfony\\Component\\Messenger\\Middleware\\MiddlewareInterface')) { + throw new \LogicException('Messenger timezone integration requires symfony/messenger. Install it or set integrations.messenger=false.'); + } + $services->set(DispatchTimezoneMiddleware::class); + $services->set(WorkerTimezoneMiddleware::class); + $services->alias('lunetics_timezone.messenger.dispatch_middleware', DispatchTimezoneMiddleware::class)->public(); + $services->alias('lunetics_timezone.messenger.worker_middleware', WorkerTimezoneMiddleware::class)->public(); + } + + if ($config['browser']['enabled']) { + $csrfEnabled = $config['browser']['csrf']['enabled']; + $services->set(BrowserTimezoneController::class) + ->public() + ->tag('controller.service_arguments') + ->arg('$clock', service(TimezoneCompilerPass::CLOCK_SERVICE)) + ->arg('$csrfTokenManager', $csrfEnabled ? service('security.csrf.token_manager') : null) + ->arg('$csrfTokenId', $config['browser']['csrf']['token_id']) + ->arg('$csrfHeader', $config['browser']['csrf']['header']); + } + } + + public function build(ContainerBuilder $container): void + { + parent::build($container); + $container->addCompilerPass(new TimezoneCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 10); + } + + /** @param Configuration $config */ + private static function validateConfig(array $config): void + { + TimezoneId::fromString($config['default_timezone']); + $header = $config['resolution']['header']; + if ('allowlist' === $header['trust'] && [] === $header['trusted_sources']) { + throw new \InvalidArgumentException('Header allowlist trust requires at least one trusted_sources entry.'); + } + $mapping = $config['resolution']['locale_mapping']; + $normalizedLocales = []; + foreach ($mapping['mapping'] as $locale => $timezone) { + if ('' === $locale) { + throw new \InvalidArgumentException('Locale mappings require non-empty locale keys.'); + } + $normalizedLocale = str_replace('-', '_', $locale); + if (isset($normalizedLocales[$normalizedLocale])) { + throw new \InvalidArgumentException(sprintf('Locale mapping key "%s" collides after normalization.', $locale)); + } + $normalizedLocales[$normalizedLocale] = true; + TimezoneId::fromString($timezone); + } + if (true === $mapping['enabled'] && [] === $mapping['mapping']) { + throw new \InvalidArgumentException('Locale mapping enabled=true requires a non-empty mapping.'); + } + $maxmind = $config['resolution']['maxmind']; + foreach (['database', 'reader'] as $option) { + self::validateNullableNonBlankString($maxmind[$option], sprintf('MaxMind %s', $option)); + } + if ($maxmind['enabled'] && ((null === $maxmind['database']) === (null === $maxmind['reader']))) { + throw new \InvalidArgumentException('Enabled MaxMind resolution requires exactly one of database or reader.'); + } + $storage = $config['persistence']['storage']; + $cookie = $config['persistence']['cookie']; + Cookie::create($cookie['name'], raw: true); + if ('cookie' === $storage) { + if (!is_string($cookie['secret']) || '' === $cookie['secret']) { + throw new \InvalidArgumentException('Cookie storage requires a non-empty secret.'); + } + if ('none' === $cookie['same_site'] && true !== $cookie['secure']) { + throw new \InvalidArgumentException('SameSite=None requires secure=true.'); + } + if (str_starts_with($cookie['name'], '__Host-') && (true !== $cookie['secure'] || '/' !== $cookie['path'] || null !== $cookie['domain'])) { + throw new \InvalidArgumentException('__Host- cookies require secure=true, path=/, and no domain.'); + } + } + } + + private static function validateNullableNonBlankString(mixed $value, string $description): void + { + if (null !== $value && (!is_string($value) || '' === trim($value))) { + throw new \InvalidArgumentException(sprintf('%s must be null or a non-empty string.', $description)); + } + } +} diff --git a/src/Resolution/PersistenceFailureStrategy.php b/src/Resolution/PersistenceFailureStrategy.php new file mode 100644 index 0000000..8556f44 --- /dev/null +++ b/src/Resolution/PersistenceFailureStrategy.php @@ -0,0 +1,11 @@ + $durationMicroseconds) { + throw new \InvalidArgumentException('The resolver attempt duration cannot be negative.'); + } + } +} diff --git a/src/Resolution/TimezoneResolutionTrace.php b/src/Resolution/TimezoneResolutionTrace.php new file mode 100644 index 0000000..18fe0e5 --- /dev/null +++ b/src/Resolution/TimezoneResolutionTrace.php @@ -0,0 +1,33 @@ + $attempts + */ + public function __construct( + public array $attempts, + public ?TimezoneResolution $selected, + ) { + } + + public function attachTo(Request $request): void + { + $request->attributes->set(self::REQUEST_ATTRIBUTE, $this); + } + + public static function fromRequest(Request $request): ?self + { + $trace = $request->attributes->get(self::REQUEST_ATTRIBUTE); + + return $trace instanceof self ? $trace : null; + } +} diff --git a/src/Resolution/TimezoneResolverChain.php b/src/Resolution/TimezoneResolverChain.php new file mode 100644 index 0000000..dd31e26 --- /dev/null +++ b/src/Resolution/TimezoneResolverChain.php @@ -0,0 +1,155 @@ + */ + private array $resolvers; + + /** + * Resolvers must already be in policy order. String iterable keys are used + * as stable diagnostic identifiers when available. + * + * @param iterable $resolvers + */ + public function __construct( + iterable $resolvers, + private TimezoneId $defaultTimezone, + private ResolutionFailureStrategy $failureStrategy, + private ?LoggerInterface $logger = null, + ) { + $normalized = []; + + foreach ($resolvers as $name => $resolver) { + $normalized[] = [ + 'name' => $this->traceName($name, $resolver), + 'resolver' => $resolver, + ]; + } + + $this->resolvers = $normalized; + } + + /** @throws ResolutionFailureExceptionInterface */ + public function resolve(Request $request): TimezoneResolution + { + $attempts = []; + + foreach ($this->resolvers as $entry) { + $startedAt = hrtime(true); + try { + $resolution = $entry['resolver']->resolve($request); + } catch (ResolutionFailureExceptionInterface $failure) { + $attempt = new TimezoneResolutionAttempt( + $entry['name'], + $failure instanceof InvalidTimezoneException + ? ResolutionAttemptOutcome::INVALID + : ResolutionAttemptOutcome::FAILED, + null, + $this->elapsedMicroseconds($startedAt), + ); + $attempts[] = $attempt; + + if ($failure instanceof InvalidTimezoneException) { + $this->logger?->warning('Timezone resolver returned an invalid identifier.', $this->attemptContext($attempt)); + } else { + $this->logger?->error('Timezone resolver failed.', $this->attemptContext($attempt)); + } + + if (ResolutionFailureStrategy::THROW === $this->failureStrategy) { + (new TimezoneResolutionTrace($attempts, null))->attachTo($request); + + throw $failure; + } + + continue; + } + + if (null === $resolution) { + $attempt = new TimezoneResolutionAttempt( + $entry['name'], + ResolutionAttemptOutcome::NO_RESULT, + null, + $this->elapsedMicroseconds($startedAt), + ); + $attempts[] = $attempt; + $this->logger?->debug('Timezone resolver produced no result.', $this->attemptContext($attempt)); + + continue; + } + + $attempt = new TimezoneResolutionAttempt( + $entry['name'], + ResolutionAttemptOutcome::RESOLVED, + $resolution->source, + $this->elapsedMicroseconds($startedAt), + ); + $attempts[] = $attempt; + $this->logger?->info('Timezone resolver selected a result.', $this->attemptContext($attempt, $resolution)); + (new TimezoneResolutionTrace($attempts, $resolution))->attachTo($request); + + return $resolution; + } + + $resolution = new TimezoneResolution( + $this->defaultTimezone, + 'default', + ResolutionKind::DEFAULT, + ); + $attempt = new TimezoneResolutionAttempt( + 'configured_default', + ResolutionAttemptOutcome::DEFAULTED, + $resolution->source, + ); + $attempts[] = $attempt; + $this->logger?->info('Timezone resolution used the configured default.', $this->attemptContext($attempt, $resolution)); + (new TimezoneResolutionTrace($attempts, $resolution))->attachTo($request); + + return $resolution; + } + + private function elapsedMicroseconds(int $startedAt): int + { + return max(0, intdiv(hrtime(true) - $startedAt, 1_000)); + } + + /** @return array{resolver: string, source?: string, kind?: string, timezone?: string, outcome: string, duration_microseconds: int} */ + private function attemptContext(TimezoneResolutionAttempt $attempt, ?TimezoneResolution $resolution = null): array + { + $context = [ + 'resolver' => $attempt->resolver, + 'outcome' => $attempt->outcome->value, + 'duration_microseconds' => $attempt->durationMicroseconds, + ]; + + if (null !== $resolution) { + $context['source'] = $resolution->source; + $context['kind'] = $resolution->kind->value; + $context['timezone'] = $resolution->timezone->value(); + } + + return $context; + } + + private function traceName(int|string $key, TimezoneResolverInterface $resolver): string + { + if (is_string($key) && '' !== $key && 1 === preg_match('/^[A-Za-z0-9_.:\\\\-]{1,160}$/D', $key)) { + return $key; + } + + $class = $resolver::class; + $anonymousMarker = strpos($class, '@anonymous'); + + return false === $anonymousMarker ? $class : substr($class, 0, $anonymousMarker + 10); + } +} diff --git a/src/Resolver/CallableTimezoneResolver.php b/src/Resolver/CallableTimezoneResolver.php new file mode 100644 index 0000000..57a4df8 --- /dev/null +++ b/src/Resolver/CallableTimezoneResolver.php @@ -0,0 +1,55 @@ +resolver = $resolver(...); + } + + public function resolve(Request $request): ?TimezoneResolution + { + $value = ($this->resolver)($request); + + if (null === $value || $value instanceof TimezoneResolution) { + return $value; + } + + if (is_string($value)) { + $value = TimezoneId::fromString($value); + } + + if (!$value instanceof TimezoneId) { + throw TimezoneResolverException::invalidResultType(); + } + + return new TimezoneResolution($value, $this->source, $this->kind); + } +} diff --git a/src/Resolver/CountryTimezoneSourceInterface.php b/src/Resolver/CountryTimezoneSourceInterface.php new file mode 100644 index 0000000..0c5130d --- /dev/null +++ b/src/Resolver/CountryTimezoneSourceInterface.php @@ -0,0 +1,13 @@ + */ + public function forCountry(string $countryCode): array; +} diff --git a/src/Resolver/HeaderTimezoneResolver.php b/src/Resolver/HeaderTimezoneResolver.php new file mode 100644 index 0000000..f66845a --- /dev/null +++ b/src/Resolver/HeaderTimezoneResolver.php @@ -0,0 +1,84 @@ + $trustedSources */ + public function __construct( + private string $headerName = 'X-Timezone', + private HeaderTrustMode $trustMode = HeaderTrustMode::FRAMEWORK, + private array $trustedSources = [], + private int $maximumLength = 255, + ) { + if ('' === $headerName) { + throw new \InvalidArgumentException('The timezone header name cannot be empty.'); + } + + if (HeaderTrustMode::ALLOWLIST === $trustMode && [] === $trustedSources) { + throw new \InvalidArgumentException('Header allowlist trust requires at least one source address or CIDR.'); + } + + if ($maximumLength < 1 || $maximumLength > 4096) { + throw new \InvalidArgumentException('The timezone header maximum length is outside the supported range.'); + } + } + + public function resolve(Request $request): ?TimezoneResolution + { + if (!$this->isTrusted($request)) { + return null; + } + + $values = $request->headers->all($this->headerName); + if ([] === $values) { + return null; + } + + if (1 !== count($values)) { + throw InvalidTimezoneException::invalidIdentifier(); + } + + if (!is_string($values[0])) { + throw InvalidTimezoneException::invalidIdentifier(); + } + + $value = trim($values[0]); + if ('' === $value || strlen($value) > $this->maximumLength || str_contains($value, ',')) { + throw InvalidTimezoneException::invalidIdentifier(); + } + + return new TimezoneResolution( + TimezoneId::fromString($value), + 'trusted_header', + ResolutionKind::EXPLICIT, + ); + } + + private function isTrusted(Request $request): bool + { + return match ($this->trustMode) { + HeaderTrustMode::FRAMEWORK => $request->isFromTrustedProxy(), + HeaderTrustMode::ALLOWLIST => $this->isFromAllowlist($request), + HeaderTrustMode::ANY => true, + }; + } + + private function isFromAllowlist(Request $request): bool + { + $remoteAddress = $request->server->get('REMOTE_ADDR'); + + return is_string($remoteAddress) + && '' !== $remoteAddress + && IpUtils::checkIp($remoteAddress, $this->trustedSources); + } +} diff --git a/src/Resolver/HeaderTrustMode.php b/src/Resolver/HeaderTrustMode.php new file mode 100644 index 0000000..a99529b --- /dev/null +++ b/src/Resolver/HeaderTrustMode.php @@ -0,0 +1,12 @@ + */ + private array $mapping; + + /** @param array $mapping */ + public function __construct(array $mapping) + { + $validatedMapping = []; + + foreach ($mapping as $locale => $timezone) { + if ('' === $locale || (!is_string($timezone) && !$timezone instanceof TimezoneId)) { + throw new \InvalidArgumentException('Locale mappings require non-empty locale keys and timezone identifiers.'); + } + + $normalizedLocale = self::normalizeLocale($locale); + if (isset($validatedMapping[$normalizedLocale])) { + throw new \InvalidArgumentException(sprintf('Locale mapping key "%s" collides after normalization.', $locale)); + } + + $validatedMapping[$normalizedLocale] = is_string($timezone) ? TimezoneId::fromString($timezone) : $timezone; + } + + $this->mapping = $validatedMapping; + } + + public function resolve(Request $request): ?TimezoneResolution + { + $timezone = $this->mapping[self::normalizeLocale($request->getLocale())] ?? null; + + return null === $timezone + ? null + : new TimezoneResolution($timezone, 'locale_mapping', ResolutionKind::INFERRED); + } + + private static function normalizeLocale(string $locale): string + { + return str_replace('-', '_', $locale); + } +} diff --git a/src/Resolver/LocaleTimezoneResolver.php b/src/Resolver/LocaleTimezoneResolver.php new file mode 100644 index 0000000..f1a8d5d --- /dev/null +++ b/src/Resolver/LocaleTimezoneResolver.php @@ -0,0 +1,60 @@ +getLocale()); + if (null === $country) { + return null; + } + + $timezones = $this->timezoneSource->forCountry($country); + if (1 !== count($timezones)) { + return null; + } + + return new TimezoneResolution($timezones[0], 'locale_country', ResolutionKind::INFERRED); + } + + private static function countryFromLocale(string $locale): ?string + { + $normalized = str_replace('-', '_', $locale); + + if (extension_loaded('intl') && class_exists(\Locale::class)) { + $region = \Locale::getRegion($normalized); + if (is_string($region) && 1 === preg_match('/^[A-Z]{2}$/D', $region)) { + return $region; + } + } + + $parts = preg_split('/[_-]/', $locale); + if (false === $parts || count($parts) < 2 || 1 !== preg_match('/^[A-Za-z]{2,3}$/D', $parts[0])) { + return null; + } + + foreach (array_slice($parts, 1) as $part) { + if (1 === preg_match('/^[A-Za-z]{2}$/D', $part)) { + return strtoupper($part); + } + + if (1 !== preg_match('/^[A-Za-z]{4}$/D', $part)) { + break; + } + } + + return null; + } +} diff --git a/src/Resolver/MaxMindTimezoneResolver.php b/src/Resolver/MaxMindTimezoneResolver.php new file mode 100644 index 0000000..bb36fda --- /dev/null +++ b/src/Resolver/MaxMindTimezoneResolver.php @@ -0,0 +1,61 @@ +getClientIp(); + if (null === $ipAddress || false === filter_var( + $ipAddress, + FILTER_VALIDATE_IP, + FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE, + ) || IpUtils::checkIp($ipAddress, self::explicitlyNonPublicRanges())) { + return null; + } + + try { + $timezone = $this->reader->timezoneForIp($ipAddress); + } catch (\Exception $exception) { + throw TimezoneResolverException::maxMindLookupFailed($exception); + } + if (null === $timezone) { + return null; + } + + return new TimezoneResolution( + is_string($timezone) ? TimezoneId::fromString($timezone) : $timezone, + 'maxmind_city', + ResolutionKind::INFERRED, + ); + } + + /** @return list */ + private static function explicitlyNonPublicRanges(): array + { + // PHP's reserved-range filter does not consistently reject multicast + // and documentation networks across supported PHP releases. + return [ + '0.0.0.0/8', '100.64.0.0/10', '192.0.0.0/24', + '192.0.2.0/24', '198.18.0.0/15', '198.51.100.0/24', + '203.0.113.0/24', '224.0.0.0/4', '240.0.0.0/4', + '::/128', '::ffff:0:0/96', '64:ff9b:1::/48', '100::/64', + '2001:db8::/32', 'fc00::/7', 'fe80::/10', 'ff00::/8', + ]; + } +} diff --git a/src/Resolver/OidcTimezoneResolver.php b/src/Resolver/OidcTimezoneResolver.php new file mode 100644 index 0000000..be3263c --- /dev/null +++ b/src/Resolver/OidcTimezoneResolver.php @@ -0,0 +1,53 @@ +claimsProvider->claimsForRequest($request); + } catch (\Exception $exception) { + throw TimezoneResolverException::oidcProviderFailed($exception); + } + + $timezone = $claims[$this->claim] ?? null; + if (!is_string($timezone)) { + return null; + } + + return new TimezoneResolution( + TimezoneId::fromString($timezone), + $this->traceSource(), + ResolutionKind::AUTHENTICATED, + ); + } + + private function traceSource(): string + { + if ('zoneinfo' === $this->claim) { + return 'oidc_zoneinfo'; + } + + return 'oidc_claim_'.substr(hash('sha256', $this->claim), 0, 16); + } +} diff --git a/src/Resolver/PhpCountryTimezoneSource.php b/src/Resolver/PhpCountryTimezoneSource.php new file mode 100644 index 0000000..d7cdaa5 --- /dev/null +++ b/src/Resolver/PhpCountryTimezoneSource.php @@ -0,0 +1,22 @@ + TimezoneId::fromString($identifier), + \DateTimeZone::listIdentifiers(\DateTimeZone::PER_COUNTRY, $countryCode), + ); + } +} diff --git a/src/Resolver/RequestAttributeTimezoneResolver.php b/src/Resolver/RequestAttributeTimezoneResolver.php new file mode 100644 index 0000000..b446012 --- /dev/null +++ b/src/Resolver/RequestAttributeTimezoneResolver.php @@ -0,0 +1,42 @@ +attributes->has($this->attribute)) { + return null; + } + + $value = $request->attributes->get($this->attribute); + if (null === $value) { + return null; + } + + $timezone = match (true) { + $value instanceof TimezoneId => $value, + $value instanceof \DateTimeZone => TimezoneId::fromDateTimeZone($value), + is_string($value) => TimezoneId::fromString($value), + default => throw InvalidTimezoneException::unsupportedValueType(), + }; + + return new TimezoneResolution($timezone, 'request_attribute', ResolutionKind::EXPLICIT); + } +} diff --git a/src/Resolver/StoredPreferenceTimezoneResolver.php b/src/Resolver/StoredPreferenceTimezoneResolver.php new file mode 100644 index 0000000..d3f6767 --- /dev/null +++ b/src/Resolver/StoredPreferenceTimezoneResolver.php @@ -0,0 +1,52 @@ +attributes->get(self::READ_ATTRIBUTE); + if ($cached instanceof TimezonePreferenceRead) { + $read = $cached; + } else { + try { + $read = $this->storage->read($request); + } catch (PersistenceFailureExceptionInterface $failure) { + if (PersistenceFailureStrategy::THROW === $this->failureStrategy) { + throw $failure; + } + return null; + } + $request->attributes->set(self::READ_ATTRIBUTE, $read); + } + if (PreferenceReadStatus::VALID !== $read->status || null === $read->preference || (null !== $this->source && $read->preference->source !== $this->source)) { + return null; + } + return new TimezoneResolution($read->preference->timezone, 'persisted_'.$read->preference->source->value, ResolutionKind::PERSISTED); + } +} diff --git a/src/Resolver/TimezoneResolverInterface.php b/src/Resolver/TimezoneResolverInterface.php new file mode 100644 index 0000000..b14c2db --- /dev/null +++ b/src/Resolver/TimezoneResolverInterface.php @@ -0,0 +1,16 @@ +tokenStorage->getToken()?->getUser(); + if (null === $user) { + return null; + } + + if ($user instanceof TimezoneAwareUserInterface) { + try { + $timezone = $user->getTimezone(); + } catch (\Exception $exception) { + throw TimezoneResolverException::userAccessorFailed($exception); + } + } elseif (null !== $this->accessor) { + try { + $timezone = $this->accessor->getTimezoneForUser($user); + } catch (\Exception $exception) { + throw TimezoneResolverException::userAccessorFailed($exception); + } + } else { + return null; + } + + if (null === $timezone) { + return null; + } + + return new TimezoneResolution( + is_string($timezone) ? TimezoneId::fromString($timezone) : $timezone, + 'authenticated_user', + ResolutionKind::AUTHENTICATED, + ); + } +} diff --git a/src/Storage/CookieTimezoneStorage.php b/src/Storage/CookieTimezoneStorage.php new file mode 100644 index 0000000..ea0ad3f --- /dev/null +++ b/src/Storage/CookieTimezoneStorage.php @@ -0,0 +1,165 @@ +sameSite = $sameSite; + if (Cookie::SAMESITE_NONE === $sameSite && true !== $secure) { + throw new \InvalidArgumentException('SameSite=None requires a secure cookie.'); + } + if (str_starts_with($name, '__Host-') && ('/' !== $path || null !== $domain || true !== $secure)) { + throw new \InvalidArgumentException('__Host- cookies require path /, no domain, and secure transport.'); + } + $key = hash_hkdf('sha256', $secret, 32, 'lunetics-timezone-cookie-v1'); + $this->key = $key; + } + + public function read(Request $request): TimezonePreferenceRead + { + try { + $encoded = $request->cookies->get($this->name); + } catch (BadRequestException) { + return new TimezonePreferenceRead(PreferenceReadStatus::INVALID); + } + if (null === $encoded) { + return new TimezonePreferenceRead(PreferenceReadStatus::ABSENT); + } + if (strlen($encoded) > $this->maxEncodedSize) { + return new TimezonePreferenceRead(PreferenceReadStatus::INVALID); + } + $parts = explode('.', $encoded); + if (2 !== count($parts)) { + return new TimezonePreferenceRead(PreferenceReadStatus::INVALID); + } + [$payload64, $signature64] = $parts; + $signature = $this->base64UrlDecode($signature64); + if (null === $signature || !hash_equals(hash_hmac('sha256', $payload64, $this->key, true), $signature)) { + return new TimezonePreferenceRead(PreferenceReadStatus::INVALID); + } + $payload = $this->base64UrlDecode($payload64); + if (null === $payload) { + return new TimezonePreferenceRead(PreferenceReadStatus::INVALID); + } + try { + $data = json_decode($payload, true, 8, JSON_THROW_ON_ERROR); + if (!is_array($data) || array_keys($data) !== ['v', 'timezone', 'source', 'recorded_at'] || 1 !== $data['v'] || !is_string($data['timezone']) || !is_string($data['source']) || !is_string($data['recorded_at'])) { + return new TimezonePreferenceRead(PreferenceReadStatus::INVALID); + } + $source = PreferenceSource::tryFrom($data['source']); + if (null === $source) { + return new TimezonePreferenceRead(PreferenceReadStatus::INVALID); + } + $recordedAt = self::parseAtom($data['recorded_at']); + if (null === $recordedAt) { + return new TimezonePreferenceRead(PreferenceReadStatus::INVALID); + } + $now = $this->clock->now(); + if ($recordedAt->getTimestamp() > $now->getTimestamp() + $this->futureSkew) { + return new TimezonePreferenceRead(PreferenceReadStatus::INVALID); + } + if ($recordedAt->getTimestamp() + $this->maxAge < $now->getTimestamp()) { + return new TimezonePreferenceRead(PreferenceReadStatus::EXPIRED); + } + return new TimezonePreferenceRead(PreferenceReadStatus::VALID, new TimezonePreference(TimezoneId::fromString($data['timezone']), $source, $recordedAt)); + } catch (\JsonException) { + return new TimezonePreferenceRead(PreferenceReadStatus::INVALID); + } catch (\Lunetics\TimezoneBundle\Exception\InvalidTimezoneException) { + return new TimezonePreferenceRead(PreferenceReadStatus::INVALID); + } catch (\Exception) { + return new TimezonePreferenceRead(PreferenceReadStatus::INVALID); + } + } + + public function write(Request $request, Response $response, TimezonePreference $preference): void + { + try { + $payload = json_encode(['v' => 1, 'timezone' => $preference->timezone->value(), 'source' => $preference->source->value, 'recorded_at' => $preference->recordedAt->format(\DateTimeInterface::ATOM)], JSON_THROW_ON_ERROR); + $payload64 = $this->base64UrlEncode($payload); + $value = $payload64.'.'.$this->base64UrlEncode(hash_hmac('sha256', $payload64, $this->key, true)); + if (strlen($value) > $this->maxEncodedSize) { + throw new \LengthException('Encoded timezone cookie exceeds its size limit.'); + } + $secure = $this->resolveSecure($request); + $response->headers->setCookie(Cookie::create($this->name, $value, $this->clock->now()->getTimestamp() + $this->maxAge, $this->path, $this->domain, $secure, $this->httpOnly, false, $this->cookieSameSite())); + } catch (\JsonException|\LengthException|\RuntimeException $failure) { + throw TimezoneStorageException::operationFailed('write', $failure); + } + } + + public function clear(Request $request, Response $response): void + { + $response->headers->clearCookie($this->name, $this->path, $this->domain, $this->resolveSecure($request), $this->httpOnly, $this->cookieSameSite()); + } + + private function resolveSecure(Request $request): bool + { + $secure = $this->secure ?? $request->isSecure(); + if ((Cookie::SAMESITE_NONE === $this->sameSite || str_starts_with($this->name, '__Host-')) && !$secure) { + throw new TimezoneStorageException('The configured timezone cookie requires a secure request.'); + } + return $secure; + } + + /** @return Cookie::SAMESITE_LAX|Cookie::SAMESITE_STRICT|Cookie::SAMESITE_NONE */ + private function cookieSameSite(): string + { + return $this->sameSite; + } + + private function base64UrlEncode(string $value): string + { + return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + } + + private static function parseAtom(string $value): ?\DateTimeImmutable + { + $date = \DateTimeImmutable::createFromFormat(\DateTimeInterface::ATOM, $value); + + return false !== $date && $date->format(\DateTimeInterface::ATOM) === $value ? $date : null; + } + + private function base64UrlDecode(string $value): ?string + { + if ('' === $value || 1 === preg_match('/[^A-Za-z0-9_-]/D', $value)) { + return null; + } + $decoded = base64_decode(strtr($value, '-_', '+/').str_repeat('=', (4 - strlen($value) % 4) % 4), true); + return false === $decoded ? null : $decoded; + } +} diff --git a/src/Storage/PreferenceReadStatus.php b/src/Storage/PreferenceReadStatus.php new file mode 100644 index 0000000..32f9929 --- /dev/null +++ b/src/Storage/PreferenceReadStatus.php @@ -0,0 +1,13 @@ +hasSession()) { + return new TimezonePreferenceRead(PreferenceReadStatus::ABSENT); + } + + try { + $session = $request->getSession(); + if (!$session->isStarted() && !$request->cookies->has($session->getName())) { + return new TimezonePreferenceRead(PreferenceReadStatus::ABSENT); + } + if (!$session->has($this->key)) { + return new TimezonePreferenceRead(PreferenceReadStatus::ABSENT); + } + $value = $session->get($this->key); + } catch (\RuntimeException $failure) { + throw TimezoneStorageException::operationFailed('read', $failure); + } + + return $this->decode($value); + } + + public function write(Request $request, Response $response, TimezonePreference $preference): void + { + unset($response); + try { + $request->getSession()->set($this->key, $this->encode($preference)); + } catch (SessionNotFoundException|\RuntimeException $failure) { + throw TimezoneStorageException::operationFailed('write', $failure); + } + } + + public function clear(Request $request, Response $response): void + { + unset($response); + if (!$request->hasSession()) { + return; + } + try { + $session = $request->getSession(); + if (!$session->isStarted() && !$request->cookies->has($session->getName())) { + return; + } + $session->remove($this->key); + } catch (\RuntimeException $failure) { + throw TimezoneStorageException::operationFailed('clear', $failure); + } + } + + /** @return array{v: 1, timezone: string, source: string, recorded_at: string} */ + private function encode(TimezonePreference $preference): array + { + return ['v' => 1, 'timezone' => $preference->timezone->value(), 'source' => $preference->source->value, 'recorded_at' => $preference->recordedAt->format(\DateTimeInterface::ATOM)]; + } + + private function decode(mixed $value): TimezonePreferenceRead + { + if (!is_array($value) || array_keys($value) !== ['v', 'timezone', 'source', 'recorded_at'] || 1 !== $value['v'] || !is_string($value['timezone']) || !is_string($value['source']) || !is_string($value['recorded_at'])) { + return new TimezonePreferenceRead(PreferenceReadStatus::INVALID); + } + try { + $source = PreferenceSource::tryFrom($value['source']); + if (null === $source) { + return new TimezonePreferenceRead(PreferenceReadStatus::INVALID); + } + $recordedAt = self::parseAtom($value['recorded_at']); + if (null === $recordedAt) { + return new TimezonePreferenceRead(PreferenceReadStatus::INVALID); + } + return new TimezonePreferenceRead(PreferenceReadStatus::VALID, new TimezonePreference(TimezoneId::fromString($value['timezone']), $source, $recordedAt)); + } catch (\Exception) { + return new TimezonePreferenceRead(PreferenceReadStatus::INVALID); + } + } + + private static function parseAtom(string $value): ?\DateTimeImmutable + { + $date = \DateTimeImmutable::createFromFormat(\DateTimeInterface::ATOM, $value); + + return false !== $date && $date->format(\DateTimeInterface::ATOM) === $value ? $date : null; + } +} diff --git a/src/Storage/TimezonePreference.php b/src/Storage/TimezonePreference.php new file mode 100644 index 0000000..bad20f8 --- /dev/null +++ b/src/Storage/TimezonePreference.php @@ -0,0 +1,26 @@ +recordedAt = $recordedAt->setTimezone(new \DateTimeZone('UTC')); + } + + public function equals(self $other): bool + { + return $this->timezone->equals($other->timezone) + && $this->source === $other->source; + } +} diff --git a/src/Storage/TimezonePreferenceRead.php b/src/Storage/TimezonePreferenceRead.php new file mode 100644 index 0000000..aef0272 --- /dev/null +++ b/src/Storage/TimezonePreferenceRead.php @@ -0,0 +1,15 @@ +getName()); + } + + public function value(): string + { + return $this->value; + } + + public function toDateTimeZone(): \DateTimeZone + { + return new \DateTimeZone($this->value); + } + + public function equals(self $other): bool + { + return $this->value === $other->value; + } + + public function __toString(): string + { + return $this->value; + } + + /** @return array{value: string} */ + public function __serialize(): array + { + return ['value' => $this->value]; + } + + /** @param array $data */ + public function __unserialize(array $data): void + { + if (1 !== count($data) || !isset($data['value']) || !is_string($data['value'])) { + throw InvalidTimezoneException::invalidIdentifier(); + } + + $validated = self::fromString($data['value']); + $this->value = $validated->value; + } + + /** @return array */ + private static function knownIdentifiers(): array + { + /** @var array|null $identifiers */ + static $identifiers = null; + + if (null === $identifiers) { + $identifiers = array_fill_keys(\DateTimeZone::listIdentifiers(\DateTimeZone::ALL_WITH_BC), true); + } + + return $identifiers; + } +} From 9c34917e97020b6d35f3aaa6fca11292bd64876b Mon Sep 17 00:00:00 2001 From: Matthias Breddin Date: Wed, 22 Jul 2026 23:46:33 +0200 Subject: [PATCH 02/34] test: make locale resolution coverage intl-independent --- Tests/Resolver/LocaleTimezoneResolverTest.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Tests/Resolver/LocaleTimezoneResolverTest.php b/Tests/Resolver/LocaleTimezoneResolverTest.php index 2c8382f..c564147 100644 --- a/Tests/Resolver/LocaleTimezoneResolverTest.php +++ b/Tests/Resolver/LocaleTimezoneResolverTest.php @@ -16,11 +16,8 @@ #[CoversClass(PhpCountryTimezoneSource::class)] final class LocaleTimezoneResolverTest extends TestCase { - public function testResolvesHyphenatedLocaleWithIncompleteIntlPolyfill(): void + public function testResolvesHyphenatedLocaleWithOrWithoutIntl(): void { - self::assertFalse(extension_loaded('intl')); - self::assertTrue(class_exists(\Locale::class)); - $source = new RecordingCountryTimezoneSource([TimezoneId::fromString('Europe/Berlin')]); $request = Request::create('/'); $request->setLocale('de-DE'); From 2f7f7d18aed278dd1b24762fd8d6e614792db1b0 Mon Sep 17 00:00:00 2001 From: Matthias Breddin Date: Thu, 23 Jul 2026 02:23:48 +0200 Subject: [PATCH 03/34] fix: keep Symfony 8.1 and PHP 8.5 CI strict --- Tests/Integration/BundleKernelSmokeTest.php | 14 ++++++++++++-- src/Context/TimezoneExecutionContext.php | 6 +++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/Tests/Integration/BundleKernelSmokeTest.php b/Tests/Integration/BundleKernelSmokeTest.php index 8ca9474..9725045 100644 --- a/Tests/Integration/BundleKernelSmokeTest.php +++ b/Tests/Integration/BundleKernelSmokeTest.php @@ -4,6 +4,7 @@ namespace Lunetics\TimezoneBundle\Tests\Integration; +use DateTimeImmutable; use Lunetics\TimezoneBundle\Bridge\Messenger\DispatchTimezoneMiddleware; use Lunetics\TimezoneBundle\Bridge\Messenger\WorkerTimezoneMiddleware; use Lunetics\TimezoneBundle\Bridge\WebProfiler\TimezoneDataCollector; @@ -21,7 +22,6 @@ use Symfony\Bundle\TwigBundle\TwigBundle; use Symfony\Bundle\WebProfilerBundle\WebProfilerBundle; use Symfony\Component\AssetMapper\AssetMapperInterface; -use Symfony\Component\Clock\Clock; use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; @@ -84,7 +84,7 @@ public function testBundleWorksInARealDebugKernel(): void $clock = $container->get('test.clock'); self::assertNotInstanceOf(SystemClock::class, $clock); - self::assertInstanceOf(Clock::class, $clock); + self::assertInstanceOf(BundleSmokeApplicationClock::class, $clock); self::assertSame($clock, $container->get('test.timezone_clock')); $router = $container->get('router'); @@ -196,6 +196,14 @@ private function removeDirectory(string $directory): void } } +final class BundleSmokeApplicationClock implements ClockInterface +{ + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2024-01-01T00:00:00+00:00'); + } +} + final class BundleSmokeKernel extends Kernel { use MicroKernelTrait; @@ -261,6 +269,8 @@ protected function configureContainer(ContainerConfigurator $container, LoaderIn 'integrations' => ['twig' => true, 'form' => true, 'messenger' => true, 'profiler' => true], ]); $services = $container->services(); + $services->set(BundleSmokeApplicationClock::class); + $services->alias(ClockInterface::class, BundleSmokeApplicationClock::class); $services->alias('test.twig', 'twig')->public(); $services->alias('test.asset_mapper', 'asset_mapper')->public(); $services->alias('test.timezone_resolver_chain', TimezoneResolverChain::class)->public(); diff --git a/src/Context/TimezoneExecutionContext.php b/src/Context/TimezoneExecutionContext.php index 16fa4e4..d06a833 100644 --- a/src/Context/TimezoneExecutionContext.php +++ b/src/Context/TimezoneExecutionContext.php @@ -24,7 +24,11 @@ public function run(TimezoneId $timezone, callable $callback): mixed public function current(): ?TimezoneId { - return $this->stack[array_key_last($this->stack)] ?? null; + if ([] === $this->stack) { + return null; + } + + return $this->stack[count($this->stack) - 1]; } public function reset(): void From 74d8f659ee25a1ca7d12b3188fe53707eba341bf Mon Sep 17 00:00:00 2001 From: Matthias Breddin Date: Mon, 27 Jul 2026 04:49:09 +0200 Subject: [PATCH 04/34] fix: mark preference writes on the request inside the storages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The invalid-preference cleanup listener suppresses itself only via the marker attribute that BrowserTimezoneController sets, but the bundle itself never writes a manual preference — the application is the only designed producer for the stored_manual resolver, and its write() call sets no marker. When a request arrives with an invalid or expired stored preference (cookie secret rotation, max_age expiry) and the application persists a manual preference during that same request, the kernel.response cleanup replaces the fresh Set-Cookie with a delete-cookie or removes the session key — the submission is silently lost while the user sees a success response. The written marker now lives on TimezonePreferenceStorageInterface and both built-in storages set it inside write(), so the cleanup listener honours every writer. The controller keeps setting it as a safety net for custom storages, and the storage contract documentation states the duty. Regression tests pin the manual-write-survives-cleanup path for both storages. --- Resources/doc/installation.md | 2 + .../PreferenceDiagnosticsFlagsTest.php | 65 +++++++++++++++++++ .../WebProfiler/TimezoneDataCollector.php | 4 +- src/Controller/BrowserTimezoneController.php | 2 +- .../InvalidPreferenceCleanupListener.php | 3 +- src/Storage/CookieTimezoneStorage.php | 1 + src/Storage/SessionTimezoneStorage.php | 1 + .../TimezonePreferenceStorageInterface.php | 9 +++ 8 files changed, 82 insertions(+), 5 deletions(-) diff --git a/Resources/doc/installation.md b/Resources/doc/installation.md index b088a1d..b5f1a15 100644 --- a/Resources/doc/installation.md +++ b/Resources/doc/installation.md @@ -151,6 +151,8 @@ public function clear(Request $request, Response $response): void; The storage must preserve the preference timezone, `manual|browser` source, and recording time. Built-in session storage stores the envelope directly; cookie storage encodes a signed versioned JSON envelope. +A successful `write()` must also set the request attribute `TimezonePreferenceStorageInterface::PREFERENCE_WRITTEN_ATTRIBUTE` (`_lunetics_timezone.preference_written`) to `true`, as the built-in storages do. The bundle's cleanup listener clears invalid or expired stored preferences on the response and skips that cleanup only when this marker is present — an application write (for example a settings form persisting a `manual` preference) would otherwise be deleted again in the same request whenever the incoming request carried a corrupt or expired record. + ## MaxMind City Install `geoip2/geoip2` and point `resolution.maxmind.database` at a GeoLite2 City or GeoIP2 City database. Both use the reader's `city()` lookup. Country and ASN databases are unsupported. GeoIP2 Enterprise is supported only across the custom reader boundary: configure `resolution.maxmind.reader` with a service implementing `MaxMindCityReaderInterface::timezoneForIp(string $ipAddress): TimezoneId|string|null`, or adapt a callable with `CallableMaxMindCityReader`. Exactly one of `database` and `reader` is required when enabled. diff --git a/Tests/EventListener/PreferenceDiagnosticsFlagsTest.php b/Tests/EventListener/PreferenceDiagnosticsFlagsTest.php index 741e399..4037e12 100644 --- a/Tests/EventListener/PreferenceDiagnosticsFlagsTest.php +++ b/Tests/EventListener/PreferenceDiagnosticsFlagsTest.php @@ -99,6 +99,71 @@ public function testFreshCookiePreferenceIsNotClearedBecauseResolverCachedExpire self::assertSame('Europe/Berlin', $read->preference?->timezone->value()); } + public function testApplicationManualSessionWriteSurvivesCleanupAfterInvalidRead(): void + { + $storage = new SessionTimezoneStorage(); + $session = new Session(new MockArraySessionStorage()); + $session->start(); + $session->set('_lunetics_timezone', ['v' => 1]); + $request = Request::create('/settings', 'POST'); + $request->setSession($session); + + (new StoredPreferenceTimezoneResolver($storage))->resolve($request); + $cachedRead = $request->attributes->get(StoredPreferenceTimezoneResolver::READ_ATTRIBUTE); + self::assertInstanceOf(TimezonePreferenceRead::class, $cachedRead); + self::assertSame(PreferenceReadStatus::INVALID, $cachedRead->status); + $response = new Response(); + $storage->write($request, $response, new TimezonePreference(TimezoneId::fromString('America/New_York'), PreferenceSource::MANUAL, new \DateTimeImmutable('2026-01-01T00:00:00Z'))); + (new InvalidPreferenceCleanupListener($storage, PersistenceFailureStrategy::CONTINUE))->onKernelResponse( + new ResponseEvent($this->createStub(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST, $response), + ); + + self::assertFalse($request->attributes->has(InvalidPreferenceCleanupListener::PREFERENCE_CLEARED_ATTRIBUTE)); + $read = $storage->read($request); + self::assertSame(PreferenceReadStatus::VALID, $read->status); + self::assertSame('America/New_York', $read->preference?->timezone->value()); + self::assertSame(PreferenceSource::MANUAL, $read->preference->source); + } + + public function testApplicationManualCookieWriteSurvivesCleanupAfterExpiredRead(): void + { + $clock = $this->fixedClock(); + $storage = new CookieTimezoneStorage('test-only-secret', $clock, maxAge: 3600); + $expiredResponse = new Response(); + $storage->write( + Request::create('https://example.test'), + $expiredResponse, + new TimezonePreference(TimezoneId::fromString('America/New_York'), PreferenceSource::BROWSER, new \DateTimeImmutable('2025-12-31T22:59:59Z')), + ); + $expiredCookie = $expiredResponse->headers->getCookies()[0]; + $request = Request::create('https://example.test/settings', 'POST'); + $request->cookies->set($expiredCookie->getName(), $expiredCookie->getValue()); + + (new StoredPreferenceTimezoneResolver($storage))->resolve($request); + $cachedRead = $request->attributes->get(StoredPreferenceTimezoneResolver::READ_ATTRIBUTE); + self::assertInstanceOf(TimezonePreferenceRead::class, $cachedRead); + self::assertSame(PreferenceReadStatus::EXPIRED, $cachedRead->status); + $response = new Response(); + $storage->write($request, $response, new TimezonePreference(TimezoneId::fromString('Europe/Paris'), PreferenceSource::MANUAL, $clock->now())); + (new InvalidPreferenceCleanupListener($storage, PersistenceFailureStrategy::CONTINUE))->onKernelResponse( + new ResponseEvent($this->createStub(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST, $response), + ); + + self::assertFalse($request->attributes->has(InvalidPreferenceCleanupListener::PREFERENCE_CLEARED_ATTRIBUTE)); + $cookies = $response->headers->getCookies(); + self::assertCount(1, $cookies); + $freshCookie = $cookies[0]; + self::assertInstanceOf(Cookie::class, $freshCookie); + self::assertNotSame('', (string) $freshCookie->getValue()); + self::assertGreaterThan($clock->now()->getTimestamp(), $freshCookie->getExpiresTime()); + $followUp = Request::create('https://example.test'); + $followUp->cookies->set($freshCookie->getName(), $freshCookie->getValue()); + $read = $storage->read($followUp); + self::assertSame(PreferenceReadStatus::VALID, $read->status); + self::assertSame('Europe/Paris', $read->preference?->timezone->value()); + self::assertSame(PreferenceSource::MANUAL, $read->preference->source); + } + #[DataProvider('writeOutcomes')] public function testWrittenFlagRequiresAnActualSuccessfulWrite(bool $fail, bool $expectedFlag): void { diff --git a/src/Bridge/WebProfiler/TimezoneDataCollector.php b/src/Bridge/WebProfiler/TimezoneDataCollector.php index b15d06a..85802e4 100644 --- a/src/Bridge/WebProfiler/TimezoneDataCollector.php +++ b/src/Bridge/WebProfiler/TimezoneDataCollector.php @@ -4,11 +4,11 @@ namespace Lunetics\TimezoneBundle\Bridge\WebProfiler; -use Lunetics\TimezoneBundle\Controller\BrowserTimezoneController; use Lunetics\TimezoneBundle\EventListener\InvalidPreferenceCleanupListener; use Lunetics\TimezoneBundle\Resolution\TimezoneResolutionTrace; use Lunetics\TimezoneBundle\Resolver\StoredPreferenceTimezoneResolver; use Lunetics\TimezoneBundle\Storage\TimezonePreferenceRead; +use Lunetics\TimezoneBundle\Storage\TimezonePreferenceStorageInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\DataCollector\DataCollector; @@ -48,7 +48,7 @@ public function collect(Request $request, Response $response, ?\Throwable $excep 'attempts' => $attempts, 'preference_read' => $read instanceof TimezonePreferenceRead, 'preference_read_status' => $read instanceof TimezonePreferenceRead ? $read->status->value : null, - 'preference_written' => true === $request->attributes->get(BrowserTimezoneController::PREFERENCE_WRITTEN_ATTRIBUTE), + 'preference_written' => true === $request->attributes->get(TimezonePreferenceStorageInterface::PREFERENCE_WRITTEN_ATTRIBUTE), 'preference_cleared' => true === $request->attributes->get(InvalidPreferenceCleanupListener::PREFERENCE_CLEARED_ATTRIBUTE), ]; } diff --git a/src/Controller/BrowserTimezoneController.php b/src/Controller/BrowserTimezoneController.php index 56b68f2..3c13c6d 100644 --- a/src/Controller/BrowserTimezoneController.php +++ b/src/Controller/BrowserTimezoneController.php @@ -21,7 +21,7 @@ final readonly class BrowserTimezoneController { - public const PREFERENCE_WRITTEN_ATTRIBUTE = '_lunetics_timezone.preference_written'; + public const PREFERENCE_WRITTEN_ATTRIBUTE = TimezonePreferenceStorageInterface::PREFERENCE_WRITTEN_ATTRIBUTE; public function __construct( private TimezonePreferenceStorageInterface $storage, diff --git a/src/EventListener/InvalidPreferenceCleanupListener.php b/src/EventListener/InvalidPreferenceCleanupListener.php index 37d0520..f66b9e4 100644 --- a/src/EventListener/InvalidPreferenceCleanupListener.php +++ b/src/EventListener/InvalidPreferenceCleanupListener.php @@ -4,7 +4,6 @@ namespace Lunetics\TimezoneBundle\EventListener; -use Lunetics\TimezoneBundle\Controller\BrowserTimezoneController; use Lunetics\TimezoneBundle\Exception\PersistenceFailureExceptionInterface; use Lunetics\TimezoneBundle\Resolution\PersistenceFailureStrategy; use Lunetics\TimezoneBundle\Resolver\StoredPreferenceTimezoneResolver; @@ -34,7 +33,7 @@ public function onKernelResponse(ResponseEvent $event): void } $request = $event->getRequest(); - if (true === $request->attributes->get(BrowserTimezoneController::PREFERENCE_WRITTEN_ATTRIBUTE)) { + if (true === $request->attributes->get(TimezonePreferenceStorageInterface::PREFERENCE_WRITTEN_ATTRIBUTE)) { return; } diff --git a/src/Storage/CookieTimezoneStorage.php b/src/Storage/CookieTimezoneStorage.php index ea0ad3f..928460f 100644 --- a/src/Storage/CookieTimezoneStorage.php +++ b/src/Storage/CookieTimezoneStorage.php @@ -120,6 +120,7 @@ public function write(Request $request, Response $response, TimezonePreference $ } catch (\JsonException|\LengthException|\RuntimeException $failure) { throw TimezoneStorageException::operationFailed('write', $failure); } + $request->attributes->set(self::PREFERENCE_WRITTEN_ATTRIBUTE, true); } public function clear(Request $request, Response $response): void diff --git a/src/Storage/SessionTimezoneStorage.php b/src/Storage/SessionTimezoneStorage.php index 7f8fe9a..6a55062 100644 --- a/src/Storage/SessionTimezoneStorage.php +++ b/src/Storage/SessionTimezoneStorage.php @@ -49,6 +49,7 @@ public function write(Request $request, Response $response, TimezonePreference $ } catch (SessionNotFoundException|\RuntimeException $failure) { throw TimezoneStorageException::operationFailed('write', $failure); } + $request->attributes->set(self::PREFERENCE_WRITTEN_ATTRIBUTE, true); } public function clear(Request $request, Response $response): void diff --git a/src/Storage/TimezonePreferenceStorageInterface.php b/src/Storage/TimezonePreferenceStorageInterface.php index 7c985b0..2cd3647 100644 --- a/src/Storage/TimezonePreferenceStorageInterface.php +++ b/src/Storage/TimezonePreferenceStorageInterface.php @@ -10,6 +10,15 @@ interface TimezonePreferenceStorageInterface { + /** + * Request attribute marking that a preference write succeeded during the + * current request, regardless of who triggered it. Implementations must + * set it to true inside write(); the invalid-preference cleanup listener + * skips its response-time cleanup when the marker is present so a freshly + * written preference is never cleared again within the same request. + */ + public const PREFERENCE_WRITTEN_ATTRIBUTE = '_lunetics_timezone.preference_written'; + /** @throws PersistenceFailureExceptionInterface */ public function read(Request $request): TimezonePreferenceRead; From 8b5254a4386d78b8eccc234b91697260567320ef Mon Sep 17 00:00:00 2001 From: Matthias Breddin Date: Mon, 27 Jul 2026 04:50:08 +0200 Subject: [PATCH 05/34] test: verify the distribution archive instead of .gitattributes text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExportPolicyTest asserts the literal rule list of .gitattributes against a hardcoded copy of itself, so both sides of the assertion are the same artifact — development files that were never added to the list ship unnoticed, and scripts/no-dev-smoke.php (a CI-only script that throws when executed from an installed package) is in the archive today. Whenever a developer adds a new dev-only file without extending .gitattributes, every consumer receives it via composer install while the guard test stays green. The test now parses the actual 'git archive --worktree-attributes' output (minimal ustar reader, pax headers skipped) and asserts a top-level allowlist plus required distribution files; /scripts is export-ignored. It skips outside a git checkout. --- .gitattributes | 2 + Tests/Distribution/ExportPolicyTest.php | 107 ++++++++++++++++-------- 2 files changed, 76 insertions(+), 33 deletions(-) diff --git a/.gitattributes b/.gitattributes index 1d16d50..0eb12b4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,6 +6,8 @@ /.claude/** export-ignore /Tests export-ignore /Tests/** export-ignore +/scripts export-ignore +/scripts/** export-ignore /.gitattributes export-ignore /.gitignore export-ignore /.travis.yml export-ignore diff --git a/Tests/Distribution/ExportPolicyTest.php b/Tests/Distribution/ExportPolicyTest.php index 8ec75a6..3bb82ff 100644 --- a/Tests/Distribution/ExportPolicyTest.php +++ b/Tests/Distribution/ExportPolicyTest.php @@ -8,42 +8,83 @@ final class ExportPolicyTest extends TestCase { - public function testPublicFilesAreExportedAndDevelopmentFilesAreIgnored(): void + private const ALLOWED_TOP_LEVEL = [ + 'CHANGELOG.md', + 'composer.json', + 'LICENSE', + 'README.markdown', + 'Resources', + 'src', + 'UPGRADE-2.0.md', + ]; + + private const REQUIRED_PATHS = [ + 'CHANGELOG.md', + 'composer.json', + 'LICENSE', + 'README.markdown', + 'Resources/config/routes.php', + 'Resources/doc/scope.md', + 'Resources/doc/v2-implementation-plan.md', + 'Resources/public/timezone.js', + 'UPGRADE-2.0.md', + 'src/LuneticsTimezoneBundle.php', + ]; + + public function testDistributionArchiveContainsOnlyAllowedPaths(): void { - $attributesFile = dirname(__DIR__, 2).'/.gitattributes'; - $contents = file_get_contents($attributesFile); - self::assertIsString($contents); - - $rules = array_values(array_filter( - preg_split('/\R/', $contents) ?: [], - static fn (string $line): bool => '' !== trim($line) && !str_starts_with(ltrim($line), '#'), - )); - - foreach ([ - '/Resources/doc/scope.md export-ignore', - '/Resources/doc/v2-implementation-plan.md export-ignore', - '/LICENSE export-ignore', - ] as $forbiddenRule) { - self::assertNotContains($forbiddenRule, $rules, sprintf('%s must be included in distribution archives.', strtok($forbiddenRule, ' '))); + $root = dirname(__DIR__, 2); + if (!is_dir($root.'/.git')) { + self::markTestSkipped('Distribution policy requires a git checkout.'); + } + $tar = shell_exec(sprintf('git -C %s archive --worktree-attributes HEAD', escapeshellarg($root))); + if (!is_string($tar) || '' === $tar) { + self::markTestSkipped('git archive is unavailable in this environment.'); + } + + $entries = self::tarEntries($tar); + self::assertNotSame([], $entries, 'The distribution archive is empty.'); + + foreach ($entries as $entry) { + $topLevel = explode('/', rtrim($entry, '/'), 2)[0]; + self::assertContains($topLevel, self::ALLOWED_TOP_LEVEL, sprintf( + 'Unexpected path "%s" in the distribution archive; add an export-ignore rule to .gitattributes or extend the allowlist.', + $entry, + )); } - foreach ([ - '/.github export-ignore', - '/.github/** export-ignore', - '/.firecrawl export-ignore', - '/.firecrawl/** export-ignore', - '/.claude export-ignore', - '/.claude/** export-ignore', - '/Tests export-ignore', - '/Tests/** export-ignore', - '/.gitattributes export-ignore', - '/.gitignore export-ignore', - '/.travis.yml export-ignore', - '/phpunit.xml.dist export-ignore', - '/phpstan.neon.dist export-ignore', - '/package.json export-ignore', - ] as $requiredRule) { - self::assertContains($requiredRule, $rules, sprintf('Missing distribution exclusion: %s.', $requiredRule)); + foreach (self::REQUIRED_PATHS as $required) { + self::assertContains($required, $entries, sprintf('Required distribution file "%s" is missing from the archive.', $required)); } } + + /** + * Minimal ustar reader: 512-byte headers, name at offset 0 (100 bytes), + * octal size at 124 (12 bytes), optional name prefix at 345 (155 bytes). + * + * @return list + */ + private static function tarEntries(string $tar): array + { + $entries = []; + $offset = 0; + $length = strlen($tar); + while ($offset + 512 <= $length) { + $header = substr($tar, $offset, 512); + $name = rtrim(substr($header, 0, 100), "\0"); + if ('' === $name) { + break; + } + $prefix = rtrim(substr($header, 345, 155), "\0"); + $path = '' === $prefix ? $name : $prefix.'/'.$name; + $typeFlag = substr($header, 156, 1); + if (in_array($typeFlag, ['0', "\0"], true) && !str_ends_with($path, '/')) { + $entries[] = $path; + } + $size = (int) octdec(trim(substr($header, 124, 12), " \0")); + $offset += 512 + (int) (ceil($size / 512) * 512); + } + + return $entries; + } } From 074698f02fe54e1dab61f6e6f2784718be2f6486 Mon Sep 17 00:00:00 2001 From: Matthias Breddin Date: Mon, 27 Jul 2026 04:52:29 +0200 Subject: [PATCH 06/34] fix: reject NAT64 and 6to4 client addresses before MaxMind lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The explicit non-public range list omits the well-known NAT64 prefix 64:ff9b::/96 (RFC 6052) and the 6to4 prefix 2002::/16, while PHP's FILTER_FLAG_NO_RES_RANGE does not know either — translation addresses that embed private IPv4 space pass both gates. An IPv6 client behind NAT64 (e.g. 64:ff9b::10.0.0.1) or a 6to4 address therefore reaches the third-party MaxMind reader that the resolver otherwise shields from non-public addresses, wasting a lookup on an address class that cannot geolocate meaningfully. Both prefixes are now part of explicitlyNonPublicRanges() and the non-public data provider covers them alongside the existing local-use NAT64 range. --- Tests/Resolver/MaxMindTimezoneResolverTest.php | 3 +++ src/Resolver/MaxMindTimezoneResolver.php | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Tests/Resolver/MaxMindTimezoneResolverTest.php b/Tests/Resolver/MaxMindTimezoneResolverTest.php index e7ce684..715bf86 100644 --- a/Tests/Resolver/MaxMindTimezoneResolverTest.php +++ b/Tests/Resolver/MaxMindTimezoneResolverTest.php @@ -40,6 +40,9 @@ public static function nonPublicAddresses(): iterable yield 'reserved documentation range' => ['192.0.2.1']; yield 'ipv6 loopback' => ['::1']; yield 'ipv6 link local' => ['fe80::1']; + yield 'ipv6 well-known nat64' => ['64:ff9b::808:808']; + yield 'ipv6 local-use nat64' => ['64:ff9b:1::1']; + yield 'ipv6 6to4' => ['2002:a00:1::1']; } #[DataProvider('nonPublicAddresses')] diff --git a/src/Resolver/MaxMindTimezoneResolver.php b/src/Resolver/MaxMindTimezoneResolver.php index bb36fda..43407f0 100644 --- a/src/Resolver/MaxMindTimezoneResolver.php +++ b/src/Resolver/MaxMindTimezoneResolver.php @@ -54,8 +54,9 @@ private static function explicitlyNonPublicRanges(): array '0.0.0.0/8', '100.64.0.0/10', '192.0.0.0/24', '192.0.2.0/24', '198.18.0.0/15', '198.51.100.0/24', '203.0.113.0/24', '224.0.0.0/4', '240.0.0.0/4', - '::/128', '::ffff:0:0/96', '64:ff9b:1::/48', '100::/64', - '2001:db8::/32', 'fc00::/7', 'fe80::/10', 'ff00::/8', + '::/128', '::ffff:0:0/96', '64:ff9b::/96', '64:ff9b:1::/48', + '100::/64', '2001:db8::/32', '2002::/16', 'fc00::/7', + 'fe80::/10', 'ff00::/8', ]; } } From 0cc535ff8aaa16a24a4a542f34f51f9d0c1fb92f Mon Sep 17 00:00:00 2001 From: Matthias Breddin Date: Mon, 27 Jul 2026 04:52:29 +0200 Subject: [PATCH 07/34] fix: validate the __Secure- cookie prefix like its __Host- sibling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The __Host- prefix rules are enforced in the storage constructor, in resolveSecure() and in the container configuration validation, but the weaker sibling prefix __Secure- is validated nowhere even though RFC 6265bis requires the Secure attribute for it as well. With name: '__Secure-tz' and secure: auto, a plain-HTTP request emits the cookie without the Secure attribute; browsers reject it, yet the endpoint answers 204, the change event fires and the profiler reports a successful write — nothing is ever persisted and the browser re-syncs on every page load. All three __Host- validation sites now also enforce explicit secure=true for __Secure- names, failing at construction/compile time instead of silently at runtime; docs and tests cover the new rule. --- Resources/doc/installation.md | 2 +- .../LuneticsTimezoneBundleTest.php | 9 +++++++++ Tests/Storage/CookieTimezoneStorageTest.php | 18 ++++++++++++++++++ src/LuneticsTimezoneBundle.php | 3 +++ src/Storage/CookieTimezoneStorage.php | 5 ++++- 5 files changed, 35 insertions(+), 2 deletions(-) diff --git a/Resources/doc/installation.md b/Resources/doc/installation.md index b5f1a15..cabc329 100644 --- a/Resources/doc/installation.md +++ b/Resources/doc/installation.md @@ -85,7 +85,7 @@ lunetics_timezone: Configuration is strict at container-build time. Unknown keys under `integrations` are rejected. The cookie name must be a valid cookie identifier, and configured MaxMind `database`/`reader` identifiers must be non-blank; invalid or blank values fail while the container is built rather than at first use. -Cookie storage requires `cookie.secret`. Its versioned JSON value is signed; invalid and expired values are ignored and cleared on the response. `same_site: none` requires the explicit setting `secure: true`. A `__Host-` name likewise requires explicit `secure: true`, `path: /`, and `domain: null`; `secure: auto` is not sufficient for either rule. +Cookie storage requires `cookie.secret`. Its versioned JSON value is signed; invalid and expired values are ignored and cleared on the response. `same_site: none` requires the explicit setting `secure: true`. A `__Host-` name likewise requires explicit `secure: true`, `path: /`, and `domain: null`; a `__Secure-` name requires explicit `secure: true`. `secure: auto` is not sufficient for any of these rules. ## Browser synchronization and route opt-in diff --git a/Tests/DependencyInjection/LuneticsTimezoneBundleTest.php b/Tests/DependencyInjection/LuneticsTimezoneBundleTest.php index 7141757..f5a50a6 100644 --- a/Tests/DependencyInjection/LuneticsTimezoneBundleTest.php +++ b/Tests/DependencyInjection/LuneticsTimezoneBundleTest.php @@ -206,6 +206,15 @@ public function testInvalidCookieNameIsRejectedByConfiguration(): void $container->compile(); } + public function testSecurePrefixedCookieNameRequiresExplicitSecureTrue(): void + { + $container = $this->container(['persistence' => ['storage' => 'cookie', 'cookie' => ['name' => '__Secure-tz', 'secret' => 'test-only-secret']]]); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('__Secure- cookies require secure=true.'); + $container->compile(); + } + public function testBrowserCsrfFailsClearlyWhenOptionalPackageIsMissing(): void { $container = $this->container(['browser' => ['enabled' => true]]); diff --git a/Tests/Storage/CookieTimezoneStorageTest.php b/Tests/Storage/CookieTimezoneStorageTest.php index b79202d..cdae8f9 100644 --- a/Tests/Storage/CookieTimezoneStorageTest.php +++ b/Tests/Storage/CookieTimezoneStorageTest.php @@ -141,6 +141,24 @@ public function testConstructorRejectsInvalidCookieName(): void new CookieTimezoneStorage('test-only-secret', new MutableClock('2026-01-01T00:00:00Z'), name: 'invalid cookie'); } + public function testConstructorRejectsSecurePrefixWithoutExplicitSecureTransport(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('__Secure- cookies require secure transport.'); + new CookieTimezoneStorage('test-only-secret', new MutableClock('2026-01-01T00:00:00Z'), name: '__Secure-tz'); + } + + public function testSecurePrefixWithExplicitSecureTransportWritesSecureCookie(): void + { + $clock = new MutableClock('2026-01-01T00:00:00Z'); + $storage = new CookieTimezoneStorage('test-only-secret', $clock, name: '__Secure-tz', secure: true); + $response = new Response(); + + $storage->write(Request::create('https://example.test'), $response, $this->preference('Europe/Berlin', PreferenceSource::BROWSER, $clock->now())); + + self::assertTrue($this->onlyCookie($response)->isSecure()); + } + private function storage(MutableClock $clock, int $maxAge = 31536000, int $futureSkew = 60, int $maxEncodedSize = 4096): CookieTimezoneStorage { return new CookieTimezoneStorage('test-only-secret', $clock, maxAge: $maxAge, futureSkew: $futureSkew, maxEncodedSize: $maxEncodedSize); diff --git a/src/LuneticsTimezoneBundle.php b/src/LuneticsTimezoneBundle.php index 1b4f462..92db8d5 100644 --- a/src/LuneticsTimezoneBundle.php +++ b/src/LuneticsTimezoneBundle.php @@ -363,6 +363,9 @@ private static function validateConfig(array $config): void if (str_starts_with($cookie['name'], '__Host-') && (true !== $cookie['secure'] || '/' !== $cookie['path'] || null !== $cookie['domain'])) { throw new \InvalidArgumentException('__Host- cookies require secure=true, path=/, and no domain.'); } + if (str_starts_with($cookie['name'], '__Secure-') && true !== $cookie['secure']) { + throw new \InvalidArgumentException('__Secure- cookies require secure=true.'); + } } } diff --git a/src/Storage/CookieTimezoneStorage.php b/src/Storage/CookieTimezoneStorage.php index 928460f..d24415c 100644 --- a/src/Storage/CookieTimezoneStorage.php +++ b/src/Storage/CookieTimezoneStorage.php @@ -46,6 +46,9 @@ public function __construct( if (str_starts_with($name, '__Host-') && ('/' !== $path || null !== $domain || true !== $secure)) { throw new \InvalidArgumentException('__Host- cookies require path /, no domain, and secure transport.'); } + if (str_starts_with($name, '__Secure-') && true !== $secure) { + throw new \InvalidArgumentException('__Secure- cookies require secure transport.'); + } $key = hash_hkdf('sha256', $secret, 32, 'lunetics-timezone-cookie-v1'); $this->key = $key; } @@ -131,7 +134,7 @@ public function clear(Request $request, Response $response): void private function resolveSecure(Request $request): bool { $secure = $this->secure ?? $request->isSecure(); - if ((Cookie::SAMESITE_NONE === $this->sameSite || str_starts_with($this->name, '__Host-')) && !$secure) { + if ((Cookie::SAMESITE_NONE === $this->sameSite || str_starts_with($this->name, '__Host-') || str_starts_with($this->name, '__Secure-')) && !$secure) { throw new TimezoneStorageException('The configured timezone cookie requires a secure request.'); } return $secure; From 14846ef81495e0900690c75b934218b0124f0f13 Mon Sep 17 00:00:00 2001 From: Matthias Breddin Date: Mon, 27 Jul 2026 04:52:29 +0200 Subject: [PATCH 08/34] test: let failOnDeprecation actually fail on first-party deprecations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit phpunit.xml.dist combines failOnDeprecation=true with ignoreDirectDeprecations=true, but a direct deprecation — first-party src/ code calling a deprecated Symfony/Twig API — is exactly the class this bundle can produce, so the gate can never fire for it (PHPUnit IssueFilter drops direct triggers before evaluation). When Symfony deprecates an API in 7.x/8.x that src/ still calls, the suite stays green on every matrix row and the breakage surfaces only as a fatal error in consumer applications after the next major. Only ignoreIndirectDeprecations remains (third-party-internal noise); the full suite passes without the direct filter on PHP 8.3 / Symfony 7.4.14. --- phpunit.xml.dist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index fdc256b..c21dd03 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -12,7 +12,7 @@ Tests - + src From 26e224a73c58523c5d8c73df30e9016913f5b837 Mon Sep 17 00:00:00 2001 From: Matthias Breddin Date: Mon, 27 Jul 2026 04:53:38 +0200 Subject: [PATCH 09/34] fix: keep TimezoneDataCollector::reset() safe after profile rehydration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reset() reads the constructor-initialized properties configuredDefault and configuredResolvers, but Symfony's DataCollector serialization contract carries only $data — on a collector loaded back out of profile storage those typed properties are uninitialized and reset() fatals with a typed-property Error. Any consumer calling reset() on a rehydrated instance — a profile replay path or code walking $profile->getCollectors() — crashes; today no in-tree caller does, which is why this stayed invisible. The properties are now declared with defaults and assigned in the constructor, so a rehydrated reset() falls back to empty configuration context instead of crashing; a test pins the round trip. --- .../WebProfiler/TimezoneDataCollectorTest.php | 13 +++++++++++++ src/Bridge/WebProfiler/TimezoneDataCollector.php | 10 +++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/Tests/Bridge/WebProfiler/TimezoneDataCollectorTest.php b/Tests/Bridge/WebProfiler/TimezoneDataCollectorTest.php index 25a5fda..51e8bb7 100644 --- a/Tests/Bridge/WebProfiler/TimezoneDataCollectorTest.php +++ b/Tests/Bridge/WebProfiler/TimezoneDataCollectorTest.php @@ -62,6 +62,19 @@ public function testProfilerTemplateHasValidTwigSyntax(): void self::addToAssertionCount(1); } + public function testResetOnARehydratedCollectorFallsBackToEmptyConfiguration(): void + { + $collector = new TimezoneDataCollector('UTC', [['name' => 'request_attribute', 'priority' => 1000]]); + $restored = unserialize(serialize($collector)); + self::assertInstanceOf(TimezoneDataCollector::class, $restored); + + $restored->reset(); + + $diagnostics = $restored->getDiagnostics(); + self::assertSame('', $diagnostics['configured_default']); + self::assertSame([], $diagnostics['configured_resolvers']); + } + public function testSerializationPreservesExactDiagnostics(): void { $collector = new TimezoneDataCollector('UTC', [['name' => 'request_attribute', 'priority' => 1000]]); diff --git a/src/Bridge/WebProfiler/TimezoneDataCollector.php b/src/Bridge/WebProfiler/TimezoneDataCollector.php index 85802e4..92e8709 100644 --- a/src/Bridge/WebProfiler/TimezoneDataCollector.php +++ b/src/Bridge/WebProfiler/TimezoneDataCollector.php @@ -15,11 +15,19 @@ final class TimezoneDataCollector extends DataCollector { + // Defaults keep reset() safe on rehydrated collectors: profile + // serialization carries only $data, not constructor state. + private string $configuredDefault = ''; + /** @var list */ + private array $configuredResolvers = []; + /** * @param list $configuredResolvers */ - public function __construct(private readonly string $configuredDefault, private readonly array $configuredResolvers) + public function __construct(string $configuredDefault, array $configuredResolvers) { + $this->configuredDefault = $configuredDefault; + $this->configuredResolvers = $configuredResolvers; $this->reset(); } From c5be7e142edd160c9fec18b1d441b56c28d3732c Mon Sep 17 00:00:00 2001 From: Matthias Breddin Date: Mon, 27 Jul 2026 04:53:38 +0200 Subject: [PATCH 10/34] refactor: bind CurrentTimezoneProvider to the execution-context interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CurrentTimezoneProvider type-hints the concrete TimezoneExecutionContext while every other consumer binds TimezoneExecutionContextInterface, which is documented as a public contract. An application that aliases the interface to its own implementation pushes scopes onto the custom context (worker middleware, run() calls), but the provider keeps reading the bundle's concrete service whose stack stays empty — inside a Messenger handler getTimezone() silently returns the configured default instead of the stamped timezone. current() is now part of the interface and the provider depends on the interface, so a replaced context serves both sides consistently; the docs mention the new contract method. --- Resources/doc/installation.md | 2 +- src/Context/CurrentTimezoneProvider.php | 2 +- src/Context/TimezoneExecutionContextInterface.php | 3 +++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Resources/doc/installation.md b/Resources/doc/installation.md index cabc329..e1600bb 100644 --- a/Resources/doc/installation.md +++ b/Resources/doc/installation.md @@ -117,7 +117,7 @@ When `persistence.storage: session` is used, browser writes require Framework se ## Provider and execution context -Inject `Lunetics\TimezoneBundle\Context\CurrentTimezoneProviderInterface`. Its `getTimezone()` returns `TimezoneId`, `getDateTimeZone()` returns `\DateTimeZone`, and `getResolution()` includes source and kind. `TimezoneExecutionContextInterface::run(TimezoneId $timezone, callable $callback): mixed` temporarily overrides the current timezone for a bounded callback. +Inject `Lunetics\TimezoneBundle\Context\CurrentTimezoneProviderInterface`. Its `getTimezone()` returns `TimezoneId`, `getDateTimeZone()` returns `\DateTimeZone`, and `getResolution()` includes source and kind. `TimezoneExecutionContextInterface::run(TimezoneId $timezone, callable $callback): mixed` temporarily overrides the current timezone for a bounded callback; `current(): ?TimezoneId` exposes the innermost active scope. ## Users and OIDC diff --git a/src/Context/CurrentTimezoneProvider.php b/src/Context/CurrentTimezoneProvider.php index a43a9e4..a3d2797 100644 --- a/src/Context/CurrentTimezoneProvider.php +++ b/src/Context/CurrentTimezoneProvider.php @@ -14,7 +14,7 @@ { public const RESOLUTION_ATTRIBUTE = '_lunetics_timezone.resolution'; - public function __construct(private TimezoneExecutionContext $context, private RequestStack $requestStack, private TimezoneId $defaultTimezone) + public function __construct(private TimezoneExecutionContextInterface $context, private RequestStack $requestStack, private TimezoneId $defaultTimezone) { } diff --git a/src/Context/TimezoneExecutionContextInterface.php b/src/Context/TimezoneExecutionContextInterface.php index 57ee373..0b2ac40 100644 --- a/src/Context/TimezoneExecutionContextInterface.php +++ b/src/Context/TimezoneExecutionContextInterface.php @@ -9,4 +9,7 @@ interface TimezoneExecutionContextInterface { public function run(TimezoneId $timezone, callable $callback): mixed; + + /** Innermost explicitly scoped timezone, or null when no scope is active. */ + public function current(): ?TimezoneId; } From 2c5546f3f5a8879ee48e6afa7e8f73fab6eecc7f Mon Sep 17 00:00:00 2001 From: Matthias Breddin Date: Mon, 27 Jul 2026 05:39:17 +0200 Subject: [PATCH 11/34] fix: maintain the preference-written marker in a storage decorator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The marker duty lives in the two built-in storages plus a docblock asking custom implementations to do the same — documentation-only enforcement — and the marker lands only on the Request object handed to write(), which the main-request cleanup listener never sees for subrequest writes. A custom storage written to directly, or any write performed on a subrequest (fragment renderer, forward()), still loses a freshly written preference to the invalid-preference cleanup on kernel.response whenever the incoming request carried a corrupt or expired record — both reproduced with executed probes during review. The bundle now decorates the configured storage (built-in or custom service id) with PreferenceWriteMarkingStorage, which marks the writing request AND the main request after every successful write; the built-in storages and the browser controller no longer set the marker themselves, the compiler pass validates the undecorated storage id, and regression tests cover the subrequest and decorated-custom-storage paths for both storages. Considered re-reading the storage before clearing instead — rejected because a cookie storage cannot observe its own response-bound write on a re-read. --- Resources/doc/installation.md | 2 +- .../BrowserTimezoneControllerTest.php | 8 ++- .../LuneticsTimezoneBundleTest.php | 5 +- .../PreferenceDiagnosticsFlagsTest.php | 58 ++++++++++++++++--- src/Controller/BrowserTimezoneController.php | 1 - .../Compiler/TimezoneCompilerPass.php | 4 +- src/LuneticsTimezoneBundle.php | 11 +++- src/Storage/CookieTimezoneStorage.php | 1 - src/Storage/PreferenceWriteMarkingStorage.php | 42 ++++++++++++++ src/Storage/SessionTimezoneStorage.php | 1 - .../TimezonePreferenceStorageInterface.php | 12 ++-- 11 files changed, 117 insertions(+), 28 deletions(-) create mode 100644 src/Storage/PreferenceWriteMarkingStorage.php diff --git a/Resources/doc/installation.md b/Resources/doc/installation.md index e1600bb..7bea030 100644 --- a/Resources/doc/installation.md +++ b/Resources/doc/installation.md @@ -151,7 +151,7 @@ public function clear(Request $request, Response $response): void; The storage must preserve the preference timezone, `manual|browser` source, and recording time. Built-in session storage stores the envelope directly; cookie storage encodes a signed versioned JSON envelope. -A successful `write()` must also set the request attribute `TimezonePreferenceStorageInterface::PREFERENCE_WRITTEN_ATTRIBUTE` (`_lunetics_timezone.preference_written`) to `true`, as the built-in storages do. The bundle's cleanup listener clears invalid or expired stored preferences on the response and skips that cleanup only when this marker is present — an application write (for example a settings form persisting a `manual` preference) would otherwise be deleted again in the same request whenever the incoming request carried a corrupt or expired record. +The bundle decorates the configured storage with `PreferenceWriteMarkingStorage`, which records every successful `write()` on the current AND the main request (`TimezonePreferenceStorageInterface::PREFERENCE_WRITTEN_ATTRIBUTE`). The cleanup listener clears invalid or expired stored preferences on the response and skips that cleanup when the marker is present — so an application write (for example a settings form persisting a `manual` preference, even from a subrequest) survives the same-request cleanup. Custom storages do not need to set the marker themselves; writes only have to go through the storage service the bundle wires (inject `TimezonePreferenceStorageInterface`, not your concrete storage class). ## MaxMind City diff --git a/Tests/Controller/BrowserTimezoneControllerTest.php b/Tests/Controller/BrowserTimezoneControllerTest.php index f29eb8a..82879e8 100644 --- a/Tests/Controller/BrowserTimezoneControllerTest.php +++ b/Tests/Controller/BrowserTimezoneControllerTest.php @@ -9,6 +9,7 @@ use Lunetics\TimezoneBundle\Exception\TimezoneStorageException; use Lunetics\TimezoneBundle\Storage\PreferenceReadStatus; use Lunetics\TimezoneBundle\Storage\PreferenceSource; +use Lunetics\TimezoneBundle\Storage\PreferenceWriteMarkingStorage; use Lunetics\TimezoneBundle\Storage\TimezonePreference; use Lunetics\TimezoneBundle\Storage\TimezonePreferenceRead; use Lunetics\TimezoneBundle\Storage\TimezonePreferenceStorageInterface; @@ -17,6 +18,7 @@ use PHPUnit\Framework\TestCase; use Psr\Clock\ClockInterface; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Csrf\CsrfToken; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; @@ -56,8 +58,10 @@ public function testAcceptsVendorJsonAndWritesAndDispatchesExactlyOnce(): void $storage = new RecordingStorage(); $dispatcher = new RecordingDispatcher(); $request = $this->request('{"timezone":"Europe/Berlin"}', 'application/vnd.lunetics+json; charset=utf-8'); + $stack = new RequestStack(); + $stack->push($request); - $response = ($this->controller($storage, $dispatcher))($request); + $response = ($this->controller(new PreferenceWriteMarkingStorage($storage, $stack), $dispatcher))($request); self::assertSame(204, $response->getStatusCode()); self::assertCount(1, $storage->writes); @@ -145,7 +149,7 @@ public function testCsrfProtectionUsesDefaultTokenIdAndHeader(): void self::assertSame(204, $controller($request)->getStatusCode()); } - private function controller(RecordingStorage $storage, RecordingDispatcher $dispatcher): BrowserTimezoneController + private function controller(TimezonePreferenceStorageInterface $storage, RecordingDispatcher $dispatcher): BrowserTimezoneController { return new BrowserTimezoneController($storage, $this->clock(), $dispatcher); } diff --git a/Tests/DependencyInjection/LuneticsTimezoneBundleTest.php b/Tests/DependencyInjection/LuneticsTimezoneBundleTest.php index f5a50a6..a4daa50 100644 --- a/Tests/DependencyInjection/LuneticsTimezoneBundleTest.php +++ b/Tests/DependencyInjection/LuneticsTimezoneBundleTest.php @@ -143,10 +143,7 @@ public function testCircularCustomStorageAliasFailsWithDeterministicMessage(): v $container->setAlias('application.storage.b', 'application.storage.a'); $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage(sprintf( - 'Alias cycle detected while resolving configured timezone preference storage service "%s": application.storage.a -> application.storage.b -> application.storage.a.', - TimezonePreferenceStorageInterface::class, - )); + $this->expectExceptionMessage('Alias cycle detected while resolving configured timezone preference storage service "lunetics_timezone.storage.configured": application.storage.a -> application.storage.b -> application.storage.a.'); $container->compile(); } diff --git a/Tests/EventListener/PreferenceDiagnosticsFlagsTest.php b/Tests/EventListener/PreferenceDiagnosticsFlagsTest.php index 4037e12..bcdfe3b 100644 --- a/Tests/EventListener/PreferenceDiagnosticsFlagsTest.php +++ b/Tests/EventListener/PreferenceDiagnosticsFlagsTest.php @@ -13,6 +13,7 @@ use Lunetics\TimezoneBundle\Storage\PreferenceReadStatus; use Lunetics\TimezoneBundle\Storage\PreferenceSource; use Lunetics\TimezoneBundle\Storage\CookieTimezoneStorage; +use Lunetics\TimezoneBundle\Storage\PreferenceWriteMarkingStorage; use Lunetics\TimezoneBundle\Storage\SessionTimezoneStorage; use Lunetics\TimezoneBundle\Storage\TimezonePreference; use Lunetics\TimezoneBundle\Storage\TimezonePreferenceRead; @@ -24,6 +25,7 @@ use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; @@ -36,12 +38,12 @@ final class PreferenceDiagnosticsFlagsTest extends TestCase public function testFreshSessionPreferenceIsNotClearedBecauseResolverCachedInvalidEnvelope(): void { $clock = $this->fixedClock(); - $storage = new SessionTimezoneStorage(); $session = new Session(new MockArraySessionStorage()); $session->start(); $session->set('_lunetics_timezone', ['v' => 1]); $request = $this->browserRequest(); $request->setSession($session); + $storage = $this->marking(new SessionTimezoneStorage(), $request); $events = []; $dispatcher = $this->recordingDispatcher($events); @@ -63,9 +65,9 @@ public function testFreshSessionPreferenceIsNotClearedBecauseResolverCachedInval public function testFreshCookiePreferenceIsNotClearedBecauseResolverCachedExpiredPreference(): void { $clock = $this->fixedClock(); - $storage = new CookieTimezoneStorage('test-only-secret', $clock, maxAge: 3600); + $inner = new CookieTimezoneStorage('test-only-secret', $clock, maxAge: 3600); $expiredResponse = new Response(); - $storage->write( + $inner->write( Request::create('https://example.test'), $expiredResponse, new TimezonePreference(TimezoneId::fromString('America/New_York'), PreferenceSource::BROWSER, new \DateTimeImmutable('2025-12-31T22:59:59Z')), @@ -73,6 +75,7 @@ public function testFreshCookiePreferenceIsNotClearedBecauseResolverCachedExpire $expiredCookie = $expiredResponse->headers->getCookies()[0]; $request = $this->browserRequest('https://example.test/_lunetics/timezone/browser'); $request->cookies->set($expiredCookie->getName(), $expiredCookie->getValue()); + $storage = $this->marking($inner, $request); $events = []; $dispatcher = $this->recordingDispatcher($events); @@ -101,12 +104,12 @@ public function testFreshCookiePreferenceIsNotClearedBecauseResolverCachedExpire public function testApplicationManualSessionWriteSurvivesCleanupAfterInvalidRead(): void { - $storage = new SessionTimezoneStorage(); $session = new Session(new MockArraySessionStorage()); $session->start(); $session->set('_lunetics_timezone', ['v' => 1]); $request = Request::create('/settings', 'POST'); $request->setSession($session); + $storage = $this->marking(new SessionTimezoneStorage(), $request); (new StoredPreferenceTimezoneResolver($storage))->resolve($request); $cachedRead = $request->attributes->get(StoredPreferenceTimezoneResolver::READ_ATTRIBUTE); @@ -128,9 +131,9 @@ public function testApplicationManualSessionWriteSurvivesCleanupAfterInvalidRead public function testApplicationManualCookieWriteSurvivesCleanupAfterExpiredRead(): void { $clock = $this->fixedClock(); - $storage = new CookieTimezoneStorage('test-only-secret', $clock, maxAge: 3600); + $inner = new CookieTimezoneStorage('test-only-secret', $clock, maxAge: 3600); $expiredResponse = new Response(); - $storage->write( + $inner->write( Request::create('https://example.test'), $expiredResponse, new TimezonePreference(TimezoneId::fromString('America/New_York'), PreferenceSource::BROWSER, new \DateTimeImmutable('2025-12-31T22:59:59Z')), @@ -138,6 +141,7 @@ public function testApplicationManualCookieWriteSurvivesCleanupAfterExpiredRead( $expiredCookie = $expiredResponse->headers->getCookies()[0]; $request = Request::create('https://example.test/settings', 'POST'); $request->cookies->set($expiredCookie->getName(), $expiredCookie->getValue()); + $storage = $this->marking($inner, $request); (new StoredPreferenceTimezoneResolver($storage))->resolve($request); $cachedRead = $request->attributes->get(StoredPreferenceTimezoneResolver::READ_ATTRIBUTE); @@ -167,12 +171,12 @@ public function testApplicationManualCookieWriteSurvivesCleanupAfterExpiredRead( #[DataProvider('writeOutcomes')] public function testWrittenFlagRequiresAnActualSuccessfulWrite(bool $fail, bool $expectedFlag): void { - $storage = $this->storage($fail, false); $clock = $this->createStub(ClockInterface::class); $clock->method('now')->willReturn(new \DateTimeImmutable('2026-01-01T00:00:00Z')); $dispatcher = $this->createStub(EventDispatcherInterface::class); - $controller = new BrowserTimezoneController($storage, $clock, $dispatcher); $request = Request::create('/', 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], '{"timezone":"Europe/Berlin"}'); + $storage = $this->marking($this->storage($fail, false), $request); + $controller = new BrowserTimezoneController($storage, $clock, $dispatcher); $controller($request); @@ -231,6 +235,44 @@ public function testThrowStrategyPropagatesTypedClearFailureOnMainRequest(): voi $listener->onKernelResponse($event); } + public function testSubrequestWriteMarksTheMainRequestForCleanup(): void + { + $session = new Session(new MockArraySessionStorage()); + $session->start(); + $session->set('_lunetics_timezone', ['v' => 1]); + $mainRequest = Request::create('/page'); + $mainRequest->setSession($session); + $storage = $this->marking(new SessionTimezoneStorage(), $mainRequest); + + (new StoredPreferenceTimezoneResolver($storage))->resolve($mainRequest); + $cachedRead = $mainRequest->attributes->get(StoredPreferenceTimezoneResolver::READ_ATTRIBUTE); + self::assertInstanceOf(TimezonePreferenceRead::class, $cachedRead); + self::assertSame(PreferenceReadStatus::INVALID, $cachedRead->status); + $subRequest = Request::create('/_fragment'); + $subRequest->setSession($session); + $response = new Response(); + $storage->write($subRequest, $response, new TimezonePreference(TimezoneId::fromString('Europe/Paris'), PreferenceSource::MANUAL, new \DateTimeImmutable('2026-01-01T00:00:00Z'))); + + self::assertTrue($mainRequest->attributes->getBoolean(TimezonePreferenceStorageInterface::PREFERENCE_WRITTEN_ATTRIBUTE)); + (new InvalidPreferenceCleanupListener($storage, PersistenceFailureStrategy::CONTINUE))->onKernelResponse( + new ResponseEvent($this->createStub(HttpKernelInterface::class), $mainRequest, HttpKernelInterface::MAIN_REQUEST, $response), + ); + + self::assertFalse($mainRequest->attributes->has(InvalidPreferenceCleanupListener::PREFERENCE_CLEARED_ATTRIBUTE)); + $read = $storage->read($mainRequest); + self::assertSame(PreferenceReadStatus::VALID, $read->status); + self::assertSame('Europe/Paris', $read->preference?->timezone->value()); + self::assertSame(PreferenceSource::MANUAL, $read->preference->source); + } + + private function marking(TimezonePreferenceStorageInterface $inner, Request $mainRequest): PreferenceWriteMarkingStorage + { + $stack = new RequestStack(); + $stack->push($mainRequest); + + return new PreferenceWriteMarkingStorage($inner, $stack); + } + private function fixedClock(): ClockInterface { return new class implements ClockInterface { diff --git a/src/Controller/BrowserTimezoneController.php b/src/Controller/BrowserTimezoneController.php index 3c13c6d..f07ca3a 100644 --- a/src/Controller/BrowserTimezoneController.php +++ b/src/Controller/BrowserTimezoneController.php @@ -70,7 +70,6 @@ public function __invoke(Request $request): Response return $response; } $this->storage->write($request, $response, $current); - $request->attributes->set(self::PREFERENCE_WRITTEN_ATTRIBUTE, true); } catch (PersistenceFailureExceptionInterface) { return new Response('', Response::HTTP_SERVICE_UNAVAILABLE); } diff --git a/src/DependencyInjection/Compiler/TimezoneCompilerPass.php b/src/DependencyInjection/Compiler/TimezoneCompilerPass.php index 885d50c..a4fed6f 100644 --- a/src/DependencyInjection/Compiler/TimezoneCompilerPass.php +++ b/src/DependencyInjection/Compiler/TimezoneCompilerPass.php @@ -52,10 +52,10 @@ public function process(ContainerBuilder $container): void } } - if (!$container->hasAlias(TimezonePreferenceStorageInterface::class)) { + if (!$container->hasAlias('lunetics_timezone.storage.configured')) { return; } - $id = $this->resolveServiceId($container, TimezonePreferenceStorageInterface::class, 'timezone preference storage'); + $id = $this->resolveServiceId($container, 'lunetics_timezone.storage.configured', 'timezone preference storage'); if (!$container->hasDefinition($id)) { return; } diff --git a/src/LuneticsTimezoneBundle.php b/src/LuneticsTimezoneBundle.php index 92db8d5..5f217f2 100644 --- a/src/LuneticsTimezoneBundle.php +++ b/src/LuneticsTimezoneBundle.php @@ -34,6 +34,7 @@ use Lunetics\TimezoneBundle\Resolver\RequestAttributeTimezoneResolver; use Lunetics\TimezoneBundle\Resolver\StoredPreferenceTimezoneResolver; use Lunetics\TimezoneBundle\Storage\CookieTimezoneStorage; +use Lunetics\TimezoneBundle\Storage\PreferenceWriteMarkingStorage; use Lunetics\TimezoneBundle\Storage\PreferenceSource; use Lunetics\TimezoneBundle\Storage\SessionTimezoneStorage; use Lunetics\TimezoneBundle\Storage\TimezonePreferenceStorageInterface; @@ -45,6 +46,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Symfony\Component\HttpFoundation\Cookie; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpKernel\Bundle\AbstractBundle; use function Symfony\Component\DependencyInjection\Loader\Configurator\service; @@ -180,7 +182,7 @@ public function loadExtension(array $config, ContainerConfigurator $configurator $storage = $config['persistence']['storage']; if ('session' === $storage) { $services->set(SessionTimezoneStorage::class)->arg('$key', $config['persistence']['session']['key']); - $services->alias(TimezonePreferenceStorageInterface::class, SessionTimezoneStorage::class); + $services->alias('lunetics_timezone.storage.configured', SessionTimezoneStorage::class); } elseif ('cookie' === $storage) { $cookie = $config['persistence']['cookie']; $services->set(CookieTimezoneStorage::class) @@ -189,10 +191,13 @@ public function loadExtension(array $config, ContainerConfigurator $configurator ->arg('$domain', $cookie['domain'])->arg('$secure', 'auto' === $cookie['secure'] ? null : $cookie['secure']) ->arg('$httpOnly', $cookie['http_only'])->arg('$sameSite', $cookie['same_site']) ->arg('$futureSkew', $cookie['future_skew'])->arg('$maxEncodedSize', $cookie['max_size']); - $services->alias(TimezonePreferenceStorageInterface::class, CookieTimezoneStorage::class); + $services->alias('lunetics_timezone.storage.configured', CookieTimezoneStorage::class); } else { - $services->alias(TimezonePreferenceStorageInterface::class, $storage); + $services->alias('lunetics_timezone.storage.configured', $storage); } + $services->set(PreferenceWriteMarkingStorage::class) + ->args([service('lunetics_timezone.storage.configured'), service(RequestStack::class)]); + $services->alias(TimezonePreferenceStorageInterface::class, PreferenceWriteMarkingStorage::class); $resolution = $config['resolution']; $container->setParameter('lunetics_timezone.resolution.user', $resolution['user']); diff --git a/src/Storage/CookieTimezoneStorage.php b/src/Storage/CookieTimezoneStorage.php index d24415c..40a9e80 100644 --- a/src/Storage/CookieTimezoneStorage.php +++ b/src/Storage/CookieTimezoneStorage.php @@ -123,7 +123,6 @@ public function write(Request $request, Response $response, TimezonePreference $ } catch (\JsonException|\LengthException|\RuntimeException $failure) { throw TimezoneStorageException::operationFailed('write', $failure); } - $request->attributes->set(self::PREFERENCE_WRITTEN_ATTRIBUTE, true); } public function clear(Request $request, Response $response): void diff --git a/src/Storage/PreferenceWriteMarkingStorage.php b/src/Storage/PreferenceWriteMarkingStorage.php new file mode 100644 index 0000000..ff44fda --- /dev/null +++ b/src/Storage/PreferenceWriteMarkingStorage.php @@ -0,0 +1,42 @@ +inner->read($request); + } + + public function write(Request $request, Response $response, TimezonePreference $preference): void + { + $this->inner->write($request, $response, $preference); + $request->attributes->set(self::PREFERENCE_WRITTEN_ATTRIBUTE, true); + $main = $this->requestStack->getMainRequest(); + if (null !== $main && $main !== $request) { + $main->attributes->set(self::PREFERENCE_WRITTEN_ATTRIBUTE, true); + } + } + + public function clear(Request $request, Response $response): void + { + $this->inner->clear($request, $response); + } +} diff --git a/src/Storage/SessionTimezoneStorage.php b/src/Storage/SessionTimezoneStorage.php index 6a55062..7f8fe9a 100644 --- a/src/Storage/SessionTimezoneStorage.php +++ b/src/Storage/SessionTimezoneStorage.php @@ -49,7 +49,6 @@ public function write(Request $request, Response $response, TimezonePreference $ } catch (SessionNotFoundException|\RuntimeException $failure) { throw TimezoneStorageException::operationFailed('write', $failure); } - $request->attributes->set(self::PREFERENCE_WRITTEN_ATTRIBUTE, true); } public function clear(Request $request, Response $response): void diff --git a/src/Storage/TimezonePreferenceStorageInterface.php b/src/Storage/TimezonePreferenceStorageInterface.php index 2cd3647..fe0ce0b 100644 --- a/src/Storage/TimezonePreferenceStorageInterface.php +++ b/src/Storage/TimezonePreferenceStorageInterface.php @@ -11,11 +11,13 @@ interface TimezonePreferenceStorageInterface { /** - * Request attribute marking that a preference write succeeded during the - * current request, regardless of who triggered it. Implementations must - * set it to true inside write(); the invalid-preference cleanup listener - * skips its response-time cleanup when the marker is present so a freshly - * written preference is never cleared again within the same request. + * Request attribute set to true when a preference write succeeded during + * the current request lifecycle. The bundle maintains it by decorating the + * configured storage (PreferenceWriteMarkingStorage), so implementations + * do not need to set it themselves — writes only have to go through the + * storage service the bundle wires. The invalid-preference cleanup + * listener skips its response-time cleanup when the marker is present on + * the main request. */ public const PREFERENCE_WRITTEN_ATTRIBUTE = '_lunetics_timezone.preference_written'; From 8742aca1a7dcd0b8e48d2ce19cc40f75ce9ff1ea Mon Sep 17 00:00:00 2001 From: Matthias Breddin Date: Mon, 27 Jul 2026 05:39:33 +0200 Subject: [PATCH 12/34] fix: union MaxMind non-public ranges with Symfony's private subnets The hand-maintained range list omits Teredo (2001::/32) and IPv6 benchmarking (2001:2::/48) although IpUtils is already imported and used in the same method, and Symfony's own IpUtils::PRIVATE_SUBNETS differs between versions (7.x dropped the documentation and benchmarking ranges that 6.4 lists), so neither source alone is complete. A client behind a Teredo tunnel or a benchmarking address passes both the filter_var gate and the explicit list and reaches the third-party MaxMind reader. explicitlyNonPublicRanges() now returns the union of IpUtils::PRIVATE_SUBNETS and the explicit special-purpose list, so the resolver rejects the superset regardless of the installed Symfony version; Teredo and benchmarking cases join the data provider. --- Tests/Resolver/MaxMindTimezoneResolverTest.php | 2 ++ src/Resolver/MaxMindTimezoneResolver.php | 14 ++++++++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/Tests/Resolver/MaxMindTimezoneResolverTest.php b/Tests/Resolver/MaxMindTimezoneResolverTest.php index 715bf86..5939a35 100644 --- a/Tests/Resolver/MaxMindTimezoneResolverTest.php +++ b/Tests/Resolver/MaxMindTimezoneResolverTest.php @@ -43,6 +43,8 @@ public static function nonPublicAddresses(): iterable yield 'ipv6 well-known nat64' => ['64:ff9b::808:808']; yield 'ipv6 local-use nat64' => ['64:ff9b:1::1']; yield 'ipv6 6to4' => ['2002:a00:1::1']; + yield 'ipv6 teredo' => ['2001::1']; + yield 'ipv6 benchmarking' => ['2001:2::1']; } #[DataProvider('nonPublicAddresses')] diff --git a/src/Resolver/MaxMindTimezoneResolver.php b/src/Resolver/MaxMindTimezoneResolver.php index 43407f0..b6260c8 100644 --- a/src/Resolver/MaxMindTimezoneResolver.php +++ b/src/Resolver/MaxMindTimezoneResolver.php @@ -48,15 +48,17 @@ public function resolve(Request $request): ?TimezoneResolution /** @return list */ private static function explicitlyNonPublicRanges(): array { - // PHP's reserved-range filter does not consistently reject multicast - // and documentation networks across supported PHP releases. - return [ + // Union of Symfony's private-subnet list (its contents differ between + // Symfony versions) and networks PHP's reserved-range filter does not + // consistently reject: multicast, documentation, benchmarking, + // discard-only and other special-purpose ranges. + return array_values(array_unique(array_merge(IpUtils::PRIVATE_SUBNETS, [ '0.0.0.0/8', '100.64.0.0/10', '192.0.0.0/24', '192.0.2.0/24', '198.18.0.0/15', '198.51.100.0/24', '203.0.113.0/24', '224.0.0.0/4', '240.0.0.0/4', '::/128', '::ffff:0:0/96', '64:ff9b::/96', '64:ff9b:1::/48', - '100::/64', '2001:db8::/32', '2002::/16', 'fc00::/7', - 'fe80::/10', 'ff00::/8', - ]; + '100::/64', '2001:2::/48', '2001:db8::/32', '2002::/16', + 'fc00::/7', 'fe80::/10', 'ff00::/8', + ]))); } } From 3abe0f83e32358876783e92cab57bdfe33f0ccac Mon Sep 17 00:00:00 2001 From: Matthias Breddin Date: Mon, 27 Jul 2026 05:39:33 +0200 Subject: [PATCH 13/34] test: harden the distribution-archive gate against silent degradation The tar reader keeps only regular-file entries and the test skips on an empty shell_exec result, so a tracked symlink ships invisibly to the allowlist (git archives it with type flag '2'), a PAX long-path entry would be truncated to its 100-byte ustar name, and a failed or missing git binary turns the sole distribution gate into a skipped test that exits green. The parser now resolves PAX path records and GNU long names, includes symlinks as path-bearing entries, and fails loudly on unsupported entry types; git archive runs through proc_open with captured stderr and an asserted exit code, so only a genuine non-checkout environment skips. --- Tests/Distribution/ExportPolicyTest.php | 85 +++++++++++++++++++++---- 1 file changed, 73 insertions(+), 12 deletions(-) diff --git a/Tests/Distribution/ExportPolicyTest.php b/Tests/Distribution/ExportPolicyTest.php index 3bb82ff..a042aed 100644 --- a/Tests/Distribution/ExportPolicyTest.php +++ b/Tests/Distribution/ExportPolicyTest.php @@ -37,10 +37,20 @@ public function testDistributionArchiveContainsOnlyAllowedPaths(): void if (!is_dir($root.'/.git')) { self::markTestSkipped('Distribution policy requires a git checkout.'); } - $tar = shell_exec(sprintf('git -C %s archive --worktree-attributes HEAD', escapeshellarg($root))); - if (!is_string($tar) || '' === $tar) { - self::markTestSkipped('git archive is unavailable in this environment.'); - } + $pipes = []; + $process = proc_open( + ['git', '-C', $root, 'archive', '--worktree-attributes', 'HEAD'], + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + ); + self::assertIsResource($process, 'Unable to start git archive.'); + $tar = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $exitCode = proc_close($process); + self::assertIsString($tar); + self::assertSame(0, $exitCode, sprintf('git archive failed (exit %d): %s', $exitCode, is_string($stderr) ? trim($stderr) : '')); $entries = self::tarEntries($tar); self::assertNotSame([], $entries, 'The distribution archive is empty.'); @@ -59,8 +69,11 @@ public function testDistributionArchiveContainsOnlyAllowedPaths(): void } /** - * Minimal ustar reader: 512-byte headers, name at offset 0 (100 bytes), - * octal size at 124 (12 bytes), optional name prefix at 345 (155 bytes). + * Minimal ustar/PAX reader: 512-byte headers, name at offset 0 (100 + * bytes), octal size at 124 (12 bytes), type flag at 156, name prefix at + * 345 (155 bytes). PAX extended headers ('x') may override the following + * entry's path; GNU long-name entries ('L') carry it as data. Unsupported + * entry types fail the test instead of being silently dropped. * * @return list */ @@ -69,22 +82,70 @@ private static function tarEntries(string $tar): array $entries = []; $offset = 0; $length = strlen($tar); + $pendingPath = null; while ($offset + 512 <= $length) { $header = substr($tar, $offset, 512); $name = rtrim(substr($header, 0, 100), "\0"); if ('' === $name) { break; } - $prefix = rtrim(substr($header, 345, 155), "\0"); - $path = '' === $prefix ? $name : $prefix.'/'.$name; - $typeFlag = substr($header, 156, 1); - if (in_array($typeFlag, ['0', "\0"], true) && !str_ends_with($path, '/')) { - $entries[] = $path; - } $size = (int) octdec(trim(substr($header, 124, 12), " \0")); + $data = substr($tar, $offset + 512, $size); $offset += 512 + (int) (ceil($size / 512) * 512); + $typeFlag = substr($header, 156, 1); + if ('g' === $typeFlag) { + continue; + } + if ('x' === $typeFlag) { + $pendingPath = self::paxPath($data) ?? $pendingPath; + continue; + } + if ('L' === $typeFlag) { + $pendingPath = rtrim($data, "\0"); + continue; + } + $prefix = rtrim(substr($header, 345, 155), "\0"); + $path = $pendingPath ?? ('' === $prefix ? $name : $prefix.'/'.$name); + $pendingPath = null; + if ('5' === $typeFlag || str_ends_with($path, '/')) { + continue; + } + self::assertContains($typeFlag, ['0', "\0", '2'], sprintf( + 'Unsupported tar entry type "%s" for "%s" — extend the parser before trusting this gate.', + addslashes($typeFlag), + $path, + )); + $entries[] = $path; } return $entries; } + + /** + * PAX records have the form " =\n" where + * the length covers the complete record including itself. + */ + private static function paxPath(string $data): ?string + { + $offset = 0; + $path = null; + $length = strlen($data); + while ($offset < $length) { + $space = strpos($data, ' ', $offset); + if (false === $space) { + break; + } + $recordLength = (int) substr($data, $offset, $space - $offset); + if ($recordLength <= 0) { + break; + } + $record = substr($data, $space + 1, $offset + $recordLength - $space - 2); + $offset += $recordLength; + if (str_starts_with($record, 'path=')) { + $path = substr($record, 5); + } + } + + return $path; + } } From 8bf1c3a2b1756ffde4fd8fbbd62de740894b62e4 Mon Sep 17 00:00:00 2001 From: Matthias Breddin Date: Mon, 27 Jul 2026 05:39:33 +0200 Subject: [PATCH 14/34] docs: sync the plan document with the implemented contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public-contracts listing still shows the execution-context interface without current() and the cookie-security sentence omits the __Secure- rule, both added to the code in the previous fix loop — installation.md was updated then, the plan document was not. Anyone porting against the plan document reads a stale contract. The interface listing now includes current() and the cookie rules name __Secure- alongside __Host-. --- Resources/doc/v2-implementation-plan.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Resources/doc/v2-implementation-plan.md b/Resources/doc/v2-implementation-plan.md index 7dcf4e6..8600f2e 100644 --- a/Resources/doc/v2-implementation-plan.md +++ b/Resources/doc/v2-implementation-plan.md @@ -64,6 +64,7 @@ interface CurrentTimezoneProviderInterface interface TimezoneExecutionContextInterface { public function run(TimezoneId $timezone, callable $callback): mixed; + public function current(): ?TimezoneId; } interface TimezoneAwareUserInterface @@ -119,7 +120,7 @@ new TimezoneStamp(string $timezone); Every built-in storage envelope has exactly these keys in order: `v`, `timezone`, `source`, `recorded_at`. Version is integer `1`; timezone is a validated IANA identifier; source is `manual|browser`; recording time is an ISO-8601/ATOM string. Session storage stores this array under its configured key and does not start a missing session merely to read or clear it. -Cookie storage serializes the same versioned envelope as JSON, base64url-encodes it, and appends a base64url HMAC-SHA-256 signature over the encoded payload. The HMAC key is derived from the configured secret using HKDF-SHA-256, length 32, info `lunetics-timezone-cookie-v1`. Reads use constant-time signature comparison, enforce encoded-size, schema/version, timezone/source/time, future-skew, and maximum-age checks, and classify bad input without exposing it. Cookie settings cover name, age, path, domain, secure, HttpOnly, SameSite, skew, and maximum size. `SameSite=None` and `__Host-` require explicit `secure=true`; `__Host-` also requires `/` and no domain. +Cookie storage serializes the same versioned envelope as JSON, base64url-encodes it, and appends a base64url HMAC-SHA-256 signature over the encoded payload. The HMAC key is derived from the configured secret using HKDF-SHA-256, length 32, info `lunetics-timezone-cookie-v1`. Reads use constant-time signature comparison, enforce encoded-size, schema/version, timezone/source/time, future-skew, and maximum-age checks, and classify bad input without exposing it. Cookie settings cover name, age, path, domain, secure, HttpOnly, SameSite, skew, and maximum size. `SameSite=None`, `__Host-`, and `__Secure-` require explicit `secure=true`; `__Host-` also requires `/` and no domain. Custom storage is selected by service ID and must implement the exact request/response-aware interface. V2 ships no Doctrine or Redis implementation. From a0689e4233a0ac13f9bb5bf378de169e02a41cfb Mon Sep 17 00:00:00 2001 From: Matthias Breddin Date: Mon, 27 Jul 2026 05:39:33 +0200 Subject: [PATCH 15/34] test: pin CurrentTimezoneProvider to the execution-context interface The provider's constructor binding to TimezoneExecutionContextInterface was fixed without a regression test, so a future revert to the concrete class would pass the suite silently. An application re-aliasing the interface would again observe the provider reading the bundle's concrete context while middleware pushes onto the custom one. A reflection test asserts the constructor parameter type is the interface. --- Tests/Context/TimezoneExecutionContextTest.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Tests/Context/TimezoneExecutionContextTest.php b/Tests/Context/TimezoneExecutionContextTest.php index a8f8154..1513100 100644 --- a/Tests/Context/TimezoneExecutionContextTest.php +++ b/Tests/Context/TimezoneExecutionContextTest.php @@ -6,6 +6,7 @@ use Lunetics\TimezoneBundle\Context\CurrentTimezoneProvider; use Lunetics\TimezoneBundle\Context\TimezoneExecutionContext; +use Lunetics\TimezoneBundle\Context\TimezoneExecutionContextInterface; use Lunetics\TimezoneBundle\Resolution\ResolutionKind; use Lunetics\TimezoneBundle\Resolution\TimezoneResolution; use Lunetics\TimezoneBundle\Timezone\TimezoneId; @@ -63,4 +64,13 @@ public function testProviderUsesExecutionContextThenMainRequestThenDefault(): vo }); self::assertSame(ResolutionKind::DEFAULT, $provider->getResolutionForRequest(new Request())->kind); } + + public function testProviderBindsTheExecutionContextInterface(): void + { + $parameters = (new \ReflectionClass(CurrentTimezoneProvider::class))->getConstructor()?->getParameters() ?? []; + self::assertNotSame([], $parameters); + $type = $parameters[0]->getType(); + self::assertInstanceOf(\ReflectionNamedType::class, $type); + self::assertSame(TimezoneExecutionContextInterface::class, $type->getName()); + } } From 57218c09fe618c4381565bab583f1a3cfd07a318 Mon Sep 17 00:00:00 2001 From: Matthias Breddin Date: Mon, 27 Jul 2026 06:05:54 +0200 Subject: [PATCH 16/34] fix: decorate the configured storage id instead of aliasing around it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The marking wrapper was wired as a second service id plus an interface alias, so the configured storage stayed reachable undecorated under its own class/service id — an app autowiring the concrete class (or its own custom storage class) bypassed the write tracking and reproduced the cleanup data-loss bug the wrapper exists to prevent; three independent review arms confirmed the bypass with compiled-container probes. Any preference write obtained via the concrete id set no marker, so the invalid-preference cleanup deleted the fresh value on kernel.response. The bundle now uses real Symfony service decoration on the configured id: every reference — interface alias, built-in class id, custom service id — resolves to PreferenceWriteMarkingStorage, and a concrete type-hint on an application class fails loudly at wiring time instead of silently losing the marker. Container tests pin the decorated alias shape and, per the same review, the provider's honouring of a replaced execution context; UPGRADE documents the injection contract. --- .../LuneticsTimezoneBundleTest.php | 60 +++++++++++++++++++ UPGRADE-2.0.md | 2 +- src/LuneticsTimezoneBundle.php | 16 +++-- 3 files changed, 72 insertions(+), 6 deletions(-) diff --git a/Tests/DependencyInjection/LuneticsTimezoneBundleTest.php b/Tests/DependencyInjection/LuneticsTimezoneBundleTest.php index a4daa50..3f6f835 100644 --- a/Tests/DependencyInjection/LuneticsTimezoneBundleTest.php +++ b/Tests/DependencyInjection/LuneticsTimezoneBundleTest.php @@ -16,6 +16,8 @@ use Lunetics\TimezoneBundle\Bridge\Twig\TwigTimezoneSubscriber; use Lunetics\TimezoneBundle\Bridge\WebProfiler\TimezoneDataCollector; use Lunetics\TimezoneBundle\Clock\SystemClock; +use Lunetics\TimezoneBundle\Context\CurrentTimezoneProviderInterface; +use Lunetics\TimezoneBundle\Context\TimezoneExecutionContextInterface; use Lunetics\TimezoneBundle\Contract\Oidc\OidcClaimsProviderInterface; use Lunetics\TimezoneBundle\Contract\User\UserTimezoneAccessorInterface; use Lunetics\TimezoneBundle\Controller\BrowserTimezoneController; @@ -25,10 +27,15 @@ use Lunetics\TimezoneBundle\Resolver\OidcTimezoneResolver; use Lunetics\TimezoneBundle\Resolver\TimezoneResolverInterface; use Lunetics\TimezoneBundle\Resolver\UserTimezoneResolver; +use Lunetics\TimezoneBundle\Storage\PreferenceWriteMarkingStorage; +use Lunetics\TimezoneBundle\Storage\SessionTimezoneStorage; use Lunetics\TimezoneBundle\Storage\TimezonePreferenceStorageInterface; +use Lunetics\TimezoneBundle\Timezone\TimezoneId; use PHPUnit\Framework\TestCase; use Psr\Clock\ClockInterface; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\Compiler\PassConfig; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; @@ -203,6 +210,38 @@ public function testInvalidCookieNameIsRejectedByConfiguration(): void $container->compile(); } + public function testEveryStorageReferenceResolvesToTheMarkingDecorator(): void + { + $container = $this->container(); + $container->compile(); + + self::assertTrue($container->hasAlias(SessionTimezoneStorage::class), 'The concrete storage id must be decorated (alias to the decorator).'); + self::assertSame(PreferenceWriteMarkingStorage::class, (string) $container->getAlias(SessionTimezoneStorage::class)); + self::assertSame(PreferenceWriteMarkingStorage::class, (string) $container->getAlias(TimezonePreferenceStorageInterface::class)); + } + + public function testProviderObservesAReplacedExecutionContext(): void + { + $container = $this->container([], static function (ContainerBuilder $container): void { + $container->setDefinition(RecordingExecutionContext::class, new Definition(RecordingExecutionContext::class))->setPublic(true); + $container->addCompilerPass(new class implements CompilerPassInterface { + public function process(ContainerBuilder $container): void + { + $container->setAlias(TimezoneExecutionContextInterface::class, RecordingExecutionContext::class)->setPublic(true); + } + }, PassConfig::TYPE_BEFORE_OPTIMIZATION, -100); + }); + $container->compile(); + + $context = $container->get(TimezoneExecutionContextInterface::class); + self::assertInstanceOf(RecordingExecutionContext::class, $context); + $provider = $container->get(CurrentTimezoneProviderInterface::class); + self::assertInstanceOf(CurrentTimezoneProviderInterface::class, $provider); + $context->run(TimezoneId::fromString('Asia/Tokyo'), static function () use ($provider): void { + self::assertSame('Asia/Tokyo', $provider->getTimezone()->value()); + }); + } + public function testSecurePrefixedCookieNameRequiresExplicitSecureTrue(): void { $container = $this->container(['persistence' => ['storage' => 'cookie', 'cookie' => ['name' => '__Secure-tz', 'secret' => 'test-only-secret']]]); @@ -556,3 +595,24 @@ public function now(): \DateTimeImmutable return new \DateTimeImmutable('2026-01-01T00:00:00Z'); } } + +final class RecordingExecutionContext implements TimezoneExecutionContextInterface +{ + /** @var list */ + private array $stack = []; + + public function run(TimezoneId $timezone, callable $callback): mixed + { + $this->stack[] = $timezone; + try { + return $callback(); + } finally { + array_pop($this->stack); + } + } + + public function current(): ?TimezoneId + { + return [] === $this->stack ? null : $this->stack[count($this->stack) - 1]; + } +} diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index 5886448..d41af04 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -13,7 +13,7 @@ V2 is a clean break. It contains no backward-compatibility layer, aliases, adapt ## Persistence and identity -V2 storage implements `TimezonePreferenceStorageInterface`; its `read()` receives a `Request`, while `write()` and `clear()` receive both `Request` and `Response`. Built-in state uses the versioned envelope `v`, `timezone`, `source`, `recorded_at`. Old session/cookie values are not a supported V2 format. +V2 storage implements `TimezonePreferenceStorageInterface`; its `read()` receives a `Request`, while `write()` and `clear()` receive both `Request` and `Response`. Built-in state uses the versioned envelope `v`, `timezone`, `source`, `recorded_at`. Old session/cookie values are not a supported V2 format. The configured storage service is decorated by the bundle (write tracking for the invalid-preference cleanup): type-hint `TimezonePreferenceStorageInterface` when injecting it — the concrete class id resolves to the decorator, so a concrete type-hint fails loudly instead of bypassing the tracking. Authenticated users either implement `TimezoneAwareUserInterface::getTimezone(): TimezoneId|string|null` or use an explicit `UserTimezoneAccessorInterface`. OIDC claims require an explicit `OidcClaimsProviderInterface`; the default claim is `zoneinfo`, and no reflection/provider-specific integration remains. The public resolution source is `oidc_zoneinfo` for the default `zoneinfo` claim. Any customized claim uses `oidc_claim_`, where `` is the first 16 lowercase hexadecimal SHA-256 characters of the exact case-sensitive configured claim name. This bounded hash keeps raw/custom claim names out of diagnostics while remaining stable. diff --git a/src/LuneticsTimezoneBundle.php b/src/LuneticsTimezoneBundle.php index 5f217f2..b1ea64a 100644 --- a/src/LuneticsTimezoneBundle.php +++ b/src/LuneticsTimezoneBundle.php @@ -182,7 +182,7 @@ public function loadExtension(array $config, ContainerConfigurator $configurator $storage = $config['persistence']['storage']; if ('session' === $storage) { $services->set(SessionTimezoneStorage::class)->arg('$key', $config['persistence']['session']['key']); - $services->alias('lunetics_timezone.storage.configured', SessionTimezoneStorage::class); + $configuredStorageId = SessionTimezoneStorage::class; } elseif ('cookie' === $storage) { $cookie = $config['persistence']['cookie']; $services->set(CookieTimezoneStorage::class) @@ -191,13 +191,19 @@ public function loadExtension(array $config, ContainerConfigurator $configurator ->arg('$domain', $cookie['domain'])->arg('$secure', 'auto' === $cookie['secure'] ? null : $cookie['secure']) ->arg('$httpOnly', $cookie['http_only'])->arg('$sameSite', $cookie['same_site']) ->arg('$futureSkew', $cookie['future_skew'])->arg('$maxEncodedSize', $cookie['max_size']); - $services->alias('lunetics_timezone.storage.configured', CookieTimezoneStorage::class); + $configuredStorageId = CookieTimezoneStorage::class; } else { - $services->alias('lunetics_timezone.storage.configured', $storage); + $configuredStorageId = $storage; } + // Real service decoration: EVERY reference to the configured storage — + // interface alias, built-in class id, or custom service id — resolves + // to the marking decorator; the undecorated storage stays reachable + // only as ".inner". + $services->alias('lunetics_timezone.storage.configured', $configuredStorageId); $services->set(PreferenceWriteMarkingStorage::class) - ->args([service('lunetics_timezone.storage.configured'), service(RequestStack::class)]); - $services->alias(TimezonePreferenceStorageInterface::class, PreferenceWriteMarkingStorage::class); + ->decorate($configuredStorageId) + ->args([service('.inner'), service(RequestStack::class)]); + $services->alias(TimezonePreferenceStorageInterface::class, $configuredStorageId); $resolution = $config['resolution']; $container->setParameter('lunetics_timezone.resolution.user', $resolution['user']); From c5d54c8e02fd6cc81ce9b525bc6815c08e85c87e Mon Sep 17 00:00:00 2001 From: Matthias Breddin Date: Mon, 27 Jul 2026 06:05:54 +0200 Subject: [PATCH 17/34] fix: make the explicit MaxMind range list self-sufficient again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The union delegated Teredo rejection to IpUtils::PRIVATE_SUBNETS, but symfony/http-foundation v6.4.0 — the exact floor the prefer-lowest CI job resolves — ships the constant without 2001::/32, so the new teredo test case fails deterministically on that job and Teredo addresses (which embed the client's private IPv4, RFC 4380) reach the MaxMind reader on old 6.4.x deployments. composer update --prefer-lowest downgrades to v6.4.0 and the suite goes red; production apps on the same floor leak the range. 2001::/32 joins the explicit list, which stays a superset of every range the resolver promises to reject on ANY supported Symfony version; PRIVATE_SUBNETS can now only ever widen the guard. --- src/Resolver/MaxMindTimezoneResolver.php | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/Resolver/MaxMindTimezoneResolver.php b/src/Resolver/MaxMindTimezoneResolver.php index b6260c8..fc7d2ae 100644 --- a/src/Resolver/MaxMindTimezoneResolver.php +++ b/src/Resolver/MaxMindTimezoneResolver.php @@ -48,17 +48,18 @@ public function resolve(Request $request): ?TimezoneResolution /** @return list */ private static function explicitlyNonPublicRanges(): array { - // Union of Symfony's private-subnet list (its contents differ between - // Symfony versions) and networks PHP's reserved-range filter does not - // consistently reject: multicast, documentation, benchmarking, - // discard-only and other special-purpose ranges. + // Union of Symfony's private-subnet list and networks PHP's + // reserved-range filter does not consistently reject. The explicit + // list must stay self-sufficient: PRIVATE_SUBNETS differs between + // Symfony versions (v6.4.0 lacks Teredo; 7.x dropped documentation + // and benchmarking ranges), so the union may only ever widen it. return array_values(array_unique(array_merge(IpUtils::PRIVATE_SUBNETS, [ '0.0.0.0/8', '100.64.0.0/10', '192.0.0.0/24', '192.0.2.0/24', '198.18.0.0/15', '198.51.100.0/24', '203.0.113.0/24', '224.0.0.0/4', '240.0.0.0/4', '::/128', '::ffff:0:0/96', '64:ff9b::/96', '64:ff9b:1::/48', - '100::/64', '2001:2::/48', '2001:db8::/32', '2002::/16', - 'fc00::/7', 'fe80::/10', 'ff00::/8', + '100::/64', '2001::/32', '2001:2::/48', '2001:db8::/32', + '2002::/16', 'fc00::/7', 'fe80::/10', 'ff00::/8', ]))); } } From 5050d4ca19278df4c5aa1f26ec2e0d10bba2aa59 Mon Sep 17 00:00:00 2001 From: Matthias Breddin Date: Mon, 27 Jul 2026 06:05:54 +0200 Subject: [PATCH 18/34] test: close the remaining distribution-gate bypasses The checkout guard uses is_dir('.git'), which is false in a linked git worktree where .git is a pointer file, and --worktree-attributes makes the test validate the working tree's attribute rules instead of the committed HEAD a release tag actually ships; a PAX header without a path record also inherited a stale pending path from an earlier header. A worktree checkout silently skips the only distribution check, and uncommitted .gitattributes edits could green-light an archive that differs from the released one. The guard is file_exists() now, the archive is taken from committed HEAD attributes, and an 'x' header without path= resets the pending path instead of reusing a stale one. --- Tests/Distribution/ExportPolicyTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/Distribution/ExportPolicyTest.php b/Tests/Distribution/ExportPolicyTest.php index a042aed..f1d8382 100644 --- a/Tests/Distribution/ExportPolicyTest.php +++ b/Tests/Distribution/ExportPolicyTest.php @@ -34,12 +34,12 @@ final class ExportPolicyTest extends TestCase public function testDistributionArchiveContainsOnlyAllowedPaths(): void { $root = dirname(__DIR__, 2); - if (!is_dir($root.'/.git')) { + if (!file_exists($root.'/.git')) { self::markTestSkipped('Distribution policy requires a git checkout.'); } $pipes = []; $process = proc_open( - ['git', '-C', $root, 'archive', '--worktree-attributes', 'HEAD'], + ['git', '-C', $root, 'archive', 'HEAD'], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, ); @@ -97,7 +97,7 @@ private static function tarEntries(string $tar): array continue; } if ('x' === $typeFlag) { - $pendingPath = self::paxPath($data) ?? $pendingPath; + $pendingPath = self::paxPath($data); continue; } if ('L' === $typeFlag) { From b8750d001bc18f4be1d2f3aae2f9271cf1394bf5 Mon Sep 17 00:00:00 2001 From: Matthias Breddin Date: Mon, 27 Jul 2026 06:05:54 +0200 Subject: [PATCH 19/34] test: give the controller suppression tests a marker that could fire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MANUAL-wins and storage-failure tests assert the written marker is absent while injecting a bare RecordingStorage into a controller that no longer marks — nothing under test could ever set the attribute, so the assertions became vacuous when the marker moved into the decorator. A regression that marks before the suppression early-returns or before a failing write would pass both tests unnoticed. Both tests now wrap their storage in PreferenceWriteMarkingStorage, so the absent-marker assertions guard the actual mechanism. --- Tests/Controller/BrowserTimezoneControllerTest.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Tests/Controller/BrowserTimezoneControllerTest.php b/Tests/Controller/BrowserTimezoneControllerTest.php index 82879e8..6985a35 100644 --- a/Tests/Controller/BrowserTimezoneControllerTest.php +++ b/Tests/Controller/BrowserTimezoneControllerTest.php @@ -80,8 +80,10 @@ public function testStoredPreferenceCanSuppressWrite(PreferenceSource $source, s $storage = new RecordingStorage(new TimezonePreferenceRead(PreferenceReadStatus::VALID, $this->preference($timezone, $source))); $dispatcher = new RecordingDispatcher(); $request = $this->request('{"timezone":"Europe/Berlin"}', 'application/json'); + $stack = new RequestStack(); + $stack->push($request); - $response = ($this->controller($storage, $dispatcher))($request); + $response = ($this->controller(new PreferenceWriteMarkingStorage($storage, $stack), $dispatcher))($request); self::assertSame(204, $response->getStatusCode()); self::assertSame([], $storage->writes); @@ -102,8 +104,10 @@ public function testStorageFailuresReturnServiceUnavailableAndDispatchNothing(bo $storage = new RecordingStorage(failRead: $failRead, failWrite: $failWrite); $dispatcher = new RecordingDispatcher(); $request = $this->request('{"timezone":"Europe/Berlin"}', 'application/json'); + $stack = new RequestStack(); + $stack->push($request); - $response = ($this->controller($storage, $dispatcher))($request); + $response = ($this->controller(new PreferenceWriteMarkingStorage($storage, $stack), $dispatcher))($request); self::assertSame(503, $response->getStatusCode()); self::assertFalse($request->attributes->has(BrowserTimezoneController::PREFERENCE_WRITTEN_ATTRIBUTE)); From ebb3c9f88794a6996964d34e903288c8bfa9f5d5 Mon Sep 17 00:00:00 2001 From: Matthias Breddin Date: Fri, 31 Jul 2026 00:59:46 +0200 Subject: [PATCH 20/34] fix: scope the preference-written marker to the storage's write medium MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The marking decorator propagates every subrequest write to the main request, but a cookie write lives on the response it was handed. When a fragment-rendered controller writes, FragmentHandler discards that response's headers, so the fresh cookie never reaches the client while the propagated marker still suppresses the main-request cleanup — the stale cookie survives and the request self-heals only once the fragment stops writing. An expired or invalid preference cookie plus a preference write from a fragment controller reproduces it; forwarded controllers are unaffected because their response becomes the main one. Storage now declares its medium: ResponseScopedStorageInterface marks writes carried by a Response, and the decorator propagates the marker across requests only for lifecycle-scoped storage such as the session. Cookie storage refuses to clear a preference it has just written to the same response, so the forward case keeps its fresh cookie while the fragment case clears the stale one. Custom storage without the marker keeps the data-preserving lifecycle behaviour. Considered re-reading the storage before clearing — rejected because a cookie storage cannot observe its own response-bound write on a re-read. --- Resources/doc/installation.md | 2 + .../PreferenceDiagnosticsFlagsTest.php | 73 +++++++++++++++++++ src/Storage/CookieTimezoneStorage.php | 21 +++++- src/Storage/PreferenceWriteMarkingStorage.php | 15 +++- .../ResponseScopedStorageInterface.php | 22 ++++++ 5 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 src/Storage/ResponseScopedStorageInterface.php diff --git a/Resources/doc/installation.md b/Resources/doc/installation.md index 7bea030..ebfd36d 100644 --- a/Resources/doc/installation.md +++ b/Resources/doc/installation.md @@ -153,6 +153,8 @@ The storage must preserve the preference timezone, `manual|browser` source, and The bundle decorates the configured storage with `PreferenceWriteMarkingStorage`, which records every successful `write()` on the current AND the main request (`TimezonePreferenceStorageInterface::PREFERENCE_WRITTEN_ATTRIBUTE`). The cleanup listener clears invalid or expired stored preferences on the response and skips that cleanup when the marker is present — so an application write (for example a settings form persisting a `manual` preference, even from a subrequest) survives the same-request cleanup. Custom storages do not need to set the marker themselves; writes only have to go through the storage service the bundle wires (inject `TimezonePreferenceStorageInterface`, not your concrete storage class). +Storage that carries its writes on the `Response` instead of a lifecycle-wide backend — cookie storage, and any custom storage implementing `ResponseScopedStorageInterface` — is treated differently on subrequests: its marker is not propagated to the main request, because a fragment response is discarded before it reaches the client. The cleanup then still removes the stale record, and the storage refuses to clear a preference it has just written to the same response. + ## MaxMind City Install `geoip2/geoip2` and point `resolution.maxmind.database` at a GeoLite2 City or GeoIP2 City database. Both use the reader's `city()` lookup. Country and ASN databases are unsupported. GeoIP2 Enterprise is supported only across the custom reader boundary: configure `resolution.maxmind.reader` with a service implementing `MaxMindCityReaderInterface::timezoneForIp(string $ipAddress): TimezoneId|string|null`, or adapt a callable with `CallableMaxMindCityReader`. Exactly one of `database` and `reader` is required when enabled. diff --git a/Tests/EventListener/PreferenceDiagnosticsFlagsTest.php b/Tests/EventListener/PreferenceDiagnosticsFlagsTest.php index bcdfe3b..c9ea286 100644 --- a/Tests/EventListener/PreferenceDiagnosticsFlagsTest.php +++ b/Tests/EventListener/PreferenceDiagnosticsFlagsTest.php @@ -265,6 +265,79 @@ public function testSubrequestWriteMarksTheMainRequestForCleanup(): void self::assertSame(PreferenceSource::MANUAL, $read->preference->source); } + public function testDiscardedSubrequestCookieWriteStillClearsTheStaleCookie(): void + { + $clock = $this->fixedClock(); + $inner = new CookieTimezoneStorage('test-only-secret', $clock, maxAge: 3600); + $expiredResponse = new Response(); + $inner->write( + Request::create('https://example.test'), + $expiredResponse, + new TimezonePreference(TimezoneId::fromString('America/New_York'), PreferenceSource::BROWSER, new \DateTimeImmutable('2025-12-31T22:00:00Z')), + ); + $expiredCookie = $expiredResponse->headers->getCookies()[0]; + $mainRequest = Request::create('https://example.test/page'); + $mainRequest->cookies->set($expiredCookie->getName(), $expiredCookie->getValue()); + $stack = new RequestStack(); + $stack->push($mainRequest); + $storage = new PreferenceWriteMarkingStorage($inner, $stack); + + (new StoredPreferenceTimezoneResolver($storage))->resolve($mainRequest); + $subRequest = Request::create('https://example.test/_fragment'); + $stack->push($subRequest); + $storage->write($subRequest, new Response(), new TimezonePreference(TimezoneId::fromString('Europe/Paris'), PreferenceSource::MANUAL, $clock->now())); + $stack->pop(); + + self::assertFalse($mainRequest->attributes->has(TimezonePreferenceStorageInterface::PREFERENCE_WRITTEN_ATTRIBUTE), 'A response-scoped write must not suppress cleanup on a different response.'); + $mainResponse = new Response(); + (new InvalidPreferenceCleanupListener($storage, PersistenceFailureStrategy::CONTINUE))->onKernelResponse( + new ResponseEvent($this->createStub(HttpKernelInterface::class), $mainRequest, HttpKernelInterface::MAIN_REQUEST, $mainResponse), + ); + + $cookies = $mainResponse->headers->getCookies(); + self::assertCount(1, $cookies); + self::assertSame('', (string) $cookies[0]->getValue()); + self::assertLessThan($clock->now()->getTimestamp(), $cookies[0]->getExpiresTime()); + self::assertTrue($mainRequest->attributes->getBoolean(InvalidPreferenceCleanupListener::PREFERENCE_CLEARED_ATTRIBUTE)); + } + + public function testForwardedCookieWriteSurvivesCleanupOnTheSharedResponse(): void + { + $clock = $this->fixedClock(); + $inner = new CookieTimezoneStorage('test-only-secret', $clock, maxAge: 3600); + $expiredResponse = new Response(); + $inner->write( + Request::create('https://example.test'), + $expiredResponse, + new TimezonePreference(TimezoneId::fromString('America/New_York'), PreferenceSource::BROWSER, new \DateTimeImmutable('2025-12-31T22:00:00Z')), + ); + $expiredCookie = $expiredResponse->headers->getCookies()[0]; + $mainRequest = Request::create('https://example.test/page'); + $mainRequest->cookies->set($expiredCookie->getName(), $expiredCookie->getValue()); + $stack = new RequestStack(); + $stack->push($mainRequest); + $storage = new PreferenceWriteMarkingStorage($inner, $stack); + + (new StoredPreferenceTimezoneResolver($storage))->resolve($mainRequest); + $sharedResponse = new Response(); + $forwarded = Request::create('https://example.test/forwarded'); + $stack->push($forwarded); + $storage->write($forwarded, $sharedResponse, new TimezonePreference(TimezoneId::fromString('Europe/Paris'), PreferenceSource::MANUAL, $clock->now())); + $stack->pop(); + (new InvalidPreferenceCleanupListener($storage, PersistenceFailureStrategy::CONTINUE))->onKernelResponse( + new ResponseEvent($this->createStub(HttpKernelInterface::class), $mainRequest, HttpKernelInterface::MAIN_REQUEST, $sharedResponse), + ); + + $cookies = $sharedResponse->headers->getCookies(); + self::assertCount(1, $cookies); + self::assertNotSame('', (string) $cookies[0]->getValue()); + $followUp = Request::create('https://example.test'); + $followUp->cookies->set($cookies[0]->getName(), $cookies[0]->getValue()); + $read = $inner->read($followUp); + self::assertSame(PreferenceReadStatus::VALID, $read->status); + self::assertSame('Europe/Paris', $read->preference?->timezone->value()); + } + private function marking(TimezonePreferenceStorageInterface $inner, Request $mainRequest): PreferenceWriteMarkingStorage { $stack = new RequestStack(); diff --git a/src/Storage/CookieTimezoneStorage.php b/src/Storage/CookieTimezoneStorage.php index 40a9e80..9d148c1 100644 --- a/src/Storage/CookieTimezoneStorage.php +++ b/src/Storage/CookieTimezoneStorage.php @@ -12,7 +12,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -final readonly class CookieTimezoneStorage implements TimezonePreferenceStorageInterface +final readonly class CookieTimezoneStorage implements ResponseScopedStorageInterface { private const MAX_ENCODED_SIZE = 4096; private string $key; @@ -127,9 +127,28 @@ public function write(Request $request, Response $response, TimezonePreference $ public function clear(Request $request, Response $response): void { + // Never clobber a preference written to this very response — a + // forwarded controller writes on the response that later becomes the + // main one. Diagnostics still report the cleanup as performed, since + // the decision is taken below the listener. + if ($this->hasFreshCookie($response)) { + return; + } + $response->headers->clearCookie($this->name, $this->path, $this->domain, $this->resolveSecure($request), $this->httpOnly, $this->cookieSameSite()); } + private function hasFreshCookie(Response $response): bool + { + foreach ($response->headers->getCookies() as $cookie) { + if ($this->name === $cookie->getName() && $this->path === $cookie->getPath() && $this->domain === $cookie->getDomain() && '' !== (string) $cookie->getValue()) { + return true; + } + } + + return false; + } + private function resolveSecure(Request $request): bool { $secure = $this->secure ?? $request->isSecure(); diff --git a/src/Storage/PreferenceWriteMarkingStorage.php b/src/Storage/PreferenceWriteMarkingStorage.php index ff44fda..4276688 100644 --- a/src/Storage/PreferenceWriteMarkingStorage.php +++ b/src/Storage/PreferenceWriteMarkingStorage.php @@ -10,9 +10,11 @@ /** * Decorates the configured storage so the preference-written marker is - * maintained by the bundle for every implementation and every request - * lifecycle: the marker lands on the request that wrote AND on the main - * request, so a subrequest write is visible to the main-request cleanup. + * maintained by the bundle for every implementation: the marker lands on the + * request that wrote and, for lifecycle-scoped storage, on the main request as + * well, so a subrequest write is visible to the main-request cleanup. + * Response-scoped storage (see {@see ResponseScopedStorageInterface}) is + * deliberately not propagated across requests. */ final readonly class PreferenceWriteMarkingStorage implements TimezonePreferenceStorageInterface { @@ -29,6 +31,13 @@ public function write(Request $request, Response $response, TimezonePreference $ { $this->inner->write($request, $response, $preference); $request->attributes->set(self::PREFERENCE_WRITTEN_ATTRIBUTE, true); + if ($this->inner instanceof ResponseScopedStorageInterface) { + // The write lives on this response only. Suppressing the cleanup + // on a different response would leave the stale record in place + // while the fresh one never reaches the client; the storage + // protects its own response instead. + return; + } $main = $this->requestStack->getMainRequest(); if (null !== $main && $main !== $request) { $main->attributes->set(self::PREFERENCE_WRITTEN_ATTRIBUTE, true); diff --git a/src/Storage/ResponseScopedStorageInterface.php b/src/Storage/ResponseScopedStorageInterface.php new file mode 100644 index 0000000..9a9b621 --- /dev/null +++ b/src/Storage/ResponseScopedStorageInterface.php @@ -0,0 +1,22 @@ + Date: Fri, 31 Jul 2026 11:28:09 +0200 Subject: [PATCH 21/34] refactor: derive stored-preference priorities from their constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DI registration hardcodes 925 and 800 although StoredPreferenceTimezoneResolver publishes MANUAL_PRIORITY and BROWSER_PRIORITY, so changing a constant leaves the container, the resolver catalog and the documentation silently disagreeing. Any future priority adjustment through the constants would not reach the tagged services at all. Both registrations and both catalog entries now read the constants, and a typed write failure from resolveSecure() is rethrown unchanged instead of being re-wrapped into the generic write-failure message — that path is unreachable behind the constructor invariants today, but the wrapper would hide an actionable configuration message if it ever fires. Both reported by the repository's greptile bot (P2). --- src/LuneticsTimezoneBundle.php | 8 ++++---- src/Storage/CookieTimezoneStorage.php | 5 +++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/LuneticsTimezoneBundle.php b/src/LuneticsTimezoneBundle.php index b1ea64a..9dc937f 100644 --- a/src/LuneticsTimezoneBundle.php +++ b/src/LuneticsTimezoneBundle.php @@ -230,8 +230,8 @@ public function loadExtension(array $config, ContainerConfigurator $configurator $persistenceStrategy = PersistenceFailureStrategy::from($config['persistence']['failure_strategy']); $services->set('lunetics_timezone.resolver.stored_manual', StoredPreferenceTimezoneResolver::class) ->args([service(TimezonePreferenceStorageInterface::class), PreferenceSource::MANUAL, $persistenceStrategy]) - ->tag(TimezoneCompilerPass::RESOLVER_TAG, ['priority' => 925, 'index' => 'stored_manual']); - $addToCatalog('stored_manual', 925); + ->tag(TimezoneCompilerPass::RESOLVER_TAG, ['priority' => StoredPreferenceTimezoneResolver::MANUAL_PRIORITY, 'index' => 'stored_manual']); + $addToCatalog('stored_manual', StoredPreferenceTimezoneResolver::MANUAL_PRIORITY); if ($resolution['oidc']['enabled']) { $oidcService = $resolution['oidc']['service']; @@ -246,8 +246,8 @@ public function loadExtension(array $config, ContainerConfigurator $configurator $services->set('lunetics_timezone.resolver.stored_browser', StoredPreferenceTimezoneResolver::class) ->args([service(TimezonePreferenceStorageInterface::class), PreferenceSource::BROWSER, $persistenceStrategy]) - ->tag(TimezoneCompilerPass::RESOLVER_TAG, ['priority' => 800, 'index' => 'stored_browser']); - $addToCatalog('stored_browser', 800); + ->tag(TimezoneCompilerPass::RESOLVER_TAG, ['priority' => StoredPreferenceTimezoneResolver::BROWSER_PRIORITY, 'index' => 'stored_browser']); + $addToCatalog('stored_browser', StoredPreferenceTimezoneResolver::BROWSER_PRIORITY); if ($resolution['maxmind']['enabled']) { $reader = $resolution['maxmind']['reader']; diff --git a/src/Storage/CookieTimezoneStorage.php b/src/Storage/CookieTimezoneStorage.php index 9d148c1..6f354f8 100644 --- a/src/Storage/CookieTimezoneStorage.php +++ b/src/Storage/CookieTimezoneStorage.php @@ -120,6 +120,11 @@ public function write(Request $request, Response $response, TimezonePreference $ } $secure = $this->resolveSecure($request); $response->headers->setCookie(Cookie::create($this->name, $value, $this->clock->now()->getTimestamp() + $this->maxAge, $this->path, $this->domain, $secure, $this->httpOnly, false, $this->cookieSameSite())); + } catch (TimezoneStorageException $failure) { + // Defensive: the constructor invariants make resolveSecure()'s + // throw unreachable today, but if it ever fires its actionable + // message must not be replaced by the generic write-failure text. + throw $failure; } catch (\JsonException|\LengthException|\RuntimeException $failure) { throw TimezoneStorageException::operationFailed('write', $failure); } From ed8f1a756d2bd479862f0ccc83c81adb157ba0d2 Mon Sep 17 00:00:00 2001 From: Matthias Breddin Date: Fri, 31 Jul 2026 12:45:10 +0200 Subject: [PATCH 22/34] fix: keep the form bridge from breaking configured model timezones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The view_timezone default always returns the current timezone, but Symfony's TimeType refuses a view timezone that differs from an explicitly configured model_timezone unless a reference_date resolves the offset — enabling the opt-in form bridge then makes such a form unbuildable. A field declared as TimeType with model_timezone: UTC and no reference_date throws 'Using different values for the "model_timezone" and "view_timezone" options without configuring a reference date is not supported.' as soon as the bridge is active. DateType and DateTimeType resolve the offset from the date itself and were never affected. The default now yields to an explicitly configured model timezone when no reference date is present, so the bridge steps aside instead of breaking the form, and still applies the current timezone whenever the conversion is defined. Tests cover all three extended types with and without a reference date. The reference-date probe reads the option value rather than using isset(), which reports true for a defined-but-null option on Options. Reported by the repository's coderabbit bot. --- .../Bridge/Form/TimezoneTypeExtensionTest.php | 33 +++++++++++++++++++ src/Bridge/Form/TimezoneTypeExtension.php | 17 +++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/Tests/Bridge/Form/TimezoneTypeExtensionTest.php b/Tests/Bridge/Form/TimezoneTypeExtensionTest.php index c114d6b..88e8387 100644 --- a/Tests/Bridge/Form/TimezoneTypeExtensionTest.php +++ b/Tests/Bridge/Form/TimezoneTypeExtensionTest.php @@ -47,6 +47,39 @@ public function testLazyDefaultAndExplicitOverrideWithoutModelTimezone(string $t self::assertSame('UTC', $explicit['model_timezone']); } + /** @param class-string> $type */ + #[DataProvider('typeProvider')] + public function testConfiguredModelTimezoneWinsWithoutAReferenceDate(string $type): void + { + $provider = $this->createStub(CurrentTimezoneProviderInterface::class); + $provider->method('getTimezone')->willReturn(TimezoneId::fromString('Europe/Berlin')); + $factory = Forms::createFormFactoryBuilder()->addTypeExtension(new TimezoneTypeExtension($provider))->getFormFactory(); + + $options = $factory->create($type, null, ['model_timezone' => 'UTC'])->getConfig()->getOptions(); + + self::assertSame('UTC', $options['view_timezone'], 'Without a reference date the form must keep its model timezone instead of failing to build.'); + self::assertSame('UTC', $options['model_timezone']); + } + + /** @param class-string> $type */ + #[DataProvider('typeProvider')] + public function testCurrentTimezoneStillAppliesWhenAReferenceDateResolvesTheOffset(string $type): void + { + $provider = $this->createStub(CurrentTimezoneProviderInterface::class); + $provider->method('getTimezone')->willReturn(TimezoneId::fromString('Europe/Berlin')); + $factory = Forms::createFormFactoryBuilder()->addTypeExtension(new TimezoneTypeExtension($provider))->getFormFactory(); + $options = ['model_timezone' => 'UTC']; + // Only TimeType exposes reference_date; the other two types resolve the + // offset from the date itself. + if (TimeType::class === $type) { + $options['reference_date'] = new \DateTimeImmutable('2026-01-01', new \DateTimeZone('UTC')); + } + + $resolved = $factory->create($type, null, $options)->getConfig()->getOptions(); + + self::assertSame(TimeType::class === $type ? 'Europe/Berlin' : 'UTC', $resolved['view_timezone']); + } + /** @return iterable>}> */ public static function typeProvider(): iterable { diff --git a/src/Bridge/Form/TimezoneTypeExtension.php b/src/Bridge/Form/TimezoneTypeExtension.php index 6d6d048..68ebf54 100644 --- a/src/Bridge/Form/TimezoneTypeExtension.php +++ b/src/Bridge/Form/TimezoneTypeExtension.php @@ -25,6 +25,21 @@ public static function getExtendedTypes(): iterable public function configureOptions(OptionsResolver $resolver): void { - $resolver->setDefault('view_timezone', fn (Options $options): string => $this->provider->getTimezone()->value()); + $resolver->setDefault('view_timezone', function (Options $options): string { + $timezone = $this->provider->getTimezone()->value(); + // Symfony rejects differing model and view timezones unless a + // reference date resolves the offset. An explicitly configured + // model timezone therefore wins over the current one, so enabling + // this bridge never breaks a form that configures its own model + // timezone. + // `isset()` on Options only calls offsetExists(), which is true for + // a defined-but-null option — the value has to be read instead. + $model = $options['model_timezone'] ?? null; + if (is_string($model) && $model !== $timezone && null === ($options['reference_date'] ?? null)) { + return $model; + } + + return $timezone; + }); } } From 3bc8104b29526878a14fec1256adb0fa12cc8ce1 Mon Sep 17 00:00:00 2001 From: Matthias Breddin Date: Fri, 31 Jul 2026 12:45:30 +0200 Subject: [PATCH 23/34] ci: stop persisting the checkout credential in the workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit actions/checkout writes the job's GitHub token into the local git config by default, so every later step of the same job — composer update, npm test, the smoke script — runs third-party code that can read it from disk. Any compromised or malicious dependency executed during a workflow run could exfiltrate a token with write access to this repository. All four checkout steps now set persist-credentials: false; no step in these jobs pushes or otherwise needs the credential. Flagged by zizmor via the repository's coderabbit bot. --- .github/workflows/tests.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 38f4156..8556625 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,6 +12,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: shivammathur/setup-php@v2 with: php-version: '8.2' @@ -48,6 +50,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: shivammathur/setup-php@v2 with: @@ -90,6 +94,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: shivammathur/setup-php@v2 with: php-version: '8.2' @@ -103,6 +109,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: actions/setup-node@v4 with: node-version: '24' From 511f5a3c7c28c8b5c5b27991784bb2ee205721aa Mon Sep 17 00:00:00 2001 From: Matthias Breddin Date: Fri, 31 Jul 2026 12:45:50 +0200 Subject: [PATCH 24/34] test: assert both resolver names before comparing their order strpos() returns false when a needle is absent, and comparing false against an integer position juggles it to 0, so the ordering assertion passes whenever 'manual' is missing but 'browser' is printed. A regression that drops the manual resolver from the debug output would keep this test green while the command silently lost a row. Both positions are asserted to be integers first, so absence fails loudly, and only then is their order compared. Reported by the repository's coderabbit bot. --- Tests/Bridge/Console/DebugTimezoneCommandTest.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Tests/Bridge/Console/DebugTimezoneCommandTest.php b/Tests/Bridge/Console/DebugTimezoneCommandTest.php index fee0e1f..a40c839 100644 --- a/Tests/Bridge/Console/DebugTimezoneCommandTest.php +++ b/Tests/Bridge/Console/DebugTimezoneCommandTest.php @@ -20,7 +20,11 @@ public function testDisplaysPassedConfigurationInDeterministicOrder(): void self::assertSame(0, $tester->execute([])); $display = $tester->getDisplay(); self::assertStringContainsString('Configured default: UTC', $display); - self::assertLessThan(strpos($display, 'browser'), strpos($display, 'manual')); + $manual = strpos($display, 'manual'); + $browser = strpos($display, 'browser'); + self::assertIsInt($manual, 'The manual resolver must be listed.'); + self::assertIsInt($browser, 'The browser resolver must be listed.'); + self::assertLessThan($browser, $manual); self::assertStringContainsString('925', $display); self::assertStringContainsString('800', $display); } From b4dfd9e4f35577124cb6ed6066f032d203b1cd9a Mon Sep 17 00:00:00 2001 From: Matthias Breddin Date: Fri, 31 Jul 2026 12:45:50 +0200 Subject: [PATCH 25/34] docs: state that resolution happens once per main request The README opens with 'resolves an IANA timezone for each Symfony request', which reads as if every subrequest ran the resolver chain, while the bundle resolves only on the main kernel.request and subrequests reuse that resolution. Readers can build integrations around a per-subrequest resolution that the bundle never performs. The sentence now names the main request and the subrequest reuse explicitly. Reported by the repository's coderabbit bot. --- README.markdown | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.markdown b/README.markdown index 823eba9..4556428 100644 --- a/README.markdown +++ b/README.markdown @@ -1,6 +1,6 @@ # LuneticsTimezoneBundle 2.x -LuneticsTimezoneBundle resolves an IANA timezone for each Symfony request and exposes it through a stable application context. It can combine explicit request data, trusted headers, persisted preferences, authenticated users, OIDC claims, MaxMind City data, and locale fallbacks. +LuneticsTimezoneBundle resolves an IANA timezone once per main Symfony request — subrequests reuse that result — and exposes it through a stable application context. It can combine explicit request data, trusted headers, persisted preferences, authenticated users, OIDC claims, MaxMind City data, and locale fallbacks. Symfony 8 supplies timezone lists, validation primitives, and form options, but applications still need policy for choosing a user's timezone, retaining it, and carrying it through Twig and asynchronous work. This bundle provides that orchestration without changing PHP's process-wide default timezone. From 855045b9d61d9f04f9b992927d7d285a16b16ee8 Mon Sep 17 00:00:00 2001 From: Matthias Breddin Date: Fri, 31 Jul 2026 12:46:28 +0200 Subject: [PATCH 26/34] docs: document the read cache and preference-event failure semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two seams are only safe under constraints that neither the code nor the documentation states: the per-request storage read is cached under one storage-agnostic key, and a throwing listener of the preference-changed event discards the response that carries a cookie write. An application registering a second StoredPreferenceTimezoneResolver against another storage would silently consume the configured storage's cached read, and a failing event listener loses a cookie write while an equivalent session write is already durable. Both constraints are now written down — on the constant itself and in the custom-storage section — including the recommendation to keep such listeners failure-free or defer them to kernel.terminate. The behaviour is unchanged: swallowing listener exceptions would hide application errors, and rekeying the cache would touch three consumers to guard a configuration the documentation does not describe. --- Resources/doc/installation.md | 4 ++++ src/Resolver/StoredPreferenceTimezoneResolver.php | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/Resources/doc/installation.md b/Resources/doc/installation.md index ebfd36d..0e7daaa 100644 --- a/Resources/doc/installation.md +++ b/Resources/doc/installation.md @@ -153,6 +153,10 @@ The storage must preserve the preference timezone, `manual|browser` source, and The bundle decorates the configured storage with `PreferenceWriteMarkingStorage`, which records every successful `write()` on the current AND the main request (`TimezonePreferenceStorageInterface::PREFERENCE_WRITTEN_ATTRIBUTE`). The cleanup listener clears invalid or expired stored preferences on the response and skips that cleanup when the marker is present — so an application write (for example a settings form persisting a `manual` preference, even from a subrequest) survives the same-request cleanup. Custom storages do not need to set the marker themselves; writes only have to go through the storage service the bundle wires (inject `TimezonePreferenceStorageInterface`, not your concrete storage class). +The shipped `StoredPreferenceTimezoneResolver` caches its storage read on the request (`READ_ATTRIBUTE`) so both built-in instances, the cleanup listener and the profiler share a single read. Registering another instance of that class against a *different* storage service would make it consume the cached read of the configured one; a resolver for a second backend implements `TimezoneResolverInterface` directly instead. + +`TimezonePreferenceChangedEvent` is dispatched after the write, and a listener that throws fails the request like any other Symfony event listener. Note that the two backends differ at that point: a session write is already durable, while a cookie write is only realized through the `Set-Cookie` header of the response that the exception discards. Keep listeners of this event free of failure paths, or defer their work to `kernel.terminate`. + Storage that carries its writes on the `Response` instead of a lifecycle-wide backend — cookie storage, and any custom storage implementing `ResponseScopedStorageInterface` — is treated differently on subrequests: its marker is not propagated to the main request, because a fragment response is discarded before it reaches the client. The cleanup then still removes the stale record, and the storage refuses to clear a preference it has just written to the same response. ## MaxMind City diff --git a/src/Resolver/StoredPreferenceTimezoneResolver.php b/src/Resolver/StoredPreferenceTimezoneResolver.php index d3f6767..4e78aac 100644 --- a/src/Resolver/StoredPreferenceTimezoneResolver.php +++ b/src/Resolver/StoredPreferenceTimezoneResolver.php @@ -18,6 +18,13 @@ { public const MANUAL_PRIORITY = 925; public const BROWSER_PRIORITY = 800; + /** + * Caches the storage read for the current request so the manual and + * browser resolvers, the cleanup listener and the profiler share one read. + * The key is storage-agnostic: every resolver instance participating in it + * must be wired to the configured storage service — a second instance + * reading a different backend would consume this cached result. + */ public const READ_ATTRIBUTE = '_lunetics_timezone.preference_read'; public function __construct( From 460af80ec960aac549d472ca30da6a763096a05c Mon Sep 17 00:00:00 2001 From: Matthias Breddin Date: Fri, 31 Jul 2026 15:35:34 +0200 Subject: [PATCH 27/34] feat!: support only maintained PHP and Symfony lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The declared range admits versions that are dying or already dead: Symfony 8.0 left support in July 2026, Symfony 6.4 stops receiving bug fixes in November 2026, and PHP 8.2 reaches end of life in December 2026 — a 2.0 released now would ship with a floor that outlives the release by months. The floor also permits symfony/http-foundation releases affected by CVE-2026-48736, whose IpUtils::PRIVATE_SUBNETS omits the 6to4 and NAT64 transition prefixes. Consumers installing at the floor receive an unpatched http-foundation, and CI spends two of its seven matrix legs on an unmaintained Symfony 8.0 and an LTS that V2 adopters cannot migrate to anyway. The package now requires PHP ^8.3 and Symfony ^7.4.13 || ^8.1, tracking the current LTS and the current stable while ^8.1 keeps later Symfony 8 minors admissible. The matrix drops to four legs (8.3/8.4 with 7.4, 8.4/8.5 with 8.1), the prefer-lowest job gains a production-dependency audit so the floor cannot silently rot, and README, installation, plan and changelog state the policy with its dates. Applications on Symfony 6.4 stay on the 1.x line — V2 breaks from it without a compatibility layer regardless, so the supported range is not what keeps them from upgrading. Verified by resolution: prefer-lowest locks http-foundation v7.4.13, highest locks v8.1.2. --- .github/workflows/tests.yml | 13 +++------ CHANGELOG.md | 3 ++- README.markdown | 6 ++--- Resources/doc/installation.md | 2 +- Resources/doc/v2-implementation-plan.md | 8 ++++-- composer.json | 36 ++++++++++++------------- 6 files changed, 34 insertions(+), 34 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8556625..98f8aaa 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,7 +16,7 @@ jobs: persist-credentials: false - uses: shivammathur/setup-php@v2 with: - php-version: '8.2' + php-version: '8.3' coverage: none tools: composer:v2 - run: composer validate --strict --no-check-publish @@ -31,16 +31,10 @@ jobs: fail-fast: false matrix: include: - - php: '8.2' - symfony: '6.4.*' - - php: '8.3' - symfony: '6.4.*' - - php: '8.2' - symfony: '7.4.*' - php: '8.3' symfony: '7.4.*' - php: '8.4' - symfony: '8.0.*' + symfony: '7.4.*' - php: '8.4' symfony: '8.1.*' - php: '8.5' @@ -98,11 +92,12 @@ jobs: persist-credentials: false - uses: shivammathur/setup-php@v2 with: - php-version: '8.2' + php-version: '8.3' coverage: none tools: composer:v2 - run: composer validate --strict --no-check-publish - run: composer update --prefer-lowest --prefer-dist --no-interaction --no-progress + - run: composer audit --locked --no-dev - run: composer check javascript: diff --git a/CHANGELOG.md b/CHANGELOG.md index bf13500..298bebd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,5 +13,6 @@ - Hardened container compilation: integration keys are strict; cookie and MaxMind identifiers are validated; aware-user/accessor exceptions become typed resolver failures; and configured-service alias cycles are rejected. - Scoped clock selection to the bundle's internal alias without defining or replacing the application's global PSR clock alias. - Hardened browser/session and diagnostics behavior: sessionless session-storage writes return `503`, and profiler data survives profile serialization/reload. -- Hardened distribution and CI checks with automatic bundle AssetMapper discovery, clean Composer `--no-dev` smoke coverage, PHP 8.3/Symfony 6.4 coverage, and archive retention of linked scope/plan docs and the root `LICENSE`. +- Hardened distribution and CI checks with automatic bundle AssetMapper discovery, clean Composer `--no-dev` smoke coverage, a production-dependency audit at the dependency floor, and archive retention of linked scope/plan docs and the root `LICENSE`. +- Requires PHP `^8.3` and Symfony `^7.4.13 || ^8.1`: unmaintained Symfony 8.0, the older 6.4 LTS, and end-of-life PHP 8.2 are outside the supported range, and the floor excludes the versions affected by CVE-2026-48736. - Removed the bundle Validator integration and the Symfony Validator requirement. V2 has no 1.x compatibility layer. diff --git a/README.markdown b/README.markdown index 4556428..07ffe32 100644 --- a/README.markdown +++ b/README.markdown @@ -6,10 +6,10 @@ Symfony 8 supplies timezone lists, validation primitives, and form options, but ## Requirements -- PHP `^8.2` -- Symfony `^6.4 || ^7.4 || ^8.0` +- PHP `^8.3` +- Symfony `^7.4.13 || ^8.1` -Symfony 8.1 is the primary current target (July 2026); the `^8.0` constraint intentionally permits compatible later Symfony 8 minors. +Symfony 8.1 is the primary current target (July 2026), and `^8.1` intentionally permits compatible later Symfony 8 minors. The supported range covers the maintained lines only: Symfony 8.0 left support in July 2026, Symfony 6.4 stops receiving bug fixes in November 2026, and PHP 8.2 reaches end of life in December 2026. The `7.4.13` floor is the patch line for CVE-2026-48736 in `symfony/http-foundation`. ## Install and register diff --git a/Resources/doc/installation.md b/Resources/doc/installation.md index 0e7daaa..45a3b75 100644 --- a/Resources/doc/installation.md +++ b/Resources/doc/installation.md @@ -2,7 +2,7 @@ ## Requirements and registration -Install PHP `^8.2` and Symfony `^6.4 || ^7.4 || ^8.0`, then: +Install PHP `^8.3` and Symfony `^7.4.13 || ^8.1`, then: ```bash composer require lunetics/timezone-bundle diff --git a/Resources/doc/v2-implementation-plan.md b/Resources/doc/v2-implementation-plan.md index 8600f2e..d1b1cb4 100644 --- a/Resources/doc/v2-implementation-plan.md +++ b/Resources/doc/v2-implementation-plan.md @@ -12,7 +12,11 @@ It does not change PHP's process-global timezone, convert stored timestamps, aut ## Support policy and forward compatibility -The package requires PHP `^8.2` and Symfony components `^6.4 || ^7.4 || ^8.0`. As of July 2026, Symfony 8.1 is the primary current target. Composer `^8.0` deliberately admits forward-compatible Symfony 8.x minors; CI also exercises the declared older lines. Optional integrations fail clearly when explicitly enabled without their component, while `auto` integrations activate only when their framework extension/service is present. +The package requires PHP `^8.3` and Symfony components `^7.4.13 || ^8.1`. As of July 2026, Symfony 8.1 is the primary current target and 7.4 is the current LTS; Composer `^8.1` deliberately admits forward-compatible Symfony 8.x minors. + +The range deliberately tracks maintained lines rather than every installable one. Symfony 8.0 left support in July 2026, Symfony 6.4 stops receiving bug fixes in November 2026 (security-only until November 2027), and PHP 8.2 reaches end of life in December 2026 — a 2.0 released now would ship with a floor that dies within months. The `7.4.13` floor additionally excludes the versions affected by CVE-2026-48736 in `symfony/http-foundation`, whose `IpUtils::PRIVATE_SUBNETS` omits the 6to4 and NAT64 transition prefixes. Applications on Symfony 6.4 stay on the 1.x line, which V2 breaks from regardless. + +Optional integrations fail clearly when explicitly enabled without their component, while `auto` integrations activate only when their framework extension/service is present. Public interfaces and value semantics listed below are the compatibility surface. Internal service construction and listener wiring may evolve without being treated as public API. V2 is a clean break from 1.x and contains no backward-compatibility layer. @@ -164,7 +168,7 @@ Resolver logging uses PSR-3 levels and structured fields: resolver, outcome, dur ## Testing and CI policy -PHPUnit covers value objects, resolver ordering/ties/failures, shipped resolvers, storage tamper/expiry/security behavior, controller protocol/statuses, context cleanup, Twig/Form/Messenger adapters, profiler/commands, compiler validation, and real-kernel smoke paths. Kernel coverage exercises both accepted and rejected CSRF tokens, the sessionless browser-write `503`, automatic bundle AssetMapper discovery without manually configured asset paths, and profiler serialization/reload. PHPStan is required. CI runs supported Symfony/PHP combinations—including PHP 8.3 with Symfony 6.4—plus prefer-lowest, `composer validate --strict --no-check-publish`, PHPStan, PHPUnit, and a clean `composer --no-dev` package smoke. The target is meaningful contract and branch-boundary coverage, not a promise of full branch coverage. +PHPUnit covers value objects, resolver ordering/ties/failures, shipped resolvers, storage tamper/expiry/security behavior, controller protocol/statuses, context cleanup, Twig/Form/Messenger adapters, profiler/commands, compiler validation, and real-kernel smoke paths. Kernel coverage exercises both accepted and rejected CSRF tokens, the sessionless browser-write `503`, automatic bundle AssetMapper discovery without manually configured asset paths, and profiler serialization/reload. PHPStan is required. CI runs the supported Symfony/PHP combinations—PHP 8.3/8.4 with Symfony 7.4 and PHP 8.4/8.5 with Symfony 8.1—plus prefer-lowest with a production-dependency audit, `composer validate --strict --no-check-publish`, PHPStan, PHPUnit, and a clean `composer --no-dev` package smoke. The target is meaningful contract and branch-boundary coverage, not a promise of full branch coverage. The pure module has a small Node test. Browser automation is optional, one-time/local release-confidence work; recurring browser infrastructure or CI is intentionally not required. diff --git a/composer.json b/composer.json index 00c545b..a4fecfd 100644 --- a/composer.json +++ b/composer.json @@ -6,14 +6,14 @@ "license": "MIT", "authors": [{"name": "Lunetics", "homepage": "https://www.lunetics.com"}], "require": { - "php": "^8.2", + "php": "^8.3", "psr/clock": "^1", "psr/log": "^3", - "symfony/config": "^6.4 || ^7.4 || ^8.0", - "symfony/dependency-injection": "^6.4 || ^7.4 || ^8.0", + "symfony/config": "^7.4.13 || ^8.1", + "symfony/dependency-injection": "^7.4.13 || ^8.1", "symfony/event-dispatcher-contracts": "^3.5", - "symfony/http-foundation": "^6.4 || ^7.4 || ^8.0", - "symfony/http-kernel": "^6.4 || ^7.4 || ^8.0", + "symfony/http-foundation": "^7.4.13 || ^8.1", + "symfony/http-kernel": "^7.4.13 || ^8.1", "symfony/service-contracts": "^3.5" }, "require-dev": { @@ -21,19 +21,19 @@ "phpstan/phpstan": "^2.1", "phpstan/phpstan-symfony": "^2.0", "phpunit/phpunit": "^11.5", - "symfony/asset-mapper": "^6.4 || ^7.4 || ^8.0", - "symfony/console": "^6.4 || ^7.4 || ^8.0", - "symfony/event-dispatcher": "^6.4 || ^7.4 || ^8.0", - "symfony/form": "^6.4 || ^7.4 || ^8.0", - "symfony/framework-bundle": "^6.4 || ^7.4 || ^8.0", - "symfony/messenger": "^6.4 || ^7.4 || ^8.0", - "symfony/routing": "^6.4 || ^7.4 || ^8.0", - "symfony/security-bundle": "^6.4 || ^7.4 || ^8.0", - "symfony/security-core": "^6.4 || ^7.4 || ^8.0", - "symfony/security-csrf": "^6.4 || ^7.4 || ^8.0", - "symfony/serializer": "^6.4 || ^7.4 || ^8.0", - "symfony/twig-bundle": "^6.4 || ^7.4 || ^8.0", - "symfony/web-profiler-bundle": "^6.4 || ^7.4 || ^8.0", + "symfony/asset-mapper": "^7.4 || ^8.1", + "symfony/console": "^7.4 || ^8.1", + "symfony/event-dispatcher": "^7.4 || ^8.1", + "symfony/form": "^7.4 || ^8.1", + "symfony/framework-bundle": "^7.4 || ^8.1", + "symfony/messenger": "^7.4 || ^8.1", + "symfony/routing": "^7.4 || ^8.1", + "symfony/security-bundle": "^7.4 || ^8.1", + "symfony/security-core": "^7.4 || ^8.1", + "symfony/security-csrf": "^7.4 || ^8.1", + "symfony/serializer": "^7.4 || ^8.1", + "symfony/twig-bundle": "^7.4 || ^8.1", + "symfony/web-profiler-bundle": "^7.4 || ^8.1", "twig/twig": "^3.0" }, "suggest": { From b85d86bb78b01c4422138672549a9ee942a6fac2 Mon Sep 17 00:00:00 2001 From: Matthias Breddin Date: Fri, 31 Jul 2026 15:49:09 +0200 Subject: [PATCH 28/34] refactor: use the defined 413 constant for oversized bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The size guard returns the bare literal 413 while every other status in the controller uses a Response constant, and the obvious candidate name HTTP_PAYLOAD_TOO_LARGE does not exist in the supported Symfony range — a later cleanup would reach for it and break the build. Nobody reading the controller can tell whether 413 was deliberate or a placeholder. The guard now returns Response::HTTP_REQUEST_ENTITY_TOO_LARGE, the name Symfony actually defines (verified in the installed 7.4.14). Reported by the repository's coderabbit bot. --- src/Controller/BrowserTimezoneController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Controller/BrowserTimezoneController.php b/src/Controller/BrowserTimezoneController.php index f07ca3a..bd7a083 100644 --- a/src/Controller/BrowserTimezoneController.php +++ b/src/Controller/BrowserTimezoneController.php @@ -40,7 +40,7 @@ public function __invoke(Request $request): Response } $content = $request->getContent(); if (strlen($content) > 1024) { - return new Response('', 413); + return new Response('', Response::HTTP_REQUEST_ENTITY_TOO_LARGE); } if (null !== $this->csrfTokenManager && !$this->csrfTokenManager->isTokenValid(new CsrfToken($this->csrfTokenId, (string) $request->headers->get($this->csrfHeader, '')))) { return new Response('', Response::HTTP_FORBIDDEN); From f446e1b7597a37163db49871321e8a9b6950d3fa Mon Sep 17 00:00:00 2001 From: Matthias Breddin Date: Fri, 31 Jul 2026 15:49:09 +0200 Subject: [PATCH 29/34] test: restore the exception handler only when the kernel pushed one tearDown() calls restore_exception_handler() unconditionally, but only a booted debug kernel installs Symfony's handler. When boot() fails earlier, the call pops PHPUnit's own handler instead. A kernel that fails to boot therefore leaves the suite without PHPUnit's exception handler, so errors of later tests are swallowed and the run can end green on a broken build. The teardown now restores only after a boot that actually completed. Reported by the repository's coderabbit bot. --- Tests/Integration/BundleKernelSmokeTest.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Tests/Integration/BundleKernelSmokeTest.php b/Tests/Integration/BundleKernelSmokeTest.php index 9725045..ae24972 100644 --- a/Tests/Integration/BundleKernelSmokeTest.php +++ b/Tests/Integration/BundleKernelSmokeTest.php @@ -40,11 +40,18 @@ final class BundleKernelSmokeTest extends TestCase { private ?BundleSmokeKernel $kernel = null; private ?string $runtimeDirectory = null; + private bool $bootedDebugKernel = false; protected function tearDown(): void { $this->kernel?->shutdown(); - restore_exception_handler(); + // Only a booted debug kernel pushes Symfony's exception handler. + // Restoring unconditionally would pop PHPUnit's own handler when boot() + // failed before that point, and swallow the errors of later tests. + if ($this->bootedDebugKernel) { + restore_exception_handler(); + } + $this->bootedDebugKernel = false; if (null !== $this->runtimeDirectory && is_dir($this->runtimeDirectory)) { $this->removeDirectory($this->runtimeDirectory); } @@ -57,6 +64,7 @@ public function testBundleWorksInARealDebugKernel(): void $this->runtimeDirectory = sys_get_temp_dir().'/lunetics_timezone_kernel_'.bin2hex(random_bytes(8)); $this->kernel = new BundleSmokeKernel('test', true, dirname(__DIR__, 2), $this->runtimeDirectory); $this->kernel->boot(); + $this->bootedDebugKernel = true; $container = $this->kernel->getContainer(); $provider = $container->get(CurrentTimezoneProviderInterface::class); @@ -160,6 +168,7 @@ public function testBrowserPersistenceFailureWithoutFrameworkSessionsReturnsServ $this->runtimeDirectory = sys_get_temp_dir().'/lunetics_timezone_kernel_'.bin2hex(random_bytes(8)); $this->kernel = new BundleSmokeKernel('test', true, dirname(__DIR__, 2), $this->runtimeDirectory, false, false); $this->kernel->boot(); + $this->bootedDebugKernel = true; $browserRequest = Request::create( '/_lunetics/timezone/browser', From 55b381bad8fd34daa5396a469e4f4b124bae1370 Mon Sep 17 00:00:00 2001 From: Matthias Breddin Date: Fri, 31 Jul 2026 15:49:09 +0200 Subject: [PATCH 30/34] test: pin the cookie anti-clobber branch at the storage level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rule that clear() must not remove a preference written to the same response is only covered end to end through the cleanup listener, and the encodedCookie() helper carries a clock parameter it never uses. A regression in that guard would surface as a confusing listener-level failure, or stay unnoticed if the listener path changes. Two focused tests now cover both branches directly — a fresh write survives, a stale cookie without a fresh write is removed — and the dead parameter is gone. Reported by the repository's coderabbit bot. --- Tests/Storage/CookieTimezoneStorageTest.php | 36 +++++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/Tests/Storage/CookieTimezoneStorageTest.php b/Tests/Storage/CookieTimezoneStorageTest.php index cdae8f9..96c265c 100644 --- a/Tests/Storage/CookieTimezoneStorageTest.php +++ b/Tests/Storage/CookieTimezoneStorageTest.php @@ -72,10 +72,10 @@ public function testExpiredAndFutureDatedCookiesAreRejected(): void $clock = new MutableClock('2026-01-01T00:00:00Z'); $storage = $this->storage($clock, maxAge: 3600, futureSkew: 60); - $expired = $this->encodedCookie($storage, $clock, new \DateTimeImmutable('2025-12-31T22:59:59Z')); + $expired = $this->encodedCookie($storage, new \DateTimeImmutable('2025-12-31T22:59:59Z')); self::assertSame(PreferenceReadStatus::EXPIRED, $storage->read($this->requestWithCookie($expired))->status); - $future = $this->encodedCookie($storage, $clock, new \DateTimeImmutable('2026-01-01T00:01:01Z')); + $future = $this->encodedCookie($storage, new \DateTimeImmutable('2026-01-01T00:01:01Z')); self::assertSame(PreferenceReadStatus::INVALID, $storage->read($this->requestWithCookie($future))->status); } @@ -135,6 +135,36 @@ public function testWriteAndClearUseConfiguredCookieAttributes(): void self::assertSame(Cookie::SAMESITE_STRICT, $cleared->getSameSite()); } + public function testClearKeepsAPreferenceWrittenToTheSameResponse(): void + { + $clock = new MutableClock('2026-01-01T00:00:00Z'); + $storage = $this->storage($clock); + $request = Request::create('https://example.test'); + $response = new Response(); + + $storage->write($request, $response, $this->preference('Europe/Paris', PreferenceSource::MANUAL, $clock->now())); + $storage->clear($request, $response); + + $cookie = $this->onlyCookie($response); + self::assertNotSame('', (string) $cookie->getValue(), 'clear() must not clobber a preference written to this response.'); + self::assertGreaterThan($clock->now()->getTimestamp(), $cookie->getExpiresTime()); + $read = $storage->read($this->requestWithCookie((string) $cookie->getValue())); + self::assertSame(PreferenceReadStatus::VALID, $read->status); + self::assertSame('Europe/Paris', $read->preference?->timezone->value()); + } + + public function testClearRemovesAStaleCookieWhenNoFreshWriteIsPresent(): void + { + $clock = new MutableClock('2026-01-01T00:00:00Z'); + $response = new Response(); + + $this->storage($clock)->clear(Request::create('https://example.test'), $response); + + $cookie = $this->onlyCookie($response); + self::assertSame('', (string) $cookie->getValue()); + self::assertLessThan($clock->now()->getTimestamp(), $cookie->getExpiresTime()); + } + public function testConstructorRejectsInvalidCookieName(): void { $this->expectException(\InvalidArgumentException::class); @@ -169,7 +199,7 @@ private function preference(string $timezone, PreferenceSource $source, \DateTim return new TimezonePreference(TimezoneId::fromString($timezone), $source, $recordedAt); } - private function encodedCookie(CookieTimezoneStorage $storage, MutableClock $clock, \DateTimeImmutable $recordedAt): string + private function encodedCookie(CookieTimezoneStorage $storage, \DateTimeImmutable $recordedAt): string { $response = new Response(); $storage->write(Request::create('https://example.test'), $response, $this->preference('Europe/Berlin', PreferenceSource::BROWSER, $recordedAt)); From b704355ab7c078167ea67d99b2ac75213e3c0955 Mon Sep 17 00:00:00 2001 From: Matthias Breddin Date: Fri, 31 Jul 2026 15:49:09 +0200 Subject: [PATCH 31/34] docs: state the PHP floor per Symfony line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The requirements read as if PHP ^8.3 combined freely with the whole Symfony range, but Symfony 8 requires PHP 8.4 — a reader on PHP 8.3 who pins ^8.1 hits a platform-requirement failure the text does not anticipate. The README and installation guide now name both pairings explicitly. Reported by the repository's coderabbit bot. --- README.markdown | 4 ++-- Resources/doc/installation.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.markdown b/README.markdown index 07ffe32..18c6f82 100644 --- a/README.markdown +++ b/README.markdown @@ -6,8 +6,8 @@ Symfony 8 supplies timezone lists, validation primitives, and form options, but ## Requirements -- PHP `^8.3` -- Symfony `^7.4.13 || ^8.1` +- PHP `^8.3` with Symfony `^7.4.13` +- PHP `^8.4` with Symfony `^8.1` (Symfony 8 requires PHP 8.4) Symfony 8.1 is the primary current target (July 2026), and `^8.1` intentionally permits compatible later Symfony 8 minors. The supported range covers the maintained lines only: Symfony 8.0 left support in July 2026, Symfony 6.4 stops receiving bug fixes in November 2026, and PHP 8.2 reaches end of life in December 2026. The `7.4.13` floor is the patch line for CVE-2026-48736 in `symfony/http-foundation`. diff --git a/Resources/doc/installation.md b/Resources/doc/installation.md index 45a3b75..d01bb97 100644 --- a/Resources/doc/installation.md +++ b/Resources/doc/installation.md @@ -2,7 +2,7 @@ ## Requirements and registration -Install PHP `^8.3` and Symfony `^7.4.13 || ^8.1`, then: +Install PHP `^8.3` with Symfony `^7.4.13`, or PHP `^8.4` with Symfony `^8.1` (Symfony 8 requires PHP 8.4), then: ```bash composer require lunetics/timezone-bundle From e14032bb483ee8eb467f6affe7938cdbbb9473ee Mon Sep 17 00:00:00 2001 From: Matthias Breddin Date: Fri, 31 Jul 2026 15:56:51 +0200 Subject: [PATCH 32/34] build: require the analyzer versions this code actually needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The declared dev floor admits phpstan 2.1.0 with phpstan-symfony 2.0.0, which cannot follow the Config component's fluent tree builder — the prefer-lowest CI job fails with 247 errors while every other leg passes, because a local install resolves far newer analyzers. Any contributor running composer update --prefer-lowest, and the CI job guarding the dependency floor, hit an analysis failure that has nothing to do with the code under test. The floors are now phpstan ^2.2 and phpstan-symfony ^2.0.20, the lowest pair that analyzes this code cleanly — verified by resolving exactly 2.2.0 and 2.0.20 in a prefer-lowest install and running composer check there (PHPStan clean, 204 tests). --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index a4fecfd..496be1b 100644 --- a/composer.json +++ b/composer.json @@ -18,8 +18,8 @@ }, "require-dev": { "geoip2/geoip2": "^3.4", - "phpstan/phpstan": "^2.1", - "phpstan/phpstan-symfony": "^2.0", + "phpstan/phpstan": "^2.2", + "phpstan/phpstan-symfony": "^2.0.20", "phpunit/phpunit": "^11.5", "symfony/asset-mapper": "^7.4 || ^8.1", "symfony/console": "^7.4 || ^8.1", From beff10959a64fc8873392e090cf5250a254a08cd Mon Sep 17 00:00:00 2001 From: Matthias Breddin Date: Fri, 31 Jul 2026 23:58:00 +0200 Subject: [PATCH 33/34] test: also restore the error handler the debug kernel installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symfony's ErrorHandler::register() pushes a PHP error handler next to the exception handler, and a debug kernel boot triggers it, but the teardown pops only the exception handler. The error handler therefore stays active for the rest of the PHPUnit process, so warnings, notices and deprecations of later tests run through Symfony's handler instead of PHPUnit's — turning unrelated tests flaky or hiding their diagnostics. Both handlers are now restored behind the same booted-kernel guard. Measured before the change: after shutdown and restore_exception_handler() the process still carried ErrorHandler::handleError, and only the added restore_error_handler() returned the handler state to its pre-boot value. Reported by the repository's coderabbit bot. --- Tests/Integration/BundleKernelSmokeTest.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Tests/Integration/BundleKernelSmokeTest.php b/Tests/Integration/BundleKernelSmokeTest.php index ae24972..04b8cde 100644 --- a/Tests/Integration/BundleKernelSmokeTest.php +++ b/Tests/Integration/BundleKernelSmokeTest.php @@ -45,11 +45,14 @@ final class BundleKernelSmokeTest extends TestCase protected function tearDown(): void { $this->kernel?->shutdown(); - // Only a booted debug kernel pushes Symfony's exception handler. - // Restoring unconditionally would pop PHPUnit's own handler when boot() - // failed before that point, and swallow the errors of later tests. + // A booted debug kernel registers Symfony's ErrorHandler, which pushes + // both a PHP error and a PHP exception handler; both have to come off + // again. Restoring unconditionally would pop PHPUnit's own handlers + // when boot() failed before that point, and swallow the errors of + // later tests. if ($this->bootedDebugKernel) { restore_exception_handler(); + restore_error_handler(); } $this->bootedDebugKernel = false; if (null !== $this->runtimeDirectory && is_dir($this->runtimeDirectory)) { From 4af18cdf663b513b15922fdb01f93245a9b4ad32 Mon Sep 17 00:00:00 2001 From: Matthias Breddin Date: Fri, 31 Jul 2026 23:59:21 +0200 Subject: [PATCH 34/34] Revert "test: also restore the error handler the debug kernel installs" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit beff109 and keeps the surrounding boot guard. Restoring the error handler is correct in a bare PHP process — a booted debug kernel does leave Symfony's ErrorHandler installed there — but not inside PHPUnit, and that is the only place this teardown runs. Measured in the running suite: at teardown the active error handler is already PHPUnit\Runner\ErrorHandler, so the extra restore_error_handler() pops PHPUnit's own handler and leaves NULL behind. The runner detects exactly that and marked both smoke tests 'Test code or tested code removed error handlers other than its own'. The teardown restores the exception handler only, and the comment now records why the sibling call must not be added back. --- Tests/Integration/BundleKernelSmokeTest.php | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/Tests/Integration/BundleKernelSmokeTest.php b/Tests/Integration/BundleKernelSmokeTest.php index 04b8cde..73e7ce3 100644 --- a/Tests/Integration/BundleKernelSmokeTest.php +++ b/Tests/Integration/BundleKernelSmokeTest.php @@ -45,14 +45,13 @@ final class BundleKernelSmokeTest extends TestCase protected function tearDown(): void { $this->kernel?->shutdown(); - // A booted debug kernel registers Symfony's ErrorHandler, which pushes - // both a PHP error and a PHP exception handler; both have to come off - // again. Restoring unconditionally would pop PHPUnit's own handlers - // when boot() failed before that point, and swallow the errors of - // later tests. + // Only the exception handler needs popping: under PHPUnit the error + // handler on top at this point is PHPUnit's own — Symfony's is already + // gone — so restoring it too would remove PHPUnit's, which the runner + // reports as a risky test. Restoring unconditionally would likewise pop + // PHPUnit's handler when boot() failed before Symfony pushed anything. if ($this->bootedDebugKernel) { restore_exception_handler(); - restore_error_handler(); } $this->bootedDebugKernel = false; if (null !== $this->runtimeDirectory && is_dir($this->runtimeDirectory)) {