From fe348dfb45d6cc2b9e8e924d6c3d5813b0e11a3d Mon Sep 17 00:00:00 2001 From: Matthias Breddin Date: Wed, 22 Jul 2026 23:41:08 +0200 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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