diff --git a/.gitea/workflows/tests.yaml b/.gitea/workflows/tests.yaml index 80992d3..3eaea61 100644 --- a/.gitea/workflows/tests.yaml +++ b/.gitea/workflows/tests.yaml @@ -29,5 +29,8 @@ jobs: - name: Install dependencies run: composer install --prefer-dist --no-progress + - name: Run php-cs-fixer + run: vendor/bin/php-cs-fixer fix --dry-run --diff + - name: Run tests run: XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text diff --git a/.gitignore b/.gitignore index ac63e11..afa4fc1 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,8 @@ ###> project-specific ### /config/reference.php ###< project-specific ### + +###> friendsofphp/php-cs-fixer ### +/.php-cs-fixer.php +/.php-cs-fixer.cache +###< friendsofphp/php-cs-fixer ### diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..b840706 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,18 @@ +in(__DIR__) + ->exclude('var') + ->exclude('vendor') + ->notPath([ + 'config/bundles.php', + 'config/reference.php', + ]) +; + +return (new PhpCsFixer\Config()) + ->setRules([ + '@PSR12' => true, + ]) + ->setFinder($finder) +; diff --git a/composer.json b/composer.json index 83939de..239210b 100644 --- a/composer.json +++ b/composer.json @@ -75,6 +75,7 @@ } }, "require-dev": { + "friendsofphp/php-cs-fixer": "*", "phpunit/phpunit": "^13.2", "symfony/browser-kit": "7.4.*", "symfony/css-selector": "7.4.*" diff --git a/composer.lock b/composer.lock index 506efbb..a570bd2 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "fda1ef2029360e1c56bfb2dd807e6994", + "content-hash": "2ef28f97b042de062226d51be1857a6f", "packages": [ { "name": "bacon/bacon-qr-code", @@ -3659,6 +3659,571 @@ } ], "packages-dev": [ + { + "name": "clue/ndjson-react", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/clue/reactphp-ndjson.git", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/reactphp-ndjson/zipball/392dc165fce93b5bb5c637b67e59619223c931b0", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "react/stream": "^1.2" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35", + "react/event-loop": "^1.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\React\\NDJson\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "Streaming newline-delimited JSON (NDJSON) parser and encoder for ReactPHP.", + "homepage": "https://github.com/clue/reactphp-ndjson", + "keywords": [ + "NDJSON", + "json", + "jsonlines", + "newline", + "reactphp", + "streaming" + ], + "support": { + "issues": "https://github.com/clue/reactphp-ndjson/issues", + "source": "https://github.com/clue/reactphp-ndjson/tree/v1.3.0" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2022-12-23T10:58:28+00:00" + }, + { + "name": "composer/pcre", + "version": "3.4.0", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<2.2.2" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.4.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-06-07T11:47:49+00:00" + }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+00:00" + }, + { + "name": "ergebnis/agent-detector", + "version": "1.2.0", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/agent-detector.git", + "reference": "e211f17928c8b95a51e06040792d57f5462fb271" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/agent-detector/zipball/e211f17928c8b95a51e06040792d57f5462fb271", + "reference": "e211f17928c8b95a51e06040792d57f5462fb271", + "shasum": "" + }, + "require": { + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.51.0", + "ergebnis/license": "^2.7.0", + "ergebnis/php-cs-fixer-config": "^6.60.2", + "ergebnis/phpstan-rules": "^2.13.1", + "ergebnis/phpunit-slow-test-detector": "^2.24.0", + "ergebnis/rector-rules": "^1.18.1", + "fakerphp/faker": "^1.24.1", + "infection/infection": "^0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.54", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "phpunit/phpunit": "^9.6.34", + "rector/rector": "^2.4.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\AgentDetector\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides a detector for detecting the presence of an agent.", + "homepage": "https://github.com/ergebnis/agent-detector", + "support": { + "issues": "https://github.com/ergebnis/agent-detector/issues", + "security": "https://github.com/ergebnis/agent-detector/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/agent-detector" + }, + "time": "2026-05-07T08:19:07+00:00" + }, + { + "name": "evenement/evenement", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evenement\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" + }, + "time": "2023-08-08T05:53:35+00:00" + }, + { + "name": "fidry/cpu-core-counter", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "fidry/makefile": "^0.2.0", + "fidry/php-cs-fixer-config": "^1.1.2", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "webmozarts/strict-phpunit": "^7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Fidry\\CpuCoreCounter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" + } + ], + "description": "Tiny utility to get the number of CPU cores.", + "keywords": [ + "CPU", + "core" + ], + "support": { + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2025-08-14T07:29:31+00:00" + }, + { + "name": "friendsofphp/php-cs-fixer", + "version": "v3.95.18", + "source": { + "type": "git", + "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", + "reference": "a8b4e4216faabf67f4e96110ee99a48c96e4e683" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/a8b4e4216faabf67f4e96110ee99a48c96e4e683", + "reference": "a8b4e4216faabf67f4e96110ee99a48c96e4e683", + "shasum": "" + }, + "require": { + "clue/ndjson-react": "^1.3", + "composer/semver": "^3.4", + "composer/xdebug-handler": "^3.0.5", + "ergebnis/agent-detector": "^1.2", + "ext-filter": "*", + "ext-hash": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "fidry/cpu-core-counter": "^1.3", + "php": "^7.4 || ^8.0", + "react/child-process": "^0.6.6", + "react/event-loop": "^1.5", + "react/socket": "^1.16", + "react/stream": "^1.4", + "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0 || ^9.0", + "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/options-resolver": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.37", + "symfony/polyfill-php80": "^1.37", + "symfony/polyfill-php81": "^1.37", + "symfony/polyfill-php84": "^1.37", + "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2 || ^8.0", + "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" + }, + "require-dev": { + "facile-it/paraunit": "^1.3.1 || ^2.11.0", + "infection/infection": "^0.32.7", + "justinrainbow/json-schema": "^6.10.0", + "keradus/cli-executor": "^2.3", + "mikey179/vfsstream": "^1.6.12", + "php-coveralls/php-coveralls": "^2.9.1", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", + "phpunit/phpunit": "^9.6.35 || ^10.5.64 || ^11.5.56 || ^12.5.31 || ^13.0.6", + "symfony/polyfill-php85": "^1.38", + "symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.1", + "symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.1" + }, + "suggest": { + "ext-dom": "For handling output formats in XML", + "ext-mbstring": "For handling non-UTF8 characters." + }, + "bin": [ + "php-cs-fixer" + ], + "type": "application", + "autoload": { + "psr-4": { + "PhpCsFixer\\": "src/" + }, + "exclude-from-classmap": [ + "src/**/Internal/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Dariusz Rumiński", + "email": "dariusz.ruminski@gmail.com" + } + ], + "description": "A tool to automatically fix PHP code style", + "keywords": [ + "Static code analysis", + "fixer", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.18" + }, + "funding": [ + { + "url": "https://github.com/keradus", + "type": "github" + } + ], + "time": "2026-07-30T15:46:02+00:00" + }, { "name": "masterminds/html5", "version": "2.10.1", @@ -4436,6 +5001,532 @@ ], "time": "2026-07-28T14:00:09+00:00" }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/child-process", + "version": "v0.6.7", + "source": { + "type": "git", + "url": "https://github.com/reactphp/child-process.git", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/event-loop": "^1.2", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/socket": "^1.16", + "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\ChildProcess\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven library for executing child processes with ReactPHP.", + "keywords": [ + "event-driven", + "process", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/child-process/issues", + "source": "https://github.com/reactphp/child-process/tree/v0.6.7" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-12-23T15:25:20+00:00" + }, + { + "name": "react/dns", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.14.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-18T19:34:28+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-17T20:46:25+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "react/socket", + "version": "v1.17.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Socket\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], + "support": { + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.17.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-19T20:47:34+00:00" + }, + { + "name": "react/stream", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" + }, { "name": "sebastian/cli-parser", "version": "5.0.0", @@ -5809,6 +6900,217 @@ ], "time": "2026-05-20T07:20:23+00:00" }, + { + "name": "symfony/polyfill-php84", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:51:13+00:00" + }, + { + "name": "symfony/process", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "f5804be144caceb570f6747519999636b664f24c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c", + "reference": "f5804be144caceb570f6747519999636b664f24c", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-23T16:05:06+00:00" + }, + { + "name": "symfony/stopwatch", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/stopwatch.git", + "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/70a852d72fec4d51efb1f48dcd968efcaf5ccb89", + "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/service-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Stopwatch\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a way to profile code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/stopwatch/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, { "name": "theseer/tokenizer", "version": "2.0.1", @@ -5871,5 +7173,5 @@ "ext-iconv": "*" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/public/index.php b/public/index.php index fc0c790..97e228d 100644 --- a/public/index.php +++ b/public/index.php @@ -1,4 +1,5 @@ setName('app:generate-backup-codes'); $this->setDescription('Generate single‑use backup codes') ->addArgument('count', InputArgument::OPTIONAL, 'Number of codes to generate', 10); } /** @throws InvalidArgumentException */ - protected function execute(InputInterface $input, OutputInterface $output): int { + protected function execute(InputInterface $input, OutputInterface $output): int + { /* since Kernel::terminate() does not get called, we must boot and persist explicitly */ $this->persistCache->boot(); $count = (int) $input->getArgument('count'); diff --git a/src/ConfigBag.php b/src/ConfigBag.php index 9493e32..afb630c 100644 --- a/src/ConfigBag.php +++ b/src/ConfigBag.php @@ -1,4 +1,5 @@ tooManyTitle = $tooManyTitle; } - public function clock(): ClockInterface { + public function clock(): ClockInterface + { return $this->clock; } - public function cookieTtl(): int { + public function cookieTtl(): int + { return $this->cookieTtl; } - public function totpUri(): string { + public function totpUri(): string + { return $this->totpUri; } - public function ipTtl(): ?int { + public function ipTtl(): ?int + { return $this->ipTtl; } - public function teapot(): bool { + public function teapot(): bool + { return $this->teapot; } - public function errorMessage(): string { + public function errorMessage(): string + { return $this->errorMessage; } - public function teapotTitle(): string { + public function teapotTitle(): string + { return $this->teapotTitle; } - public function tooManyTitle(): string { + public function tooManyTitle(): string + { return $this->tooManyTitle; } } diff --git a/src/Data/Payload.php b/src/Data/Payload.php index a6229df..ede6f7f 100644 --- a/src/Data/Payload.php +++ b/src/Data/Payload.php @@ -1,4 +1,5 @@ has('username') && $input->has('nonce') && $input->has('totp')) { return Payload::create((object)[ @@ -42,7 +46,8 @@ final class Payload { return null; } - public static function create(object $data): ?Payload { + public static function create(object $data): ?Payload + { /* if missing required fields id, nonce, or token */ if (strlen(trim($data->id ?? '')) < 1 || strlen(trim($data->nonce ?? '')) < 1 || @@ -63,11 +68,13 @@ final class Payload { return Payload::constrict($payload); } - public function toString(): string { + public function toString(): string + { return json_encode($this); } - private static function constrict(Payload $payload): Payload { + private static function constrict(Payload $payload): Payload + { /* When scope is None, json will be considered false. */ if ($payload->scope === Scope::None) { $payload->json = false; diff --git a/src/Enum/Scope.php b/src/Enum/Scope.php index 83f085a..d4d0502 100644 --- a/src/Enum/Scope.php +++ b/src/Enum/Scope.php @@ -1,10 +1,12 @@ persistCache = $this->container->get(PersistCache::class); @@ -23,7 +26,8 @@ class Kernel extends BaseKernel { } /** @throws InvalidArgumentException */ - public function terminate(Request $request, Response $response): void { + public function terminate(Request $request, Response $response): void + { $this->persistCache->persist(); parent::terminate($request, $response); diff --git a/src/Listener/AcceptListener.php b/src/Listener/AcceptListener.php index 1e424f4..e9ec54a 100644 --- a/src/Listener/AcceptListener.php +++ b/src/Listener/AcceptListener.php @@ -1,4 +1,5 @@ domainManager->authBase() ?$this->authCookieName() : $this->cookieName(); + $cookieName = $this->domainManager->authBase() ? $this->authCookieName() : $this->cookieName(); if ($event->getRequest()->cookies->has($cookieName)) { $cookie = $event->getRequest()->cookies->get($cookieName); $cookieKey = $this->makeCacheKey("cookie_$cookie"); diff --git a/src/Listener/AllowListener.php b/src/Listener/AllowListener.php index 97d55f6..2785018 100644 --- a/src/Listener/AllowListener.php +++ b/src/Listener/AllowListener.php @@ -1,4 +1,5 @@ config->ipTtl() > 0) { $ipKey = $this->makeCacheKey("ip_{$event->getRequest()->getClientIp()}"); if ($this->sessionCache->hasItem($ipKey)) { diff --git a/src/Listener/InterceptListener.php b/src/Listener/InterceptListener.php index 7fdc936..1aa632c 100644 --- a/src/Listener/InterceptListener.php +++ b/src/Listener/InterceptListener.php @@ -1,4 +1,5 @@ $event->getRequest()->getUri()]); - $event->setResponse(new Response('', Response::HTTP_SEE_OTHER, + $event->setResponse(new Response( + '', + Response::HTTP_SEE_OTHER, ['Location' => "https://{$this->domainManager->getAuthSubdomain()}/?$query"] )); } else { @@ -52,13 +58,16 @@ final readonly class InterceptListener { $hasCookie = (bool) $event->getRequest()->cookies->get( $this->domainManager->authBase() ? $this->authCookieName() : $this->cookieName() ); - $event->setResponse($this->pruneInvalidCookie(new Response($content, - Response::HTTP_UNAUTHORIZED, ['Content-Type' => 'text/html'] + $event->setResponse($this->pruneInvalidCookie(new Response( + $content, + Response::HTTP_UNAUTHORIZED, + ['Content-Type' => 'text/html'] ), $hasCookie, $event->getRequest()->getHost())); } } - private function pruneInvalidCookie(Response $response, bool $hasCookie, string $host): Response { + private function pruneInvalidCookie(Response $response, bool $hasCookie, string $host): Response + { if ($hasCookie) { /* input here must match LoginListener::setCookie() */ $response->headers->clearCookie( diff --git a/src/Listener/LoginListener.php b/src/Listener/LoginListener.php index 1c8ca69..4e9d4a8 100644 --- a/src/Listener/LoginListener.php +++ b/src/Listener/LoginListener.php @@ -1,4 +1,5 @@ rateLimiter = $rateLimiter; } /** @throws InvalidArgumentException|LoaderError|RuntimeError|SyntaxError */ #[AsEventListener(priority: 66)] - public function onKernelRequest(RequestEvent $event): void { + public function onKernelRequest(RequestEvent $event): void + { $payload = null; $response = null; @@ -51,7 +54,7 @@ final readonly class LoginListener { /* if request contains our "X-Preauth" header */ $data = $event->getRequest()->headers->get($this->headerName()); $payload = Payload::decode($data); - } else if ($event->getRequest()->isMethod(Request::METHOD_POST) && + } elseif ($event->getRequest()->isMethod(Request::METHOD_POST) && $this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost() ) { /* if request is a POST to the auth-subdomain */ @@ -76,18 +79,23 @@ final readonly class LoginListener { $limitReached = $this->logFailure($event->getRequest()); $this->logger->debug("logging failure for: {$event->getRequest()->getClientIp()}"); - $event->setResponse($this->makeFailedResponse($limitReached, $payload->json ?? true, - $event->getRequest()->getHost(), $this->makeCacheKey($payload ? $payload->id : '') + $event->setResponse($this->makeFailedResponse( + $limitReached, + $payload->json ?? true, + $event->getRequest()->getHost(), + $this->makeCacheKey($payload ? $payload->id : '') )); } - private function logFailure(Request $request): bool { + private function logFailure(Request $request): bool + { $limiter = $this->rateLimiter->create($request->getClientIp()); return ($limiter->consume(1)->getRemainingTokens() < 1); } /** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */ - private function makeFailedResponse(bool $limited, bool $json, string $host, string $username): Response { + private function makeFailedResponse(bool $limited, bool $json, string $host, string $username): Response + { if ($limited) { $status = $this->config->teapot() ? Response::HTTP_I_AM_A_TEAPOT : Response::HTTP_TOO_MANY_REQUESTS; diff --git a/src/Listener/RejectListener.php b/src/Listener/RejectListener.php index 643f09e..fb7b704 100644 --- a/src/Listener/RejectListener.php +++ b/src/Listener/RejectListener.php @@ -1,4 +1,5 @@ rateLimiter = $rateLimiter; @@ -32,13 +34,16 @@ final readonly class RejectListener { /** @throws SyntaxError|RuntimeError|LoaderError */ #[AsEventListener(priority: 77)] - public function onKernelRequest(RequestEvent $event): void { + public function onKernelRequest(RequestEvent $event): void + { /* check if they have made too many failed login attempts */ $limiter = $this->rateLimiter->create($event->getRequest()->getClientIp()); if ($limiter->consume(0)->getRemainingTokens() < 1) { $this->logger->debug("already blocked: {$event->getRequest()->getClientIp()}"); $html = $this->twig->render('error.html.twig'); - $event->setResponse(new Response($html, ($this->config->teapot() + $event->setResponse(new Response( + $html, + ($this->config->teapot() ? Response::HTTP_I_AM_A_TEAPOT : Response::HTTP_TOO_MANY_REQUESTS), ['Content-Type' => 'text/html'] )); diff --git a/src/MonitorCacheKeys.php b/src/MonitorCacheKeys.php index a291eca..955c817 100644 --- a/src/MonitorCacheKeys.php +++ b/src/MonitorCacheKeys.php @@ -1,4 +1,5 @@ cache = $cache; $items = $cache->getItems([self::KEY_LIST, self::CHANGE_LIST]); foreach ($items as $item) { - if ( ! $item->isHit()) { + if (! $item->isHit()) { $this->initialize(); break; } @@ -31,7 +34,8 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface { } /** @throws InvalidArgumentException */ - private function initialize(): void { + private function initialize(): void + { $keyList = $this->cache->getItem(self::KEY_LIST); $changeList = $this->cache->getItem(self::CHANGE_LIST); $keyList->set([]); @@ -42,42 +46,49 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface { } /** @throws InvalidArgumentException */ - public function getKeys(): array { + public function getKeys(): array + { $keyList = $this->cache->getItem(self::KEY_LIST); return array_keys($keyList->get() ?? []); } /** @throws InvalidArgumentException */ - public function getChanges(): array { + public function getChanges(): array + { $changeList = $this->cache->getItem(self::CHANGE_LIST); return $changeList->get() ?? []; } /** @throws InvalidArgumentException */ - public function markClean(): void { + public function markClean(): void + { $changeList = $this->cache->getItem(self::CHANGE_LIST); $changeList->set([]); $this->cache->save($changeList); } - public function getItem(string $key): CacheItemInterface { + public function getItem(string $key): CacheItemInterface + { return $this->cache->getItem($key); } /** @return CacheItemInterface[] * @throws InvalidArgumentException */ - public function getItems(array $keys = []): iterable { + public function getItems(array $keys = []): iterable + { return $this->cache->getItems($keys); } - public function hasItem(string $key): bool { + public function hasItem(string $key): bool + { return $this->cache->hasItem($key); } /** @throws InvalidArgumentException */ - public function clear(): bool { + public function clear(): bool + { /* only bother clearing the pool if it is not empty */ - if ( ! empty($this->getKeys())) { + if (! empty($this->getKeys())) { $response = $this->cache->clear(); $this->initialize(); @@ -86,7 +97,8 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface { return true; } - public function deleteItem(string $key): bool { + public function deleteItem(string $key): bool + { $this->isValid($key); $keyList = $this->cache->getItem(self::KEY_LIST); $keyValues = $keyList->get(); @@ -101,7 +113,8 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface { return $this->cache->deleteItem($key); } - public function deleteItems(array $keys): bool { + public function deleteItems(array $keys): bool + { $this->allValid($keys); $keyList = $this->cache->getItem(self::KEY_LIST); $keyValues = $keyList->get(); @@ -119,23 +132,27 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface { } /** @throws InvalidArgumentException */ - public function save(CacheItemInterface $item): bool { + public function save(CacheItemInterface $item): bool + { $this->update($item); return $this->cache->save($item); } /** @throws InvalidArgumentException */ - public function saveDeferred(CacheItemInterface $item): bool { + public function saveDeferred(CacheItemInterface $item): bool + { $this->update($item); return $this->cache->saveDeferred($item); } - public function commit(): bool { + public function commit(): bool + { return $this->cache->commit(); } /** @throws InvalidArgumentException|OutOfBoundsException */ - private function update(CacheItemInterface $item): void { + private function update(CacheItemInterface $item): void + { $this->isValid($item->getKey()); $keyList = $this->cache->getItem(self::KEY_LIST); $keyValues = $keyList->get(); @@ -147,7 +164,8 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface { } /** @throws OutOfBoundsException */ - private function isValid(string $key): void { + private function isValid(string $key): void + { if ($key === self::KEY_LIST || $key === self::CHANGE_LIST) { throw new OutOfBoundsException( 'Can not modify the private key or change lists' @@ -156,7 +174,8 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface { } /** @throws OutOfBoundsException */ - private function allValid(array $keys): void { + private function allValid(array $keys): void + { if (in_array(self::KEY_LIST, $keys, true) || in_array(self::CHANGE_LIST, $keys, true) ) { @@ -167,7 +186,8 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface { } /** @throws InvalidArgumentException */ - private function logChange(string $key, int $code = MonitorCacheKeys::UPDATED): void { + private function logChange(string $key, int $code = MonitorCacheKeys::UPDATED): void + { $changeList = $this->cache->getItem(self::CHANGE_LIST); $changeValues = $changeList->get(); $changeValues[$key] = $code; diff --git a/src/PersistCache.php b/src/PersistCache.php index 81c780c..d1fcfa4 100644 --- a/src/PersistCache.php +++ b/src/PersistCache.php @@ -1,4 +1,5 @@ boot() */ #[Autoconfigure(public: true)] -final readonly class PersistCache { +final readonly class PersistCache +{ private MonitorCacheKeys $sessionCache; private MonitorCacheKeys $sessionStorage; @@ -23,7 +25,8 @@ final readonly class PersistCache { } /** @throws InvalidArgumentException */ - public function boot(): void { + public function boot(): void + { /* the caches are considered warm as soon as they are not empty */ if (empty($this->sessionCache->getKeys())) { $items = $this->sessionStorage->getItems($this->sessionStorage->getKeys()); @@ -36,7 +39,8 @@ final readonly class PersistCache { } /** @throws InvalidArgumentException */ - public function persist(): void { + public function persist(): void + { /* we only need to persist the changes made to the cache (if any) */ $changes = $this->sessionCache->getChanges(); if ($changes) { diff --git a/src/Service/BackupCodeInterface.php b/src/Service/BackupCodeInterface.php index 9c13709..ac60e91 100644 --- a/src/Service/BackupCodeInterface.php +++ b/src/Service/BackupCodeInterface.php @@ -2,13 +2,13 @@ namespace App\Service; - use Exception; use Psr\Cache\InvalidArgumentException; /** backup-codes are case‑insensitive alphanumeric strings * they are single-use and marked as used after successful authentication */ -interface BackupCodeInterface { +interface BackupCodeInterface +{ /** generate a set of backup-codes and return them * @param int $count Number of codes to generate * @return string[] Generated backup codes diff --git a/src/Service/BackupCodeManager.php b/src/Service/BackupCodeManager.php index e7bf67e..e66e073 100644 --- a/src/Service/BackupCodeManager.php +++ b/src/Service/BackupCodeManager.php @@ -1,4 +1,5 @@ sessionCache = new MonitorCacheKeys($sessionCache); } @@ -34,7 +37,8 @@ final readonly class BackupCodeManager implements BackupCodeInterface { * @param int $count Number of codes to generate * @return string[] Generated backup codes * @throws InvalidArgumentException|Exception */ - public function generate(int $count = self::DEFAULT_COUNT): array { + public function generate(int $count = self::DEFAULT_COUNT): array + { $length = min($this->getTotp()->getDigits() + 2, self::MAX_LENGTH); $codes = []; for ($i = 0; $i < $count; $i++) { @@ -49,7 +53,8 @@ final readonly class BackupCodeManager implements BackupCodeInterface { } /** @throws InvalidArgumentException */ - public function expire(): void { + public function expire(): void + { $itemsToRemove = []; foreach ($this->sessionCache->getKeys() as $key) { if (str_starts_with($key, 'backup_')) { @@ -65,7 +70,8 @@ final readonly class BackupCodeManager implements BackupCodeInterface { * @param string $code Code supplied by the client * @return bool true if the code is valid and unused * @throws InvalidArgumentException */ - public function verifyAndConsume(string $code): bool { + public function verifyAndConsume(string $code): bool + { /* remove unallowed characters, since backup codes are case-insensitive alphanumeric */ $backupKey = 'backup_' . preg_replace('/[^a-z0-9]+/', '', strtolower($code)); $backupItem = $this->sessionCache->getItem($this->makeCacheKey($backupKey)); @@ -77,7 +83,8 @@ final readonly class BackupCodeManager implements BackupCodeInterface { /* per PSR6, if no expiration is set, implementation may set a default, * we want this to keep forever, so a few hundred years should do it */ $backupItem->expiresAt(DateTimeImmutable::createFromFormat( - 'Y-m-d', '2999-12-31' + 'Y-m-d', + '2999-12-31' )); $this->sessionCache->save($backupItem); @@ -87,7 +94,8 @@ final readonly class BackupCodeManager implements BackupCodeInterface { } /** @throws InvalidArgumentException */ - private function saveCodes(array $codes): void { + private function saveCodes(array $codes): void + { foreach ($codes as $code) { $backupItem = $this->sessionCache->getItem($this->makeCacheKey(strtolower("backup_$code"))); /* mark backup code as ready */ @@ -95,7 +103,8 @@ final readonly class BackupCodeManager implements BackupCodeInterface { /* per PSR6, if no expiration is set, implementation may set a default, * we want this to keep forever, so a few hundred years should do it */ $backupItem->expiresAt(DateTimeImmutable::createFromFormat( - 'Y-m-d', '2999-12-31' + 'Y-m-d', + '2999-12-31' )); $this->sessionCache->saveDeferred($backupItem); } diff --git a/src/Service/DomainInterface.php b/src/Service/DomainInterface.php index 29da47a..4b17f15 100644 --- a/src/Service/DomainInterface.php +++ b/src/Service/DomainInterface.php @@ -2,7 +2,8 @@ namespace App\Service; -interface DomainInterface { +interface DomainInterface +{ /** IE: "auth.example.com" or null if not using a separate subdomain * @return ?string Returns auth subdomain if configured, otherwise null */ public function getAuthSubdomain(): ?string; diff --git a/src/Service/DomainManager.php b/src/Service/DomainManager.php index d225382..0e8c288 100644 --- a/src/Service/DomainManager.php +++ b/src/Service/DomainManager.php @@ -1,11 +1,13 @@ ['com','net','off','org'], @@ -38,7 +40,8 @@ final readonly class DomainManager implements DomainInterface { /** IE: "auth.example.com" or null if not using a separate subdomain * @return ?string Returns auth subdomain if configured, otherwise null */ - public function getAuthSubdomain(): ?string { + public function getAuthSubdomain(): ?string + { if ($this->authBase()) { return $this->authSubdomain; } @@ -48,7 +51,8 @@ final readonly class DomainManager implements DomainInterface { /** check if given url is an acceptable url for redirection * @param string $url Where we are thinking of sending the user * @return bool Returns true if it is acceptable to send the user there */ - public function validReturn(string $url): bool { + public function validReturn(string $url): bool + { /* ensure url is valid and, when using an auth subdomain, * that the url host matches the base domain */ if (!filter_var($url, FILTER_VALIDATE_URL)) { @@ -70,7 +74,8 @@ final readonly class DomainManager implements DomainInterface { /** check if host-base matches auth-base * @param string $host * @return bool returns true if and only if host matches base domain of auth */ - public function matchesAuth(string $host): bool { + public function matchesAuth(string $host): bool + { $hostBase = $this->baseDomain($host); $authBase = $this->baseDomain($this->authSubdomain); return $this->subdomainRedirect && $this->authSubdomain && @@ -79,7 +84,8 @@ final readonly class DomainManager implements DomainInterface { /** IE: "example.com" if central auth is something like "auth.example.com" * @return string|null returns base domain if we are doing central auth */ - public function authBase(): ?string { + public function authBase(): ?string + { if ($this->subdomainRedirect && $this->authSubdomain && $this->baseDomain($this->authSubdomain)) { return $this->baseDomain($this->authSubdomain); } @@ -91,7 +97,8 @@ final readonly class DomainManager implements DomainInterface { * things like "localhost" and "8.8.8.8" will return null * @param string $host ip, localhost, or domain with zero or more subdomains * @return ?string returns null if host is ip or localhost otherwise domain with all subdomains removed */ - private function baseDomain(string $host): ?string { + private function baseDomain(string $host): ?string + { /* if host is an ip address (or localhost), leave it as is */ if (filter_var($host, FILTER_VALIDATE_IP) || $host === 'localhost') { return null; @@ -106,12 +113,13 @@ final readonly class DomainManager implements DomainInterface { /** IE: ["www", "example", "com"] or ["www", "example", "co", "uk"] * @param string[] $parts pieces of a domain split by "." dot * @return int typically 2 but sometimes 3 */ - private function baseLength(array $parts): int { + private function baseLength(array $parts): int + { $length = count($parts); $baseLength = min(2, $length); /* check if host should retain 3 parts, due to TLD */ - if (count($parts) > 2 && isset(self::TLD[$parts[$length-1]]) && - in_array($parts[$length-2], self::TLD[$parts[$length-1]], true) + if (count($parts) > 2 && isset(self::TLD[$parts[$length - 1]]) && + in_array($parts[$length - 2], self::TLD[$parts[$length - 1]], true) ) { $baseLength = min(3, $length); } diff --git a/src/Service/LoginInterface.php b/src/Service/LoginInterface.php index 98b58cb..84f3e44 100644 --- a/src/Service/LoginInterface.php +++ b/src/Service/LoginInterface.php @@ -7,7 +7,8 @@ use Psr\Cache\InvalidArgumentException; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -interface LoginInterface { +interface LoginInterface +{ /** @throws InvalidArgumentException */ public function checkToken(Payload $payload, Request $request): ?Response; } diff --git a/src/Service/LoginManager.php b/src/Service/LoginManager.php index d0079aa..2937de2 100644 --- a/src/Service/LoginManager.php +++ b/src/Service/LoginManager.php @@ -1,4 +1,5 @@ scope === Scope::Ip && ! $this->config->ipTtl()) { /* requested to grant ip access, but that is not enabled */ @@ -69,7 +72,7 @@ final readonly class LoginManager implements LoginInterface { /* grant access based on the requested scope */ if ($payload->scope === Scope::Cookie) { $response->headers->setCookie($this->setCookie($cleanId, $request->getHost())); - } else if ($payload->scope === Scope::Ip) { + } elseif ($payload->scope === Scope::Ip) { $this->setIp($cleanId, $request->getClientIp()); } @@ -104,7 +107,8 @@ final readonly class LoginManager implements LoginInterface { } /** @throws InvalidArgumentException */ - private function setCookie(string $id, string $host): Cookie { + private function setCookie(string $id, string $host): Cookie + { /* successful auth with token, store session and set the cookie */ $ulid = new Ulid(); $sessionCookie = $this->sessionCache->getItem( @@ -136,7 +140,8 @@ final readonly class LoginManager implements LoginInterface { } /** @throws InvalidArgumentException */ - private function setIp(string $id, string $ip): void { + private function setIp(string $id, string $ip): void + { /* successful auth with token, requested scope of ip (and ip access enabled) */ $ipKey = $this->makeCacheKey("ip_$ip"); diff --git a/src/Trait/CookieNameTrait.php b/src/Trait/CookieNameTrait.php index bdddde4..550b472 100644 --- a/src/Trait/CookieNameTrait.php +++ b/src/Trait/CookieNameTrait.php @@ -1,22 +1,27 @@ config = $config; } - protected function getTotp(): TOTPInterface { + protected function getTotp(): TOTPInterface + { $otp = Factory::loadFromProvisioningUri( - $this->config->totpUri(), $this->config->clock() + $this->config->totpUri(), + $this->config->clock() ); if ($otp instanceof TOTPInterface) { return $otp; diff --git a/src/Trait/HasLoggerTrait.php b/src/Trait/HasLoggerTrait.php index 57344e4..c3c4ef5 100644 --- a/src/Trait/HasLoggerTrait.php +++ b/src/Trait/HasLoggerTrait.php @@ -1,4 +1,5 @@ logger = $logger; } } diff --git a/src/Trait/MakeNonceTrait.php b/src/Trait/MakeNonceTrait.php index 5f2066f..c472e8b 100644 --- a/src/Trait/MakeNonceTrait.php +++ b/src/Trait/MakeNonceTrait.php @@ -1,4 +1,5 @@ nonceCache = $nonceCache; } /** @throws InvalidArgumentException|Exception */ - protected function makeNonce(int $retries = 3): string { + protected function makeNonce(int $retries = 3): string + { /* convert raw binary into base64url */ $nonce = rtrim(strtr(base64_encode(random_bytes( static::NONCE_LENGTH diff --git a/src/Trait/StringTrait.php b/src/Trait/StringTrait.php index e7f6f6c..72e32bb 100644 --- a/src/Trait/StringTrait.php +++ b/src/Trait/StringTrait.php @@ -1,13 +1,16 @@ appPool->hasItem('totp')) { $totp = $this->appPool->getItem('totp')->get(); @@ -31,7 +35,8 @@ final readonly class Utilities { } /** @throws InvalidArgumentException */ - private function makeTotp(): string { + private function makeTotp(): string + { /* we have not stored a totp into the app cache yet */ $totpObj = TOTP::generate($this->clock); $totpObj->setLabel('Preauth-TOTP'); @@ -41,21 +46,25 @@ final readonly class Utilities { /* per PSR6, if no expiration is set, implementation may set a default, * we want this to keep forever, so a few hundred years should do it */ $totpItem->expiresAt(DateTimeImmutable::createFromFormat( - 'Y-m-d', '2999-12-31' + 'Y-m-d', + '2999-12-31' )); $this->appPool->save($totpItem); return $totp; } - private function showTotp(string $totp): void { + private function showTotp(string $totp): void + { $writer = new Writer(new PlainTextRenderer()); file_put_contents( - 'php://stderr', <<writeString($totp)} $totp loading TOTP, because the env is not set, please copy above into TOTP_URI - RAW, FILE_APPEND + RAW, + FILE_APPEND ); } } diff --git a/symfony.lock b/symfony.lock index 90002fc..a53ef73 100644 --- a/symfony.lock +++ b/symfony.lock @@ -1,4 +1,16 @@ { + "friendsofphp/php-cs-fixer": { + "version": "3.95", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "3.39", + "ref": "97aaf9026490db73b86c23d49e5774bc89d2b232" + }, + "files": [ + ".php-cs-fixer.dist.php" + ] + }, "phpunit/phpunit": { "version": "13.2", "recipe": { diff --git a/tests/Functional/AuthenticationFlowTest.php b/tests/Functional/AuthenticationFlowTest.php index 43b445c..82a1c7d 100644 --- a/tests/Functional/AuthenticationFlowTest.php +++ b/tests/Functional/AuthenticationFlowTest.php @@ -1,4 +1,5 @@ LoginListener -> AllowListener -> * AcceptListener -> InterceptListener and the services they orchestrate. */ -final class AuthenticationFlowTest extends WebTestCase { - +final class AuthenticationFlowTest extends WebTestCase +{ private const string TOTP_SECRET = 'JBSWY3DPEHPK3PXP'; private const string COOKIE_NAME = '__Host-Http-Preauth'; @@ -32,13 +33,15 @@ final class AuthenticationFlowTest extends WebTestCase { return $client; } - private function validTotpCode(): string { + private function validTotpCode(): string + { // the app uses the real system clock, so generate the code for now() return TOTP::createFromSecret(self::TOTP_SECRET)->now(); } /** base64url-encode a payload, matching the client-side JS / X-Preauth header. */ - private function encodePayload(array $data): string { + private function encodePayload(array $data): string + { $json = json_encode($data, JSON_THROW_ON_ERROR); return rtrim(strtr(base64_encode($json), '+/', '-_'), '='); } @@ -59,7 +62,8 @@ final class AuthenticationFlowTest extends WebTestCase { /* ── unauthenticated access ──────────────────────────────────────── */ - public function testUnauthenticatedRequestShowsLoginPage(): void { + public function testUnauthenticatedRequestShowsLoginPage(): void + { $client = static::createClient(); $client->request('GET', '/'); @@ -71,7 +75,8 @@ final class AuthenticationFlowTest extends WebTestCase { self::assertSelectorExists('input[name="totp"]'); } - public function testLoginPageContainsGeneratedNonce(): void { + public function testLoginPageContainsGeneratedNonce(): void + { $client = static::createClient(); $crawler = $client->request('GET', '/'); @@ -81,7 +86,8 @@ final class AuthenticationFlowTest extends WebTestCase { self::assertMatchesRegularExpression('/^[A-Za-z0-9_-]+$/', $nonceInput); } - public function testLoginFormDoesNotUsePostMethodWithoutAuthSubdomain(): void { + public function testLoginFormDoesNotUsePostMethodWithoutAuthSubdomain(): void + { $client = static::createClient(); $crawler = $client->request('GET', '/'); @@ -93,7 +99,8 @@ final class AuthenticationFlowTest extends WebTestCase { /* ── successful TOTP login ────────────────────────────────────────── */ - public function testSuccessfulTotpLoginViaHeaderSetsCookieAndRedirects(): void { + public function testSuccessfulTotpLoginViaHeaderSetsCookieAndRedirects(): void + { $client = static::createClient(); // first, grab a valid nonce from the login page @@ -125,7 +132,8 @@ final class AuthenticationFlowTest extends WebTestCase { self::assertTrue($hasPreauthCookie, 'Expected a preauth cookie to be set after login'); } - public function testSuccessfulLoginReturnsJsonWhenJsonRequested(): void { + public function testSuccessfulLoginReturnsJsonWhenJsonRequested(): void + { $client = static::createClient(); $crawler = $client->request('GET', '/'); @@ -147,7 +155,8 @@ final class AuthenticationFlowTest extends WebTestCase { self::assertSame('Login successful', $body['message']); } - public function testSuccessfulLoginReturnsHtmlWhenJsonFalse(): void { + public function testSuccessfulLoginReturnsHtmlWhenJsonFalse(): void + { $client = static::createClient(); $crawler = $client->request('GET', '/'); @@ -167,7 +176,8 @@ final class AuthenticationFlowTest extends WebTestCase { self::assertStringStartsWith('text/html', $response->headers->get('Content-Type')); } - public function testAuthenticatedCookieAccessAfterLogin(): void { + public function testAuthenticatedCookieAccessAfterLogin(): void + { $client = static::createClient(); // login @@ -204,7 +214,8 @@ final class AuthenticationFlowTest extends WebTestCase { self::assertSame('dave', $response->headers->get('Remote-User')); } - public function testScopeNoneReturnsPlainTextWithoutRedirect(): void { + public function testScopeNoneReturnsPlainTextWithoutRedirect(): void + { $client = static::createClient(); $crawler = $client->request('GET', '/'); @@ -229,7 +240,8 @@ final class AuthenticationFlowTest extends WebTestCase { /* ── failed login ─────────────────────────────────────────────────── */ - public function testFailedLoginReturnsUnauthorizedJsonWithError(): void { + public function testFailedLoginReturnsUnauthorizedJsonWithError(): void + { $client = static::createClient(); $crawler = $client->request('GET', '/'); @@ -254,7 +266,8 @@ final class AuthenticationFlowTest extends WebTestCase { self::assertNotEmpty($body['nonce']); } - public function testFailedLoginReturnsHtmlWhenJsonFalse(): void { + public function testFailedLoginReturnsHtmlWhenJsonFalse(): void + { $client = static::createClient(); $crawler = $client->request('GET', '/'); @@ -275,7 +288,8 @@ final class AuthenticationFlowTest extends WebTestCase { self::assertSelectorExists('form#preauth-form'); } - public function testFailedLoginWithSpentNonceIsRejected(): void { + public function testFailedLoginWithSpentNonceIsRejected(): void + { $client = static::createClient(); $crawler = $client->request('GET', '/'); @@ -309,7 +323,8 @@ final class AuthenticationFlowTest extends WebTestCase { self::assertSame(401, $client->getResponse()->getStatusCode()); } - public function testFailedLoginWithInvalidNonceIsRejected(): void { + public function testFailedLoginWithInvalidNonceIsRejected(): void + { $client = static::createClient(); // skip fetching a real nonce; use one that was never stored @@ -327,7 +342,8 @@ final class AuthenticationFlowTest extends WebTestCase { /* ── invalid payload ──────────────────────────────────────────────── */ - public function testInvalidHeaderPayloadReturnsUnauthorized(): void { + public function testInvalidHeaderPayloadReturnsUnauthorized(): void + { $client = static::createClient(); $client->request('GET', '/', [], [], [ @@ -338,7 +354,8 @@ final class AuthenticationFlowTest extends WebTestCase { self::assertSame(401, $client->getResponse()->getStatusCode()); } - public function testPayloadWithMissingFieldsReturnsUnauthorized(): void { + public function testPayloadWithMissingFieldsReturnsUnauthorized(): void + { $client = static::createClient(); // payload missing token @@ -353,7 +370,8 @@ final class AuthenticationFlowTest extends WebTestCase { /* ── invalid cookie ───────────────────────────────────────────────── */ - public function testInvalidCookieIsClearedAndLoginPageShown(): void { + public function testInvalidCookieIsClearedAndLoginPageShown(): void + { $client = static::createClient(); // the cookie must be set via the CookieJar so that the HttpFoundation @@ -361,8 +379,15 @@ final class AuthenticationFlowTest extends WebTestCase { // not parsed by Request::create) $client->getCookieJar()->set( new \Symfony\Component\BrowserKit\Cookie( - self::COOKIE_NAME, 'invalid-ulid-value', - null, '/', 'localhost', true, true, false, 'Strict', + self::COOKIE_NAME, + 'invalid-ulid-value', + null, + '/', + 'localhost', + true, + true, + false, + 'Strict', ) ); @@ -383,7 +408,8 @@ final class AuthenticationFlowTest extends WebTestCase { /* ── backup code authentication ───────────────────────────────────── */ - public function testBackupCodeAuthenticationWorks(): void { + public function testBackupCodeAuthenticationWorks(): void + { $client = static::createClient(); $container = $client->getContainer(); @@ -407,7 +433,8 @@ final class AuthenticationFlowTest extends WebTestCase { self::assertSame(303, $client->getResponse()->getStatusCode()); } - public function testConsumedBackupCodeCannotBeReused(): void { + public function testConsumedBackupCodeCannotBeReused(): void + { $client = static::createClient(); $container = $client->getContainer(); @@ -442,7 +469,8 @@ final class AuthenticationFlowTest extends WebTestCase { /* ── return URL handling ──────────────────────────────────────────── */ - public function testSuccessfulLoginWithValidReturnUrl(): void { + public function testSuccessfulLoginWithValidReturnUrl(): void + { $client = static::createClient(); $crawler = $client->request('GET', '/?return=https://example.com/app'); @@ -460,7 +488,8 @@ final class AuthenticationFlowTest extends WebTestCase { self::assertSame('https://example.com/app', $response->headers->get('Location')); } - public function testSuccessfulLoginWithInvalidReturnFallsBackToPath(): void { + public function testSuccessfulLoginWithInvalidReturnFallsBackToPath(): void + { $client = static::createClient(); $crawler = $client->request('GET', '/?return=not-a-url'); diff --git a/tests/Support/ListenerTestHelper.php b/tests/Support/ListenerTestHelper.php index bd335b2..e312c93 100644 --- a/tests/Support/ListenerTestHelper.php +++ b/tests/Support/ListenerTestHelper.php @@ -1,4 +1,5 @@ true]); // the templates reference a global `env` object; supply one with the @@ -49,32 +52,43 @@ trait ListenerTestHelper { * A RateLimiterFactoryInterface whose created limiter returns a RateLimit * with the given remaining tokens. */ - private function makeRateLimiterFactory(int $remainingTokens): RateLimiterFactoryInterface { + private function makeRateLimiterFactory(int $remainingTokens): RateLimiterFactoryInterface + { $limiter = $this->makeLimiter($remainingTokens); - return new class($limiter) implements RateLimiterFactoryInterface { - public function __construct(private LimiterInterface $limiter) {} - public function create(?string $key = null): LimiterInterface { + return new class ($limiter) implements RateLimiterFactoryInterface { + public function __construct(private LimiterInterface $limiter) + { + } + public function create(?string $key = null): LimiterInterface + { return $this->limiter; } }; } - private function makeLimiter(int $remainingTokens): LimiterInterface { + private function makeLimiter(int $remainingTokens): LimiterInterface + { $rateLimit = new RateLimit( $remainingTokens, new \DateTimeImmutable('+10 seconds'), $remainingTokens > 0, 10, ); - return new class($rateLimit) implements LimiterInterface { - public function __construct(private RateLimit $rateLimit) {} - public function reserve(int $tokens = 1, ?float $maxTime = null): \Symfony\Component\RateLimiter\Reservation { + return new class ($rateLimit) implements LimiterInterface { + public function __construct(private RateLimit $rateLimit) + { + } + public function reserve(int $tokens = 1, ?float $maxTime = null): \Symfony\Component\RateLimiter\Reservation + { throw new \Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException(); } - public function consume(int $tokens = 1): RateLimit { + public function consume(int $tokens = 1): RateLimit + { return $this->rateLimit; } - public function reset(): void {} + public function reset(): void + { + } }; } @@ -82,14 +96,19 @@ trait ListenerTestHelper { * A factory whose limiter tracks how many consume(1) calls were made and * reports the limit as reached only after $threshold failures. */ - private function makeCountingRateLimiterFactory(int $threshold): RateLimiterFactoryInterface { - $limiter = new class($threshold) implements LimiterInterface { + private function makeCountingRateLimiterFactory(int $threshold): RateLimiterFactoryInterface + { + $limiter = new class ($threshold) implements LimiterInterface { private int $consumed = 0; - public function __construct(private int $threshold) {} - public function reserve(int $tokens = 1, ?float $maxTime = null): \Symfony\Component\RateLimiter\Reservation { + public function __construct(private int $threshold) + { + } + public function reserve(int $tokens = 1, ?float $maxTime = null): \Symfony\Component\RateLimiter\Reservation + { throw new \Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException(); } - public function consume(int $tokens = 1): RateLimit { + public function consume(int $tokens = 1): RateLimit + { $this->consumed += $tokens; $remaining = max(0, $this->threshold - $this->consumed); return new RateLimit( @@ -99,11 +118,17 @@ trait ListenerTestHelper { $this->threshold, ); } - public function reset(): void { $this->consumed = 0; } + public function reset(): void + { + $this->consumed = 0; + } }; - return new class($limiter) implements RateLimiterFactoryInterface { - public function __construct(private LimiterInterface $limiter) {} - public function create(?string $key = null): LimiterInterface { + return new class ($limiter) implements RateLimiterFactoryInterface { + public function __construct(private LimiterInterface $limiter) + { + } + public function create(?string $key = null): LimiterInterface + { return $this->limiter; } }; diff --git a/tests/Support/TotpTestHelper.php b/tests/Support/TotpTestHelper.php index 43ca9d8..9ac0706 100644 --- a/tests/Support/TotpTestHelper.php +++ b/tests/Support/TotpTestHelper.php @@ -1,4 +1,5 @@ time); } }; } /** Provisioning URI built from the well-known secret + frozen clock. */ - private function totpUri(): string { + private function totpUri(): string + { $totp = TOTP::createFromSecret(self::TOTP_SECRET, $this->frozenClock()); $totp->setLabel('Test-TOTP'); return $totp->getProvisioningUri(); } /** The TOTP code that is valid at the frozen timestamp. */ - private function validTotpCode(): string { + private function validTotpCode(): string + { return TOTP::createFromSecret(self::TOTP_SECRET, $this->frozenClock())->now(); } /** A fresh in-memory cache pool suitable for wrapping in MonitorCacheKeys. */ - private function emptyPool(): CacheItemPoolInterface { + private function emptyPool(): CacheItemPoolInterface + { return new ArrayAdapter(); } @@ -67,9 +76,15 @@ trait TotpTestHelper { $clock = $this->frozenClock(); $utilities = $this->createUtilities($clock); return new ConfigBag( - $utilities, $clock, - $cookieTtl, $this->totpUri(), $ipTtl, $teapot, - $errorMessage, $teapotTitle, $tooManyTitle, + $utilities, + $clock, + $cookieTtl, + $this->totpUri(), + $ipTtl, + $teapot, + $errorMessage, + $teapotTitle, + $tooManyTitle, ); } @@ -77,7 +92,8 @@ trait TotpTestHelper { * Minimal Utilities stub that never triggers TOTP generation when * a non-empty totpUri is supplied to ConfigBag. */ - private function createUtilities(?PsrClockInterface $clock = null): Utilities { + private function createUtilities(?PsrClockInterface $clock = null): Utilities + { $clock ??= $this->frozenClock(); $cache = $this->createStub(CacheItemPoolInterface::class); $cache->method('hasItem')->willReturn(false); diff --git a/tests/TestKernel.php b/tests/TestKernel.php index f4cc885..369568d 100644 --- a/tests/TestKernel.php +++ b/tests/TestKernel.php @@ -1,4 +1,5 @@ addCompilerPass(new class implements CompilerPassInterface { + $container->addCompilerPass(new class () implements CompilerPassInterface { public function process(ContainerBuilder $container): void { foreach (['nonceCache', 'rateLimitCache', 'sessionCache', 'sessionStorage'] as $poolId) { diff --git a/tests/Unit/ClockTest.php b/tests/Unit/ClockTest.php index 559cd6a..3a921ca 100644 --- a/tests/Unit/ClockTest.php +++ b/tests/Unit/ClockTest.php @@ -1,4 +1,5 @@ now(); diff --git a/tests/Unit/Command/GenerateBackupCodesCommandTest.php b/tests/Unit/Command/GenerateBackupCodesCommandTest.php index f8b5e62..694a9f6 100644 --- a/tests/Unit/Command/GenerateBackupCodesCommandTest.php +++ b/tests/Unit/Command/GenerateBackupCodesCommandTest.php @@ -1,4 +1,5 @@ createStub(BackupCodeInterface::class); $manager->method('generate')->willReturn($generatedCodes); return $manager; } - public function testGenerateDefaultCountOutputsCodes(): void { + public function testGenerateDefaultCountOutputsCodes(): void + { $codes = ['abc123', 'def456', 'ghi789', 'jkl012', 'mno345', 'pqr678', 'stu901', 'vwx234', 'yzA567', 'bCd890']; $command = new GenerateBackupCodesCommand( - $this->makeManagerStub($codes), $this->makePersistCache() + $this->makeManagerStub($codes), + $this->makePersistCache() ); $command->setName('app:generate-backup-codes'); @@ -42,7 +47,8 @@ final class GenerateBackupCodesCommandTest extends TestCase { } } - public function testGenerateSpecificCountPassesCountToManager(): void { + public function testGenerateSpecificCountPassesCountToManager(): void + { $manager = $this->createMock(BackupCodeInterface::class); $manager->expects(self::once()) ->method('generate') @@ -58,7 +64,8 @@ final class GenerateBackupCodesCommandTest extends TestCase { self::assertSame(0, $exit); } - public function testDefaultCountArgumentIsTen(): void { + public function testDefaultCountArgumentIsTen(): void + { // the configured default for the count argument should be 10 $manager = $this->createMock(BackupCodeInterface::class); $manager->expects(self::once()) @@ -76,13 +83,15 @@ final class GenerateBackupCodesCommandTest extends TestCase { $this->addToAssertionCount(1); } - public function testBootsAndPersistsCache(): void { + public function testBootsAndPersistsCache(): void + { // PersistCache is final and can't be mocked, but we can verify the // command runs end-to-end with a real instance; boot()/persist() // are invoked implicitly. A successful exit confirms both were called // without throwing. $command = new GenerateBackupCodesCommand( - $this->makeManagerStub(['code1']), $this->makePersistCache() + $this->makeManagerStub(['code1']), + $this->makePersistCache() ); $command->setName('app:generate-backup-codes'); @@ -92,9 +101,11 @@ final class GenerateBackupCodesCommandTest extends TestCase { self::assertSame(0, $exit); } - public function testZeroCodesOutputsNothing(): void { + public function testZeroCodesOutputsNothing(): void + { $command = new GenerateBackupCodesCommand( - $this->makeManagerStub([]), $this->makePersistCache() + $this->makeManagerStub([]), + $this->makePersistCache() ); $command->setName('app:generate-backup-codes'); @@ -105,9 +116,11 @@ final class GenerateBackupCodesCommandTest extends TestCase { self::assertSame('', trim($tester->getDisplay())); } - public function testCommandNameAndDescriptionAreConfigured(): void { + public function testCommandNameAndDescriptionAreConfigured(): void + { $command = new GenerateBackupCodesCommand( - $this->makeManagerStub([]), $this->makePersistCache() + $this->makeManagerStub([]), + $this->makePersistCache() ); // configuring via the Application runs the protected configure() $app = new \Symfony\Component\Console\Application(); diff --git a/tests/Unit/ConfigBagTest.php b/tests/Unit/ConfigBagTest.php index acddced..f690f46 100644 --- a/tests/Unit/ConfigBagTest.php +++ b/tests/Unit/ConfigBagTest.php @@ -1,4 +1,5 @@ createStub(ClockInterface::class); $cache = $this->createStub(CacheItemPoolInterface::class); @@ -28,14 +31,21 @@ final class ConfigBagTest extends TestCase { return new Utilities($clock, $cache); } - public function testGettersWithExplicitValues(): void { + public function testGettersWithExplicitValues(): void + { $clock = $this->createStub(ClockInterface::class); $utilities = $this->createUtilities(); $config = new ConfigBag( - $utilities, $clock, - 3600, 'otpauth://totp/test', 1800, true, - 'Error!', 'Teapot!', 'Too Many!' + $utilities, + $clock, + 3600, + 'otpauth://totp/test', + 1800, + true, + 'Error!', + 'Teapot!', + 'Too Many!' ); self::assertSame($clock, $config->clock()); @@ -48,43 +58,63 @@ final class ConfigBagTest extends TestCase { self::assertSame('Too Many!', $config->tooManyTitle()); } - public function testTotpUriFallsBackToUtilitiesWhenEmpty(): void { + public function testTotpUriFallsBackToUtilitiesWhenEmpty(): void + { $clock = $this->createStub(ClockInterface::class); $utilities = $this->createUtilities('fallback-totp'); $config = new ConfigBag( - $utilities, $clock, - 3600, '', 1800, false, - 'Error', 'Teapot', 'Too Many' + $utilities, + $clock, + 3600, + '', + 1800, + false, + 'Error', + 'Teapot', + 'Too Many' ); self::assertSame('fallback-totp', $config->totpUri()); } - public function testIpTtlFallsBackToNullWhenZero(): void { + public function testIpTtlFallsBackToNullWhenZero(): void + { $clock = $this->createStub(ClockInterface::class); $utilities = $this->createUtilities(); $config = new ConfigBag( - $utilities, $clock, - 3600, 'otpauth://totp/test', 0, false, - 'Error', 'Teapot', 'Too Many' + $utilities, + $clock, + 3600, + 'otpauth://totp/test', + 0, + false, + 'Error', + 'Teapot', + 'Too Many' ); self::assertNull($config->ipTtl()); } - public function testIpTtlFallsBackToNullWhenNull(): void { + public function testIpTtlFallsBackToNullWhenNull(): void + { $clock = $this->createStub(ClockInterface::class); $utilities = $this->createUtilities(); $config = new ConfigBag( - $utilities, $clock, - 3600, 'otpauth://totp/test', null, false, - 'Error', 'Teapot', 'Too Many' + $utilities, + $clock, + 3600, + 'otpauth://totp/test', + null, + false, + 'Error', + 'Teapot', + 'Too Many' ); self::assertNull($config->ipTtl()); } } - diff --git a/tests/Unit/Data/PayloadTest.php b/tests/Unit/Data/PayloadTest.php index b86fe30..d9fc4e0 100644 --- a/tests/Unit/Data/PayloadTest.php +++ b/tests/Unit/Data/PayloadTest.php @@ -1,4 +1,5 @@ 'testuser', 'token' => '123456', 'nonce' => 'abc123', 'json' => true, 'scope' => 'cookie', @@ -28,41 +32,50 @@ final class PayloadTest extends TestCase { self::assertSame(Scope::Cookie, $payload->scope); } - public function testDecodeInvalidBase64UrlReturnsNull(): void { + public function testDecodeInvalidBase64UrlReturnsNull(): void + { self::assertNull(Payload::decode('!!!not-valid-base64!!!')); } - public function testDecodeNonObjectJsonReturnsNull(): void { + public function testDecodeNonObjectJsonReturnsNull(): void + { self::assertNull(Payload::decode(self::b64u('"just a string"'))); } - public function testDecodeInvalidJsonReturnsNull(): void { + public function testDecodeInvalidJsonReturnsNull(): void + { // valid base64url but invalid JSON self::assertNull(Payload::decode(self::b64u('{invalid json'))); } - public function testDecodeJsonArrayReturnsNull(): void { + public function testDecodeJsonArrayReturnsNull(): void + { self::assertNull(Payload::decode(self::b64u('[1,2,3]'))); } - public function testDecodeJsonNullReturnsNull(): void { + public function testDecodeJsonNullReturnsNull(): void + { self::assertNull(Payload::decode(self::b64u('null'))); } - public function testDecodeJsonBooleanReturnsNull(): void { + public function testDecodeJsonBooleanReturnsNull(): void + { self::assertNull(Payload::decode(self::b64u('true'))); self::assertNull(Payload::decode(self::b64u('false'))); } - public function testDecodeJsonNumberReturnsNull(): void { + public function testDecodeJsonNumberReturnsNull(): void + { self::assertNull(Payload::decode(self::b64u('42'))); } - public function testDecodeEmptyStringReturnsNull(): void { + public function testDecodeEmptyStringReturnsNull(): void + { self::assertNull(Payload::decode('')); } - public function testLoadWithValidInputBag(): void { + public function testLoadWithValidInputBag(): void + { $input = new InputBag([ 'username' => 'alice', 'nonce' => 'nonce123', 'totp' => '654321', ]); @@ -76,28 +89,33 @@ final class PayloadTest extends TestCase { self::assertSame(Scope::Cookie, $payload->scope); } - public function testLoadMissingUsernameReturnsNull(): void { + public function testLoadMissingUsernameReturnsNull(): void + { $input = new InputBag(['nonce' => 'n', 'totp' => 't']); self::assertNull(Payload::load($input)); } - public function testLoadMissingNonceReturnsNull(): void { + public function testLoadMissingNonceReturnsNull(): void + { $input = new InputBag(['username' => 'u', 'totp' => 't']); self::assertNull(Payload::load($input)); } - public function testLoadMissingTotpReturnsNull(): void { + public function testLoadMissingTotpReturnsNull(): void + { $input = new InputBag(['username' => 'u', 'nonce' => 'n']); self::assertNull(Payload::load($input)); } - public function testLoadWithAllFieldsPresentButEmptyReturnsNull(): void { + public function testLoadWithAllFieldsPresentButEmptyReturnsNull(): void + { // has() returns true for all, but create() rejects empty values $input = new InputBag(['username' => '', 'nonce' => '', 'totp' => '']); self::assertNull(Payload::load($input)); } - public function testCreateWithValidData(): void { + public function testCreateWithValidData(): void + { $data = (object)[ 'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1', 'json' => false, 'scope' => 'ip', @@ -112,13 +130,15 @@ final class PayloadTest extends TestCase { self::assertSame(Scope::Ip, $payload->scope); } - public function testCreateWithDefaultScope(): void { + public function testCreateWithDefaultScope(): void + { $data = (object)['id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1']; $payload = Payload::create($data); self::assertSame(Scope::Cookie, $payload->scope); } - public function testCreateWithInvalidScopeFallsBackToCookie(): void { + public function testCreateWithInvalidScopeFallsBackToCookie(): void + { $data = (object)[ 'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1', 'scope' => 'admin', @@ -127,13 +147,15 @@ final class PayloadTest extends TestCase { self::assertSame(Scope::Cookie, $payload->scope); } - public function testCreateWithMissingJsonDefaultsToTrue(): void { + public function testCreateWithMissingJsonDefaultsToTrue(): void + { $data = (object)['id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1']; $payload = Payload::create($data); self::assertTrue($payload->json); } - public function testCreateWithNoneScopeSetsJsonFalse(): void { + public function testCreateWithNoneScopeSetsJsonFalse(): void + { $data = (object)[ 'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1', 'json' => true, 'scope' => 'none', @@ -143,27 +165,32 @@ final class PayloadTest extends TestCase { self::assertFalse($payload->json); } - public function testCreateWithEmptyIdReturnsNull(): void { + public function testCreateWithEmptyIdReturnsNull(): void + { $data = (object)['id' => '', 'token' => 't', 'nonce' => 'n']; self::assertNull(Payload::create($data)); } - public function testCreateWithWhitespaceIdReturnsNull(): void { + public function testCreateWithWhitespaceIdReturnsNull(): void + { $data = (object)['id' => ' ', 'token' => 't', 'nonce' => 'n']; self::assertNull(Payload::create($data)); } - public function testCreateWithEmptyTokenReturnsNull(): void { + public function testCreateWithEmptyTokenReturnsNull(): void + { $data = (object)['id' => 'u', 'token' => '', 'nonce' => 'n']; self::assertNull(Payload::create($data)); } - public function testCreateWithEmptyNonceReturnsNull(): void { + public function testCreateWithEmptyNonceReturnsNull(): void + { $data = (object)['id' => 'u', 'token' => 't', 'nonce' => '']; self::assertNull(Payload::create($data)); } - public function testCreateTrimsAndTruncatesFields(): void { + public function testCreateTrimsAndTruncatesFields(): void + { $long = str_repeat('a', 200); $data = (object)[ 'id' => ' ' . $long . ' ', @@ -177,7 +204,8 @@ final class PayloadTest extends TestCase { self::assertSame($expected, $payload->nonce); } - public function testToString(): void { + public function testToString(): void + { $payload = new Payload(); $payload->id = 'u'; $payload->token = 't'; diff --git a/tests/Unit/Enum/ScopeTest.php b/tests/Unit/Enum/ScopeTest.php index 071f58d..2ecd532 100644 --- a/tests/Unit/Enum/ScopeTest.php +++ b/tests/Unit/Enum/ScopeTest.php @@ -1,4 +1,5 @@ value); self::assertSame('ip', Scope::Ip->value); self::assertSame('none', Scope::None->value); } - public function testTryFromValid(): void { + public function testTryFromValid(): void + { self::assertSame(Scope::Cookie, Scope::tryFrom('cookie')); self::assertSame(Scope::Ip, Scope::tryFrom('ip')); self::assertSame(Scope::None, Scope::tryFrom('none')); } - public function testTryFromInvalid(): void { + public function testTryFromInvalid(): void + { self::assertNull(Scope::tryFrom('invalid')); self::assertNull(Scope::tryFrom('')); } diff --git a/tests/Unit/Listener/AcceptListenerTest.php b/tests/Unit/Listener/AcceptListenerTest.php index 41b04a9..f360088 100644 --- a/tests/Unit/Listener/AcceptListenerTest.php +++ b/tests/Unit/Listener/AcceptListenerTest.php @@ -1,4 +1,5 @@ setLogger(new NullLogger()); return $listener; } - private function makeEvent(Request $request): RequestEvent { + private function makeEvent(Request $request): RequestEvent + { return new RequestEvent( $this->createStub(\Symfony\Component\HttpKernel\HttpKernelInterface::class), $request, @@ -36,7 +40,8 @@ final class AcceptListenerTest extends TestCase { /* ── valid cookie session ─────────────────────────────────────────── */ - public function testValidCookieSetsResponseWithRemoteUser(): void { + public function testValidCookieSetsResponseWithRemoteUser(): void + { $pool = new ArrayAdapter(); $ulid = '01HXY1234567890ABCDEFGHIJK'; $item = $pool->getItem('cookie_' . $ulid); @@ -59,7 +64,8 @@ final class AcceptListenerTest extends TestCase { self::assertSame('text/plain', $response->headers->get('Content-Type')); } - public function testValidCookieUsesAuthCookieNameWhenUsingCentralAuth(): void { + public function testValidCookieUsesAuthCookieNameWhenUsingCentralAuth(): void + { $pool = new ArrayAdapter(); $ulid = '01HXY1234567890ABCDEFGHIJK'; $item = $pool->getItem('cookie_' . $ulid); @@ -81,7 +87,8 @@ final class AcceptListenerTest extends TestCase { /* ── negative cases ───────────────────────────────────────────────── */ - public function testNoCookieSetsNoResponse(): void { + public function testNoCookieSetsNoResponse(): void + { $pool = new ArrayAdapter(); $domainManager = new DomainManager(false, ''); $listener = $this->makeListener($pool, $domainManager); @@ -92,7 +99,8 @@ final class AcceptListenerTest extends TestCase { self::assertFalse($event->hasResponse()); } - public function testCookieWithoutSessionSetsNoResponse(): void { + public function testCookieWithoutSessionSetsNoResponse(): void + { $pool = new ArrayAdapter(); $domainManager = new DomainManager(false, ''); $listener = $this->makeListener($pool, $domainManager); @@ -106,7 +114,8 @@ final class AcceptListenerTest extends TestCase { self::assertFalse($event->hasResponse()); } - public function testEmptyCookieValueSetsNoResponse(): void { + public function testEmptyCookieValueSetsNoResponse(): void + { $pool = new ArrayAdapter(); $domainManager = new DomainManager(false, ''); $listener = $this->makeListener($pool, $domainManager); diff --git a/tests/Unit/Listener/AllowListenerTest.php b/tests/Unit/Listener/AllowListenerTest.php index 3b4917e..5641447 100644 --- a/tests/Unit/Listener/AllowListenerTest.php +++ b/tests/Unit/Listener/AllowListenerTest.php @@ -1,4 +1,5 @@ setLogger(new NullLogger()); return $listener; } - private function makeEvent(Request $request): RequestEvent { + private function makeEvent(Request $request): RequestEvent + { return new RequestEvent( $this->createStub(HttpKernelInterface::class), $request, @@ -30,7 +34,8 @@ final class AllowListenerTest extends TestCase { ); } - public function testValidIpSessionSetsResponseWithRemoteUser(): void { + public function testValidIpSessionSetsResponseWithRemoteUser(): void + { $pool = new ArrayAdapter(); $item = $pool->getItem('ip_1.2.3.4'); $item->set('carol'); @@ -50,7 +55,8 @@ final class AllowListenerTest extends TestCase { self::assertSame('text/plain', $response->headers->get('Content-Type')); } - public function testNoIpSessionSetsNoResponse(): void { + public function testNoIpSessionSetsNoResponse(): void + { $pool = new ArrayAdapter(); $config = $this->makeConfig(ipTtl: 1800); $listener = $this->makeListener($pool, $config); @@ -62,7 +68,8 @@ final class AllowListenerTest extends TestCase { self::assertFalse($event->hasResponse()); } - public function testIpAccessDisabledSetsNoResponse(): void { + public function testIpAccessDisabledSetsNoResponse(): void + { $pool = new ArrayAdapter(); // even though there's a stored session, ip access is disabled $item = $pool->getItem('ip_1.2.3.4'); @@ -79,7 +86,8 @@ final class AllowListenerTest extends TestCase { self::assertFalse($event->hasResponse()); } - public function testIpAccessDisabledDoesNotCheckCache(): void { + public function testIpAccessDisabledDoesNotCheckCache(): void + { $pool = new ArrayAdapter(); $config = $this->makeConfig(ipTtl: 0); $listener = $this->makeListener($pool, $config); diff --git a/tests/Unit/Listener/InterceptListenerTest.php b/tests/Unit/Listener/InterceptListenerTest.php index 8ca30c1..d12e11f 100644 --- a/tests/Unit/Listener/InterceptListenerTest.php +++ b/tests/Unit/Listener/InterceptListenerTest.php @@ -1,4 +1,5 @@ createStub(HttpKernelInterface::class), $request, @@ -46,7 +49,8 @@ final class InterceptListenerTest extends TestCase { /* ── central-auth redirect branch ─────────────────────────────────── */ - public function testRedirectsToAuthSubdomainWhenHostMatchesBaseDomain(): void { + public function testRedirectsToAuthSubdomainWhenHostMatchesBaseDomain(): void + { $domainManager = new DomainManager(true, 'auth.example.com'); $listener = $this->makeListener($domainManager); @@ -64,7 +68,8 @@ final class InterceptListenerTest extends TestCase { self::assertStringContainsString(urlencode('https://app.example.com/dashboard'), $location); } - public function testDoesNotRedirectWhenAlreadyOnAuthSubdomain(): void { + public function testDoesNotRedirectWhenAlreadyOnAuthSubdomain(): void + { $domainManager = new DomainManager(true, 'auth.example.com'); $listener = $this->makeListener($domainManager); @@ -81,7 +86,8 @@ final class InterceptListenerTest extends TestCase { /* ── login page rendering branch ──────────────────────────────────── */ - public function testPresentsLoginPageWithUnauthorizedStatus(): void { + public function testPresentsLoginPageWithUnauthorizedStatus(): void + { $domainManager = new DomainManager(false, ''); $listener = $this->makeListener($domainManager); @@ -99,7 +105,8 @@ final class InterceptListenerTest extends TestCase { self::assertStringContainsString('name="nonce"', $content); } - public function testGeneratedNonceIsStoredInCache(): void { + public function testGeneratedNonceIsStoredInCache(): void + { $nonceCache = new ArrayAdapter(); $domainManager = new DomainManager(false, ''); $listener = $this->makeListener($domainManager, $nonceCache); @@ -119,7 +126,8 @@ final class InterceptListenerTest extends TestCase { self::assertTrue(count($nonceCache->getValues()) > 0); } - public function testLoginTemplateUsesPostFormWhenOnAuthSubdomain(): void { + public function testLoginTemplateUsesPostFormWhenOnAuthSubdomain(): void + { $domainManager = new DomainManager(true, 'auth.example.com'); $listener = $this->makeListener($domainManager); @@ -132,7 +140,8 @@ final class InterceptListenerTest extends TestCase { self::assertStringContainsString('method="post"', $content); } - public function testLoginTemplateDoesNotUsePostFormWhenNotOnAuthSubdomain(): void { + public function testLoginTemplateDoesNotUsePostFormWhenNotOnAuthSubdomain(): void + { $domainManager = new DomainManager(false, ''); $listener = $this->makeListener($domainManager); @@ -147,7 +156,8 @@ final class InterceptListenerTest extends TestCase { /* ── invalid cookie pruning ───────────────────────────────────────── */ - public function testInvalidCookieIsClearedWhenPresent(): void { + public function testInvalidCookieIsClearedWhenPresent(): void + { $domainManager = new DomainManager(false, ''); $listener = $this->makeListener($domainManager); @@ -171,7 +181,8 @@ final class InterceptListenerTest extends TestCase { self::assertTrue($cleared, 'Expected the invalid cookie to be cleared'); } - public function testNoCookieClearingWhenNoCookiePresent(): void { + public function testNoCookieClearingWhenNoCookiePresent(): void + { $domainManager = new DomainManager(false, ''); $listener = $this->makeListener($domainManager); @@ -183,7 +194,8 @@ final class InterceptListenerTest extends TestCase { self::assertSame([], $response->headers->getCookies()); } - public function testInvalidCookieUsesAuthCookieNameWithCentralAuth(): void { + public function testInvalidCookieUsesAuthCookieNameWithCentralAuth(): void + { $domainManager = new DomainManager(true, 'auth.example.com'); $listener = $this->makeListener($domainManager); diff --git a/tests/Unit/Listener/LoginListenerTest.php b/tests/Unit/Listener/LoginListenerTest.php index d8d1b65..9ca06eb 100644 --- a/tests/Unit/Listener/LoginListenerTest.php +++ b/tests/Unit/Listener/LoginListenerTest.php @@ -1,4 +1,5 @@ createStub(HttpKernelInterface::class), $request, @@ -48,14 +51,16 @@ final class LoginListenerTest extends TestCase { } /** Build a base64url-encoded X-Preauth header value for a payload. */ - private function encodePayload(array $data): string { + private function encodePayload(array $data): string + { $json = json_encode($data, JSON_THROW_ON_ERROR); return rtrim(strtr(base64_encode($json), '+/', '-_'), '='); } /* ── no login attempt ─────────────────────────────────────────────── */ - public function testNoHeaderAndNoPostReturnsEarlyWithoutResponse(): void { + public function testNoHeaderAndNoPostReturnsEarlyWithoutResponse(): void + { $listener = $this->makeListener(); $request = Request::create('https://example.com/', 'GET'); @@ -65,7 +70,8 @@ final class LoginListenerTest extends TestCase { self::assertFalse($event->hasResponse()); } - public function testPostToNonAuthSubdomainReturnsEarlyWithoutResponse(): void { + public function testPostToNonAuthSubdomainReturnsEarlyWithoutResponse(): void + { // POST only counts as a login attempt when on the auth subdomain $domainManager = new DomainManager(true, 'auth.example.com'); $listener = $this->makeListener(domainManager: $domainManager); @@ -79,7 +85,8 @@ final class LoginListenerTest extends TestCase { /* ── successful login via header ──────────────────────────────────── */ - public function testSuccessfulLoginViaHeaderSetsResponseFromManager(): void { + public function testSuccessfulLoginViaHeaderSetsResponseFromManager(): void + { $expected = new Response('hi alice', 200, ['Remote-User' => 'alice']); $loginManager = $this->createStub(LoginInterface::class); $loginManager->method('checkToken')->willReturn($expected); @@ -99,7 +106,8 @@ final class LoginListenerTest extends TestCase { self::assertSame($expected, $event->getResponse()); } - public function testSuccessfulLoginViaPostToAuthSubdomain(): void { + public function testSuccessfulLoginViaPostToAuthSubdomain(): void + { $expected = new Response('hi bob', 303, ['Location' => '/']); $loginManager = $this->createStub(LoginInterface::class); $loginManager->method('checkToken')->willReturn($expected); @@ -120,7 +128,8 @@ final class LoginListenerTest extends TestCase { /* ── failed login ─────────────────────────────────────────────────── */ - public function testFailedLoginReturnsJsonErrorWithNewNonce(): void { + public function testFailedLoginReturnsJsonErrorWithNewNonce(): void + { $loginManager = $this->createStub(LoginInterface::class); $loginManager->method('checkToken')->willReturn(null); @@ -148,7 +157,8 @@ final class LoginListenerTest extends TestCase { self::assertSame('alice', $body['username']); } - public function testFailedLoginHtmlResponseWhenJsonFalse(): void { + public function testFailedLoginHtmlResponseWhenJsonFalse(): void + { $loginManager = $this->createStub(LoginInterface::class); $loginManager->method('checkToken')->willReturn(null); @@ -169,7 +179,8 @@ final class LoginListenerTest extends TestCase { self::assertStringContainsString('getContent()); } - public function testFailedLoginOnAuthSubdomainUsesPostForm(): void { + public function testFailedLoginOnAuthSubdomainUsesPostForm(): void + { $loginManager = $this->createStub(LoginInterface::class); $loginManager->method('checkToken')->willReturn(null); @@ -194,7 +205,8 @@ final class LoginListenerTest extends TestCase { /* ── rate-limited (blocked) login ─────────────────────────────────── */ - public function testRateLimitedLoginReturnsTeapotWhenTeapotEnabled(): void { + public function testRateLimitedLoginReturnsTeapotWhenTeapotEnabled(): void + { $loginManager = $this->createStub(LoginInterface::class); $loginManager->method('checkToken')->willReturn(null); @@ -220,7 +232,8 @@ final class LoginListenerTest extends TestCase { self::assertSame('Teapot', $body['message']); } - public function testRateLimitedLoginReturnsTooManyRequestsWhenTeapotDisabled(): void { + public function testRateLimitedLoginReturnsTooManyRequestsWhenTeapotDisabled(): void + { $loginManager = $this->createStub(LoginInterface::class); $loginManager->method('checkToken')->willReturn(null); @@ -252,7 +265,8 @@ final class LoginListenerTest extends TestCase { /* ── invalid payload handling ─────────────────────────────────────── */ - public function testInvalidHeaderPayloadStillRecordsFailureAndResponds(): void { + public function testInvalidHeaderPayloadStillRecordsFailureAndResponds(): void + { $loginManager = $this->createMock(LoginInterface::class); // checkToken should not be called with a null payload $loginManager->expects(self::never())->method('checkToken'); @@ -272,7 +286,8 @@ final class LoginListenerTest extends TestCase { self::assertSame(Response::HTTP_UNAUTHORIZED, $event->getResponse()->getStatusCode()); } - public function testPostWithoutRequiredFieldsDoesNotAttemptLogin(): void { + public function testPostWithoutRequiredFieldsDoesNotAttemptLogin(): void + { $loginManager = $this->createMock(LoginInterface::class); $loginManager->expects(self::never())->method('checkToken'); diff --git a/tests/Unit/Listener/RejectListenerTest.php b/tests/Unit/Listener/RejectListenerTest.php index 2e8ab5d..d3307f8 100644 --- a/tests/Unit/Listener/RejectListenerTest.php +++ b/tests/Unit/Listener/RejectListenerTest.php @@ -1,4 +1,5 @@ createStub(HttpKernelInterface::class), $request, @@ -37,7 +40,8 @@ final class RejectListenerTest extends TestCase { ); } - public function testBlockedRequestReturnsTeapotWhenTeapotEnabled(): void { + public function testBlockedRequestReturnsTeapotWhenTeapotEnabled(): void + { $listener = $this->makeListener(teapot: true, remainingTokens: 0); $request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']); @@ -50,7 +54,8 @@ final class RejectListenerTest extends TestCase { self::assertSame('text/html', $response->headers->get('Content-Type')); } - public function testBlockedRequestReturnsTooManyRequestsWhenTeapotDisabled(): void { + public function testBlockedRequestReturnsTooManyRequestsWhenTeapotDisabled(): void + { $listener = $this->makeListener(teapot: false, remainingTokens: 0); $request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']); @@ -63,7 +68,8 @@ final class RejectListenerTest extends TestCase { self::assertSame('text/html', $response->headers->get('Content-Type')); } - public function testUnblockedRequestSetsNoResponse(): void { + public function testUnblockedRequestSetsNoResponse(): void + { $listener = $this->makeListener(remainingTokens: 5); $request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']); @@ -74,7 +80,8 @@ final class RejectListenerTest extends TestCase { self::assertFalse($event->hasResponse()); } - public function testBlockedResponseContainsErrorTemplateContent(): void { + public function testBlockedResponseContainsErrorTemplateContent(): void + { $listener = $this->makeListener(teapot: true, remainingTokens: 0); $request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']); diff --git a/tests/Unit/MonitorCacheKeysTest.php b/tests/Unit/MonitorCacheKeysTest.php index 32105e8..ba8400f 100644 --- a/tests/Unit/MonitorCacheKeysTest.php +++ b/tests/Unit/MonitorCacheKeysTest.php @@ -1,4 +1,5 @@ wrap(); self::assertSame([], $monitor->getKeys()); self::assertSame([], $monitor->getChanges()); } - public function testSaveAddsKeyAndTracksChange(): void { + public function testSaveAddsKeyAndTracksChange(): void + { $monitor = $this->wrap(); $item = $monitor->getItem('alpha'); $item->set('value'); @@ -31,7 +36,8 @@ final class MonitorCacheKeysTest extends TestCase { self::assertSame(['alpha' => MonitorCacheKeys::UPDATED], $monitor->getChanges()); } - public function testSaveDeferredThenCommitAddsKey(): void { + public function testSaveDeferredThenCommitAddsKey(): void + { $monitor = $this->wrap(); $item = $monitor->getItem('beta'); $item->set('value'); @@ -42,7 +48,8 @@ final class MonitorCacheKeysTest extends TestCase { self::assertSame(['beta' => MonitorCacheKeys::UPDATED], $monitor->getChanges()); } - public function testGetItemReturnsUnderlyingItem(): void { + public function testGetItemReturnsUnderlyingItem(): void + { $monitor = $this->wrap(); $item = $monitor->getItem('mykey'); $item->set('data'); @@ -53,7 +60,8 @@ final class MonitorCacheKeysTest extends TestCase { self::assertSame('data', $fetched->get()); } - public function testGetItemsReturnsMultipleItems(): void { + public function testGetItemsReturnsMultipleItems(): void + { $monitor = $this->wrap(); $a = $monitor->getItem('a'); $a->set(1); @@ -70,7 +78,8 @@ final class MonitorCacheKeysTest extends TestCase { self::assertSame(['a' => 1, 'b' => 2], $keys); } - public function testHasItemReturnsTrueForExistingKey(): void { + public function testHasItemReturnsTrueForExistingKey(): void + { $monitor = $this->wrap(); $item = $monitor->getItem('exists'); $item->set('v'); @@ -80,7 +89,8 @@ final class MonitorCacheKeysTest extends TestCase { self::assertFalse($monitor->hasItem('missing')); } - public function testDeleteItemRemovesKeyAndTracksRemoval(): void { + public function testDeleteItemRemovesKeyAndTracksRemoval(): void + { $monitor = $this->wrap(); $item = $monitor->getItem('doomed'); $item->set('v'); @@ -93,7 +103,8 @@ final class MonitorCacheKeysTest extends TestCase { self::assertFalse($monitor->hasItem('doomed')); } - public function testDeleteItemOnMissingKeyIsNoop(): void { + public function testDeleteItemOnMissingKeyIsNoop(): void + { $monitor = $this->wrap(); $result = $monitor->deleteItem('nonexistent'); @@ -102,7 +113,8 @@ final class MonitorCacheKeysTest extends TestCase { self::assertSame([], $monitor->getKeys()); } - public function testDeleteItemsRemovesMultipleKeys(): void { + public function testDeleteItemsRemovesMultipleKeys(): void + { $monitor = $this->wrap(); foreach (['x', 'y', 'z'] as $key) { $item = $monitor->getItem($key); @@ -118,7 +130,8 @@ final class MonitorCacheKeysTest extends TestCase { self::assertSame(MonitorCacheKeys::REMOVED, $changes['y']); } - public function testDeleteItemsWithMissingKeysStillReturnsTrue(): void { + public function testDeleteItemsWithMissingKeysStillReturnsTrue(): void + { $monitor = $this->wrap(); $result = $monitor->deleteItems(['ghost1', 'ghost2']); @@ -126,7 +139,8 @@ final class MonitorCacheKeysTest extends TestCase { self::assertTrue($result); } - public function testClearWipesPoolWhenNotEmpty(): void { + public function testClearWipesPoolWhenNotEmpty(): void + { $monitor = $this->wrap(); $item = $monitor->getItem('keep'); $item->set('v'); @@ -138,7 +152,8 @@ final class MonitorCacheKeysTest extends TestCase { self::assertSame([], $monitor->getKeys()); } - public function testClearIsNoopWhenEmpty(): void { + public function testClearIsNoopWhenEmpty(): void + { $monitor = $this->wrap(); $result = $monitor->clear(); @@ -146,7 +161,8 @@ final class MonitorCacheKeysTest extends TestCase { self::assertTrue($result); } - public function testMarkCleanResetsChangeList(): void { + public function testMarkCleanResetsChangeList(): void + { $monitor = $this->wrap(); $item = $monitor->getItem('temp'); $item->set('v'); @@ -160,13 +176,15 @@ final class MonitorCacheKeysTest extends TestCase { self::assertSame(['temp'], $monitor->getKeys()); } - public function testCommitPassesThrough(): void { + public function testCommitPassesThrough(): void + { $monitor = $this->wrap(); self::assertTrue($monitor->commit()); } - public function testSaveKeyListThrowsOutOfBoundsException(): void { + public function testSaveKeyListThrowsOutOfBoundsException(): void + { $monitor = $this->wrap(); $item = $monitor->getItem('__key_list'); @@ -174,7 +192,8 @@ final class MonitorCacheKeysTest extends TestCase { $monitor->save($item); } - public function testSaveChangeListThrowsOutOfBoundsException(): void { + public function testSaveChangeListThrowsOutOfBoundsException(): void + { $monitor = $this->wrap(); $item = $monitor->getItem('__chg_list'); @@ -182,35 +201,40 @@ final class MonitorCacheKeysTest extends TestCase { $monitor->save($item); } - public function testDeleteKeyListThrowsOutOfBoundsException(): void { + public function testDeleteKeyListThrowsOutOfBoundsException(): void + { $monitor = $this->wrap(); $this->expectException(OutOfBoundsException::class); $monitor->deleteItem('__key_list'); } - public function testDeleteChangeListThrowsOutOfBoundsException(): void { + public function testDeleteChangeListThrowsOutOfBoundsException(): void + { $monitor = $this->wrap(); $this->expectException(OutOfBoundsException::class); $monitor->deleteItem('__chg_list'); } - public function testDeleteItemsWithKeyListThrowsOutOfBoundsException(): void { + public function testDeleteItemsWithKeyListThrowsOutOfBoundsException(): void + { $monitor = $this->wrap(); $this->expectException(OutOfBoundsException::class); $monitor->deleteItems(['safe', '__key_list']); } - public function testDeleteItemsWithChangeListThrowsOutOfBoundsException(): void { + public function testDeleteItemsWithChangeListThrowsOutOfBoundsException(): void + { $monitor = $this->wrap(); $this->expectException(OutOfBoundsException::class); $monitor->deleteItems(['__chg_list']); } - public function testSaveDeferredOnKeyListThrowsOutOfBoundsException(): void { + public function testSaveDeferredOnKeyListThrowsOutOfBoundsException(): void + { $monitor = $this->wrap(); $item = $monitor->getItem('safe'); $item->set('value'); @@ -223,7 +247,8 @@ final class MonitorCacheKeysTest extends TestCase { $monitor->saveDeferred($keyListItem); } - public function testSaveDeferredOnChangeListThrowsOutOfBoundsException(): void { + public function testSaveDeferredOnChangeListThrowsOutOfBoundsException(): void + { $monitor = $this->wrap(); $changeListItem = $monitor->getItem('__chg_list'); @@ -231,7 +256,8 @@ final class MonitorCacheKeysTest extends TestCase { $monitor->saveDeferred($changeListItem); } - public function testGetKeysReturnsEmptyArrayWhenKeyListMissing(): void { + public function testGetKeysReturnsEmptyArrayWhenKeyListMissing(): void + { // If the underlying pool loses its key list, getKeys should return [] $pool = new ArrayAdapter(); $monitor = new MonitorCacheKeys($pool); @@ -249,7 +275,8 @@ final class MonitorCacheKeysTest extends TestCase { self::assertSame([], $monitor2->getKeys()); } - public function testDeleteItemReturnsTrueForExistingKey(): void { + public function testDeleteItemReturnsTrueForExistingKey(): void + { $monitor = $this->wrap(); $item = $monitor->getItem('to-delete'); $item->set('value'); @@ -259,7 +286,8 @@ final class MonitorCacheKeysTest extends TestCase { self::assertNotContains('to-delete', $monitor->getKeys()); } - public function testDeleteItemsReturnsTrue(): void { + public function testDeleteItemsReturnsTrue(): void + { $monitor = $this->wrap(); foreach (['a', 'b', 'c'] as $key) { $item = $monitor->getItem($key); diff --git a/tests/Unit/PersistCacheTest.php b/tests/Unit/PersistCacheTest.php index 34bc45e..e5421b1 100644 --- a/tests/Unit/PersistCacheTest.php +++ b/tests/Unit/PersistCacheTest.php @@ -1,4 +1,5 @@ getKeys()); } - public function testBootLoadsFromStorageIntoCache(): void { + public function testBootLoadsFromStorageIntoCache(): void + { $sessionCache = new ArrayAdapter(); $sessionStorage = new ArrayAdapter(); @@ -43,7 +47,8 @@ final class PersistCacheTest extends TestCase { self::assertSame([], $cacheMonitor->getChanges()); } - public function testBootDoesNotReloadWhenCacheAlreadyWarm(): void { + public function testBootDoesNotReloadWhenCacheAlreadyWarm(): void + { $sessionCache = new ArrayAdapter(); $sessionStorage = new ArrayAdapter(); @@ -68,7 +73,8 @@ final class PersistCacheTest extends TestCase { self::assertNotContains('cookie_new', $monitor->getKeys()); } - public function testPersistWritesChangesToStorage(): void { + public function testPersistWritesChangesToStorage(): void + { $sessionCache = new ArrayAdapter(); $sessionStorage = new ArrayAdapter(); @@ -89,7 +95,8 @@ final class PersistCacheTest extends TestCase { self::assertSame('user2', $storageMonitor->getItem('cookie_xyz')->get()); } - public function testPersistHandlesRemovals(): void { + public function testPersistHandlesRemovals(): void + { $sessionCache = new ArrayAdapter(); $sessionStorage = new ArrayAdapter(); @@ -114,7 +121,8 @@ final class PersistCacheTest extends TestCase { self::assertNotContains('cookie_to_remove', $storageMonitor->getKeys()); } - public function testPersistIsNoopWhenNoChanges(): void { + public function testPersistIsNoopWhenNoChanges(): void + { $sessionCache = new ArrayAdapter(); $sessionStorage = new ArrayAdapter(); @@ -126,7 +134,8 @@ final class PersistCacheTest extends TestCase { self::assertSame([], $storageMonitor->getKeys()); } - public function testFullBootModifyPersistCycle(): void { + public function testFullBootModifyPersistCycle(): void + { $sessionCache = new ArrayAdapter(); $sessionStorage = new ArrayAdapter(); @@ -151,7 +160,8 @@ final class PersistCacheTest extends TestCase { self::assertSame('cycled-user', $monitor->getItem('cookie_cycle')->get()); } - public function testPersistHandlesMixedUpdatesAndRemovals(): void { + public function testPersistHandlesMixedUpdatesAndRemovals(): void + { $sessionCache = new ArrayAdapter(); $sessionStorage = new ArrayAdapter(); @@ -184,7 +194,8 @@ final class PersistCacheTest extends TestCase { self::assertNotContains('cookie_remove', $storageMonitor->getKeys()); } - public function testMultipleBootModifyPersistCycles(): void { + public function testMultipleBootModifyPersistCycles(): void + { $sessionCache = new ArrayAdapter(); $sessionStorage = new ArrayAdapter(); diff --git a/tests/Unit/Service/BackupCodeManagerTest.php b/tests/Unit/Service/BackupCodeManagerTest.php index 32fe766..e6905f7 100644 --- a/tests/Unit/Service/BackupCodeManagerTest.php +++ b/tests/Unit/Service/BackupCodeManagerTest.php @@ -1,4 +1,5 @@ setConfig($this->makeConfig()); @@ -20,7 +23,8 @@ final class BackupCodeManagerTest extends TestCase { return $manager; } - public function testGenerateReturnsRequestedCount(): void { + public function testGenerateReturnsRequestedCount(): void + { $manager = $this->makeManager(); $codes = $manager->generate(5); @@ -33,7 +37,8 @@ final class BackupCodeManagerTest extends TestCase { } } - public function testGenerateDefaultCount(): void { + public function testGenerateDefaultCount(): void + { $manager = $this->makeManager(); $codes = $manager->generate(); @@ -41,7 +46,8 @@ final class BackupCodeManagerTest extends TestCase { self::assertCount(10, $codes); } - public function testGenerateZeroReturnsEmptyArray(): void { + public function testGenerateZeroReturnsEmptyArray(): void + { $manager = $this->makeManager(); $codes = $manager->generate(0); @@ -49,7 +55,8 @@ final class BackupCodeManagerTest extends TestCase { self::assertSame([], $codes); } - public function testGeneratedCodesAreStoredInCache(): void { + public function testGeneratedCodesAreStoredInCache(): void + { $pool = new ArrayAdapter(); $manager = $this->makeManager($pool); @@ -65,7 +72,8 @@ final class BackupCodeManagerTest extends TestCase { } } - public function testGeneratedCodesHaveFarFutureExpiry(): void { + public function testGeneratedCodesHaveFarFutureExpiry(): void + { $pool = new ArrayAdapter(); $manager = $this->makeManager($pool); @@ -77,7 +85,8 @@ final class BackupCodeManagerTest extends TestCase { self::assertGreaterThan((new \DateTimeImmutable('+10 years'))->getTimestamp(), (int) $expiry); } - public function testVerifyAndConsumeValidCode(): void { + public function testVerifyAndConsumeValidCode(): void + { $manager = $this->makeManager(); $codes = $manager->generate(2); @@ -86,7 +95,8 @@ final class BackupCodeManagerTest extends TestCase { self::assertTrue($manager->verifyAndConsume($code)); } - public function testVerifyAndConsumeMarksCodeAsUsed(): void { + public function testVerifyAndConsumeMarksCodeAsUsed(): void + { $pool = new ArrayAdapter(); $manager = $this->makeManager($pool); $codes = $manager->generate(1); @@ -99,13 +109,15 @@ final class BackupCodeManagerTest extends TestCase { self::assertFalse($manager->verifyAndConsume($code)); } - public function testVerifyAndConsumeInvalidCode(): void { + public function testVerifyAndConsumeInvalidCode(): void + { $manager = $this->makeManager(); self::assertFalse($manager->verifyAndConsume('nonexistent_code')); } - public function testVerifyAndConsumeIsCaseInsensitive(): void { + public function testVerifyAndConsumeIsCaseInsensitive(): void + { $manager = $this->makeManager(); $codes = $manager->generate(1); $code = $codes[0]; @@ -114,7 +126,8 @@ final class BackupCodeManagerTest extends TestCase { self::assertTrue($manager->verifyAndConsume(strtoupper($code))); } - public function testVerifyAndConsumeStripsInvalidCharacters(): void { + public function testVerifyAndConsumeStripsInvalidCharacters(): void + { $manager = $this->makeManager(); $codes = $manager->generate(1); $code = $codes[0]; @@ -123,7 +136,8 @@ final class BackupCodeManagerTest extends TestCase { self::assertTrue($manager->verifyAndConsume(' ' . $code . '!!')); } - public function testExpireRemovesAllBackupCodes(): void { + public function testExpireRemovesAllBackupCodes(): void + { $pool = new ArrayAdapter(); $manager = $this->makeManager($pool); $codes = $manager->generate(5); @@ -136,7 +150,8 @@ final class BackupCodeManagerTest extends TestCase { } } - public function testExpireWhenNoBackupCodesIsNoop(): void { + public function testExpireWhenNoBackupCodesIsNoop(): void + { $pool = new ArrayAdapter(); $manager = $this->makeManager($pool); @@ -147,7 +162,8 @@ final class BackupCodeManagerTest extends TestCase { self::assertTrue(true); } - public function testExpireRemovesOnlyBackupPrefixedKeys(): void { + public function testExpireRemovesOnlyBackupPrefixedKeys(): void + { $pool = new ArrayAdapter(); $manager = $this->makeManager($pool); @@ -169,14 +185,16 @@ final class BackupCodeManagerTest extends TestCase { } } - public function testVerifyAndConsumeEmptyStringReturnsFalse(): void { + public function testVerifyAndConsumeEmptyStringReturnsFalse(): void + { $manager = $this->makeManager(); // empty string after preg_replace becomes 'backup_' with nothing after it self::assertFalse($manager->verifyAndConsume('')); } - public function testVerifyAndConsumeCodeWithValueFalseReturnsFalse(): void { + public function testVerifyAndConsumeCodeWithValueFalseReturnsFalse(): void + { $pool = new ArrayAdapter(); $manager = $this->makeManager($pool); $codes = $manager->generate(1); @@ -195,7 +213,8 @@ final class BackupCodeManagerTest extends TestCase { self::assertFalse($manager->verifyAndConsume($code)); } - public function testGenerateProducesUniqueCodes(): void { + public function testGenerateProducesUniqueCodes(): void + { $manager = $this->makeManager(); $codes = $manager->generate(50); @@ -204,7 +223,8 @@ final class BackupCodeManagerTest extends TestCase { self::assertCount(50, array_unique($codes), 'All generated codes should be unique'); } - public function testGenerateCodeLengthIsDigitsPlusTwo(): void { + public function testGenerateCodeLengthIsDigitsPlusTwo(): void + { $manager = $this->makeManager(); $codes = $manager->generate(1); diff --git a/tests/Unit/Service/DomainManagerTest.php b/tests/Unit/Service/DomainManagerTest.php index 4deca4a..67b4996 100644 --- a/tests/Unit/Service/DomainManagerTest.php +++ b/tests/Unit/Service/DomainManagerTest.php @@ -1,4 +1,5 @@ createManager(false, 'auth.example.com'); self::assertNull($manager->authBase()); self::assertNull($manager->getAuthSubdomain()); } - public function testAuthBaseIsNullWhenAuthSubdomainIsEmpty(): void { + public function testAuthBaseIsNullWhenAuthSubdomainIsEmpty(): void + { $manager = $this->createManager(true, ''); self::assertNull($manager->authBase()); self::assertNull($manager->getAuthSubdomain()); } - public function testAuthBaseExtractsSimpleDomain(): void { + public function testAuthBaseExtractsSimpleDomain(): void + { $manager = $this->createManager(true, 'auth.example.com'); self::assertSame('example.com', $manager->authBase()); self::assertSame('auth.example.com', $manager->getAuthSubdomain()); } - public function testAuthBaseExtractsMultiPartTld(): void { + public function testAuthBaseExtractsMultiPartTld(): void + { $manager = $this->createManager(true, 'auth.example.co.uk'); self::assertSame('example.co.uk', $manager->authBase()); self::assertSame('auth.example.co.uk', $manager->getAuthSubdomain()); } - public function testAuthBaseIsNullForLocalhostAuth(): void { + public function testAuthBaseIsNullForLocalhostAuth(): void + { $manager = $this->createManager(true, 'localhost'); self::assertNull($manager->authBase()); self::assertNull($manager->getAuthSubdomain()); } - public function testAuthBaseIsNullForIpAuth(): void { + public function testAuthBaseIsNullForIpAuth(): void + { $manager = $this->createManager(true, '192.168.1.1'); self::assertNull($manager->authBase()); self::assertNull($manager->getAuthSubdomain()); @@ -51,90 +60,105 @@ final class DomainManagerTest extends TestCase { /* ── validReturn ──────────────────────────────────────────────────────── */ - public function testValidReturnAcceptsAnyUrlWhenNoSubdomain(): void { + public function testValidReturnAcceptsAnyUrlWhenNoSubdomain(): void + { $manager = $this->createManager(false, ''); self::assertTrue($manager->validReturn('https://evil.com/page')); self::assertTrue($manager->validReturn('https://example.com/ok')); } - public function testValidReturnRejectsInvalidUrl(): void { + public function testValidReturnRejectsInvalidUrl(): void + { $manager = $this->createManager(true, 'auth.example.com'); self::assertFalse($manager->validReturn('not-a-url')); self::assertFalse($manager->validReturn('')); } - public function testValidReturnAcceptsSameBaseDomain(): void { + public function testValidReturnAcceptsSameBaseDomain(): void + { $manager = $this->createManager(true, 'auth.example.com'); self::assertTrue($manager->validReturn('https://app.example.com/dashboard')); self::assertTrue($manager->validReturn('https://example.com/')); } - public function testValidReturnRejectsDifferentBaseDomain(): void { + public function testValidReturnRejectsDifferentBaseDomain(): void + { $manager = $this->createManager(true, 'auth.example.com'); self::assertFalse($manager->validReturn('https://evil.com/phish')); self::assertFalse($manager->validReturn('https://other-example.com/')); } - public function testValidReturnHandlesCoUkTld(): void { + public function testValidReturnHandlesCoUkTld(): void + { $manager = $this->createManager(true, 'auth.example.co.uk'); self::assertTrue($manager->validReturn('https://www.example.co.uk/')); self::assertFalse($manager->validReturn('https://example.com/')); } - public function testValidReturnRejectsUrlWithoutHost(): void { + public function testValidReturnRejectsUrlWithoutHost(): void + { $manager = $this->createManager(true, 'auth.example.com'); self::assertFalse($manager->validReturn('mailto:test@example.com')); } /* ── matchesAuth ──────────────────────────────────────────────────────── */ - public function testMatchesAuthIsFalseWhenSubdomainRedirectDisabled(): void { + public function testMatchesAuthIsFalseWhenSubdomainRedirectDisabled(): void + { $manager = $this->createManager(false, 'auth.example.com'); self::assertFalse($manager->matchesAuth('example.com')); self::assertFalse($manager->matchesAuth('app.example.com')); } - public function testMatchesAuthIsFalseWhenAuthSubdomainIsEmpty(): void { + public function testMatchesAuthIsFalseWhenAuthSubdomainIsEmpty(): void + { $manager = $this->createManager(true, ''); self::assertFalse($manager->matchesAuth('example.com')); } - public function testMatchesAuthMatchesSameBaseDomain(): void { + public function testMatchesAuthMatchesSameBaseDomain(): void + { $manager = $this->createManager(true, 'auth.example.com'); self::assertTrue($manager->matchesAuth('example.com')); self::assertTrue($manager->matchesAuth('app.example.com')); } - public function testMatchesAuthRejectsDifferentBaseDomain(): void { + public function testMatchesAuthRejectsDifferentBaseDomain(): void + { $manager = $this->createManager(true, 'auth.example.com'); self::assertFalse($manager->matchesAuth('evil.com')); self::assertFalse($manager->matchesAuth('example.org')); } - public function testMatchesAuthHandlesMultiPartTld(): void { + public function testMatchesAuthHandlesMultiPartTld(): void + { $manager = $this->createManager(true, 'auth.example.co.uk'); self::assertTrue($manager->matchesAuth('www.example.co.uk')); self::assertFalse($manager->matchesAuth('example.com')); } - public function testMatchesAuthRejectsIpHost(): void { + public function testMatchesAuthRejectsIpHost(): void + { $manager = $this->createManager(true, 'auth.example.com'); self::assertFalse($manager->matchesAuth('192.168.1.1')); } - public function testMatchesAuthRejectsLocalhost(): void { + public function testMatchesAuthRejectsLocalhost(): void + { $manager = $this->createManager(true, 'auth.example.com'); self::assertFalse($manager->matchesAuth('localhost')); } /* ── baseDomain edge cases via matchesAuth ────────────────────────────── */ - public function testMatchesAuthWithDeepSubdomain(): void { + public function testMatchesAuthWithDeepSubdomain(): void + { $manager = $this->createManager(true, 'auth.example.com'); self::assertTrue($manager->matchesAuth('a.b.c.example.com')); } - public function testMatchesAuthWithTwoPartDomain(): void { + public function testMatchesAuthWithTwoPartDomain(): void + { /* for a 2-part auth subdomain, the baseDomain retains both parts */ $manager = $this->createManager(true, 'auth.local'); self::assertSame('auth.local', $manager->authBase()); @@ -145,7 +169,8 @@ final class DomainManagerTest extends TestCase { /* ── TLD table coverage ──────────────────────────────────────────────── */ - public function testMatchesAuthWithComAuTld(): void { + public function testMatchesAuthWithComAuTld(): void + { // com.au is NOT in the TLD table (table has au? no, it doesn't), // so it's treated as a standard 2-part TLD: base = com.au $manager = $this->createManager(true, 'auth.example.com.au'); @@ -154,7 +179,8 @@ final class DomainManagerTest extends TestCase { self::assertFalse($manager->matchesAuth('example.com')); } - public function testMatchesAuthWithCoJpTld(): void { + public function testMatchesAuthWithCoJpTld(): void + { // co.jp is NOT in the TLD table (table has jpn under com, not jp under co) // so base = co.jp $manager = $this->createManager(true, 'auth.example.co.jp'); @@ -162,7 +188,8 @@ final class DomainManagerTest extends TestCase { self::assertTrue($manager->matchesAuth('www.example.co.jp')); } - public function testMatchesAuthWithComBrTld(): void { + public function testMatchesAuthWithComBrTld(): void + { // com.br: TLD table has com => [br], meaning *.br.com is multi-part // but com.br has last=br, TLD['br'] doesn't exist, so base = com.br $manager = $this->createManager(true, 'auth.example.com.br'); @@ -170,35 +197,40 @@ final class DomainManagerTest extends TestCase { self::assertTrue($manager->matchesAuth('app.example.com.br')); } - public function testMatchesAuthWithCoNzTld(): void { + public function testMatchesAuthWithCoNzTld(): void + { // co.nz is NOT in the TLD table (nz => [co,net,org], so *.co.nz IS multi-part) $manager = $this->createManager(true, 'auth.example.co.nz'); self::assertSame('example.co.nz', $manager->authBase()); self::assertTrue($manager->matchesAuth('sub.example.co.nz')); } - public function testMatchesAuthWithComMxTld(): void { + public function testMatchesAuthWithComMxTld(): void + { // com.mx is NOT in the TLD table (mx => [com,net,org], so *.com.mx IS multi-part) $manager = $this->createManager(true, 'auth.example.com.mx'); self::assertSame('example.com.mx', $manager->authBase()); self::assertTrue($manager->matchesAuth('app.example.com.mx')); } - public function testMatchesAuthWithCoInTld(): void { + public function testMatchesAuthWithCoInTld(): void + { // co.in: in => [co,...], so *.co.in IS multi-part $manager = $this->createManager(true, 'auth.example.co.in'); self::assertSame('example.co.in', $manager->authBase()); self::assertTrue($manager->matchesAuth('app.example.co.in')); } - public function testMatchesAuthWithBrComTld(): void { + public function testMatchesAuthWithBrComTld(): void + { // br.com: TLD table has com => [br], so *.br.com IS multi-part $manager = $this->createManager(true, 'auth.example.br.com'); self::assertSame('example.br.com', $manager->authBase()); self::assertTrue($manager->matchesAuth('app.example.br.com')); } - public function testSimpleTldNotTreatedAsMultiPart(): void { + public function testSimpleTldNotTreatedAsMultiPart(): void + { // example.com is a standard 2-part domain, not multi-part $manager = $this->createManager(true, 'auth.example.com'); self::assertSame('example.com', $manager->authBase()); @@ -208,7 +240,8 @@ final class DomainManagerTest extends TestCase { /* ── baseDomain edge cases ───────────────────────────────────────────── */ - public function testMatchesAuthWithSingleLabelHost(): void { + public function testMatchesAuthWithSingleLabelHost(): void + { // a single-label domain (not localhost, not IP) has baseLength 1 // so 'myhost' has baseDomain 'myhost', while 'auth.local' has base 'auth.local' // they won't match unless the auth subdomain itself is single-label @@ -219,22 +252,26 @@ final class DomainManagerTest extends TestCase { self::assertTrue($manager->matchesAuth('app.auth.local')); } - public function testMatchesAuthWithEmptyStringHost(): void { + public function testMatchesAuthWithEmptyStringHost(): void + { $manager = $this->createManager(true, 'auth.example.com'); self::assertFalse($manager->matchesAuth('')); } - public function testValidReturnAcceptsUrlWithPort(): void { + public function testValidReturnAcceptsUrlWithPort(): void + { $manager = $this->createManager(true, 'auth.example.com'); self::assertTrue($manager->validReturn('https://example.com:8080/path')); } - public function testValidReturnAcceptsUrlWithoutPath(): void { + public function testValidReturnAcceptsUrlWithoutPath(): void + { $manager = $this->createManager(true, 'auth.example.com'); self::assertTrue($manager->validReturn('https://example.com')); } - public function testValidReturnRejectsDifferentDomainWithPort(): void { + public function testValidReturnRejectsDifferentDomainWithPort(): void + { $manager = $this->createManager(true, 'auth.example.com'); self::assertFalse($manager->validReturn('https://evil.com:8080/path')); } diff --git a/tests/Unit/Service/LoginManagerTest.php b/tests/Unit/Service/LoginManagerTest.php index 291cd39..4180947 100644 --- a/tests/Unit/Service/LoginManagerTest.php +++ b/tests/Unit/Service/LoginManagerTest.php @@ -1,4 +1,5 @@ getValue($manager); @@ -74,7 +77,8 @@ final class LoginManagerTest extends TestCase { return $nonce; } - public function testCheckTokenReturnsNullForInvalidTotp(): void { + public function testCheckTokenReturnsNullForInvalidTotp(): void + { $manager = $this->makeLoginManager(); $payload = $this->makePayloadWithNonce($manager, token: 'wrong-code'); @@ -85,7 +89,8 @@ final class LoginManagerTest extends TestCase { self::assertNull($manager->checkToken($payload, $request)); } - public function testCheckTokenReturnsNullForSpentNonce(): void { + public function testCheckTokenReturnsNullForSpentNonce(): void + { $manager = $this->makeLoginManager(); $payload = $this->makePayloadWithNonce($manager); @@ -103,7 +108,8 @@ final class LoginManagerTest extends TestCase { self::assertNull($manager->checkToken($payload, $request)); } - public function testCheckTokenReturnsNullForMissingNonce(): void { + public function testCheckTokenReturnsNullForMissingNonce(): void + { $manager = $this->makeLoginManager(); $this->backupCodeManager->method('verifyAndConsume')->willReturn(false); @@ -120,7 +126,8 @@ final class LoginManagerTest extends TestCase { self::assertNull($manager->checkToken($payload, $request)); } - public function testSuccessfulTotpLoginWithCookieScopeReturnsRedirect(): void { + public function testSuccessfulTotpLoginWithCookieScopeReturnsRedirect(): void + { $manager = $this->makeLoginManager(); $payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie); @@ -136,7 +143,8 @@ final class LoginManagerTest extends TestCase { self::assertTrue($response->headers->has('Set-Cookie')); } - public function testSuccessfulLoginWithNoneScopeReturnsPlainResponse(): void { + public function testSuccessfulLoginWithNoneScopeReturnsPlainResponse(): void + { $manager = $this->makeLoginManager(); $payload = $this->makePayloadWithNonce($manager, scope: Scope::None); @@ -154,7 +162,8 @@ final class LoginManagerTest extends TestCase { self::assertFalse($response->headers->has('Location')); } - public function testSuccessfulLoginSetsRemoteUserHeader(): void { + public function testSuccessfulLoginSetsRemoteUserHeader(): void + { $manager = $this->makeLoginManager(); $payload = $this->makePayloadWithNonce($manager, id: 'alice', scope: Scope::None); @@ -168,7 +177,8 @@ final class LoginManagerTest extends TestCase { self::assertSame('alice', $response->headers->get('Remote-User')); } - public function testSuccessfulLoginJsonResponse(): void { + public function testSuccessfulLoginJsonResponse(): void + { $manager = $this->makeLoginManager(); $payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie, token: null); $payload->json = true; @@ -185,7 +195,8 @@ final class LoginManagerTest extends TestCase { self::assertSame('Login successful', $body['message']); } - public function testSuccessfulLoginHtmlResponse(): void { + public function testSuccessfulLoginHtmlResponse(): void + { $manager = $this->makeLoginManager(); $payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie); $payload->json = false; @@ -200,7 +211,8 @@ final class LoginManagerTest extends TestCase { self::assertSame('text/html', $response->headers->get('Content-Type')); } - public function testSuccessfulLoginWithReturnUrl(): void { + public function testSuccessfulLoginWithReturnUrl(): void + { $manager = $this->makeLoginManager(); $payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie); @@ -214,7 +226,8 @@ final class LoginManagerTest extends TestCase { self::assertSame('https://example.com/app', $response->headers->get('Location')); } - public function testSuccessfulLoginWithInvalidReturnFallsBackToPath(): void { + public function testSuccessfulLoginWithInvalidReturnFallsBackToPath(): void + { $manager = $this->makeLoginManager(); $payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie); @@ -229,7 +242,8 @@ final class LoginManagerTest extends TestCase { self::assertStringStartsWith('/login', $location); } - public function testIpScopeDowngradesToCookieWhenIpAccessDisabled(): void { + public function testIpScopeDowngradesToCookieWhenIpAccessDisabled(): void + { $manager = $this->makeLoginManager(ipTtl: 0); $payload = $this->makePayloadWithNonce($manager, scope: Scope::Ip); @@ -244,7 +258,8 @@ final class LoginManagerTest extends TestCase { self::assertTrue($response->headers->has('Set-Cookie')); } - public function testIpScopeWhenEnabledSetsIpSession(): void { + public function testIpScopeWhenEnabledSetsIpSession(): void + { $manager = $this->makeLoginManager(ipTtl: 1800); $payload = $this->makePayloadWithNonce($manager, scope: Scope::Ip); @@ -264,7 +279,8 @@ final class LoginManagerTest extends TestCase { self::assertTrue($sessionCache->hasItem('ip_1.2.3.4')); } - public function testBackupCodeAuthentication(): void { + public function testBackupCodeAuthentication(): void + { $manager = $this->makeLoginManager(); $payload = $this->makePayloadWithNonce($manager, token: 'backup-code-123'); @@ -278,7 +294,8 @@ final class LoginManagerTest extends TestCase { self::assertSame(303, $response->getStatusCode()); } - public function testNonceIsConsumedAfterSuccessfulLogin(): void { + public function testNonceIsConsumedAfterSuccessfulLogin(): void + { $manager = $this->makeLoginManager(); $payload = $this->makePayloadWithNonce($manager); @@ -296,7 +313,8 @@ final class LoginManagerTest extends TestCase { self::assertFalse($nonceItem->get()); } - public function testUlidCollisionThrowsHttpException(): void { + public function testUlidCollisionThrowsHttpException(): void + { // Use a stub pool where every cookie_ key is already a hit (collision) $pool = $this->createStub(CacheItemPoolInterface::class); $item = $this->createStub(CacheItemInterface::class); @@ -358,7 +376,8 @@ final class LoginManagerTest extends TestCase { $manager->checkToken($payload, $request); } - public function testCookieScopeWithCentralAuthSetsDomainOnMatchingHost(): void { + public function testCookieScopeWithCentralAuthSetsDomainOnMatchingHost(): void + { $manager = $this->makeLoginManager( subdomainRedirect: true, authSubdomain: 'auth.example.com', @@ -381,7 +400,8 @@ final class LoginManagerTest extends TestCase { self::assertSame('__Http-Domain-Preauth', $cookies[0]->getName()); } - public function testCookieScopeWithCentralAuthOnNonMatchingHostUsesNullDomain(): void { + public function testCookieScopeWithCentralAuthOnNonMatchingHostUsesNullDomain(): void + { $manager = $this->makeLoginManager( subdomainRedirect: true, authSubdomain: 'auth.example.com', @@ -404,7 +424,8 @@ final class LoginManagerTest extends TestCase { self::assertSame('__Http-Domain-Preauth', $cookies[0]->getName()); } - public function testCheckTokenWithEmptyReturnParameterFallsBackToPath(): void { + public function testCheckTokenWithEmptyReturnParameterFallsBackToPath(): void + { $manager = $this->makeLoginManager(); $payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie); diff --git a/tests/Unit/Trait/CookieNameTraitTest.php b/tests/Unit/Trait/CookieNameTraitTest.php index cf809e9..648d53a 100644 --- a/tests/Unit/Trait/CookieNameTraitTest.php +++ b/tests/Unit/Trait/CookieNameTraitTest.php @@ -1,4 +1,5 @@ cookieName()); } - public function testAuthCookieName(): void { + public function testAuthCookieName(): void + { self::assertSame('__Http-Domain-Preauth', $this->authCookieName()); } - public function testHeaderName(): void { + public function testHeaderName(): void + { self::assertSame('X-Preauth', $this->headerName()); } } diff --git a/tests/Unit/Trait/GetTotpTraitTest.php b/tests/Unit/Trait/GetTotpTraitTest.php index 03d0937..5428a5a 100644 --- a/tests/Unit/Trait/GetTotpTraitTest.php +++ b/tests/Unit/Trait/GetTotpTraitTest.php @@ -1,4 +1,5 @@ getTotp(); } }; } - public function testSetConfigSetsProperty(): void { + public function testSetConfigSetsProperty(): void + { $obj = $this->makeObject(); $config = $this->makeConfig(); @@ -33,7 +38,8 @@ final class GetTotpTraitTest extends TestCase { self::assertSame($config, $reflection->getValue($obj)); } - public function testGetTotpReturnsTotpInterface(): void { + public function testGetTotpReturnsTotpInterface(): void + { $obj = $this->makeObject(); $obj->setConfig($this->makeConfig()); @@ -42,7 +48,8 @@ final class GetTotpTraitTest extends TestCase { self::assertInstanceOf(TOTPInterface::class, $totp); } - public function testGetTotpReturnsValidCode(): void { + public function testGetTotpReturnsValidCode(): void + { $obj = $this->makeObject(); $obj->setConfig($this->makeConfig()); @@ -52,14 +59,21 @@ final class GetTotpTraitTest extends TestCase { self::assertSame($this->validTotpCode(), $totp->now()); } - public function testGetTotpThrowsOnInvalidUri(): void { + public function testGetTotpThrowsOnInvalidUri(): void + { $obj = $this->makeObject(); $clock = $this->frozenClock(); $utilities = $this->createUtilities($clock); $config = new ConfigBag( - $utilities, $clock, - 3600, 'not-a-valid-uri', 0, false, - 'Error', 'Teapot', 'Too Many' + $utilities, + $clock, + 3600, + 'not-a-valid-uri', + 0, + false, + 'Error', + 'Teapot', + 'Too Many' ); $obj->setConfig($config); @@ -70,16 +84,23 @@ final class GetTotpTraitTest extends TestCase { $obj->publicGetTotp(); } - public function testGetTotpThrowsHttpExceptionWhenNotTotpType(): void { + public function testGetTotpThrowsHttpExceptionWhenNotTotpType(): void + { // A HOTP URI loads successfully as an OTPInterface but is NOT a TOTPInterface, // so the instanceof check in getTotp() should throw an HttpException(500) $obj = $this->makeObject(); $clock = $this->frozenClock(); $utilities = $this->createUtilities($clock); $config = new ConfigBag( - $utilities, $clock, - 3600, 'otpauth://hotp/Test-HOTP?secret=JBSWY3DPEHPK3PXP&counter=0', 0, false, - 'Error', 'Teapot', 'Too Many' + $utilities, + $clock, + 3600, + 'otpauth://hotp/Test-HOTP?secret=JBSWY3DPEHPK3PXP&counter=0', + 0, + false, + 'Error', + 'Teapot', + 'Too Many' ); $obj->setConfig($config); diff --git a/tests/Unit/Trait/HasLoggerTraitTest.php b/tests/Unit/Trait/HasLoggerTraitTest.php index ca480d5..560890a 100644 --- a/tests/Unit/Trait/HasLoggerTraitTest.php +++ b/tests/Unit/Trait/HasLoggerTraitTest.php @@ -1,4 +1,5 @@ createStub(LoggerInterface::class); $this->setLogger($logger); self::assertSame($logger, $this->logger); diff --git a/tests/Unit/Trait/MakeNonceTraitTest.php b/tests/Unit/Trait/MakeNonceTraitTest.php index f0fcd11..b998d2b 100644 --- a/tests/Unit/Trait/MakeNonceTraitTest.php +++ b/tests/Unit/Trait/MakeNonceTraitTest.php @@ -1,4 +1,5 @@ makeNonce($retries); } - public function publicMakeCacheKey(string $name): string { + public function publicMakeCacheKey(string $name): string + { return $this->makeCacheKey($name); } }; } - public function testMakeNonceReturnsBase64UrlString(): void { + public function testMakeNonceReturnsBase64UrlString(): void + { $obj = $this->makeObject(); $obj->setLogger(new NullLogger()); $obj->setNonceCache(new ArrayAdapter()); @@ -44,7 +50,8 @@ final class MakeNonceTraitTest extends TestCase { self::assertMatchesRegularExpression('/^[A-Za-z0-9_-]+$/', $nonce); } - public function testMakeNonceStoresNonceInCache(): void { + public function testMakeNonceStoresNonceInCache(): void + { $pool = new ArrayAdapter(); $obj = $this->makeObject(); $obj->setLogger(new NullLogger()); @@ -59,7 +66,8 @@ final class MakeNonceTraitTest extends TestCase { self::assertTrue($item->get()); } - public function testMakeNonceSetsExpiry(): void { + public function testMakeNonceSetsExpiry(): void + { $pool = new ArrayAdapter(); $obj = $this->makeObject(); $obj->setLogger(new NullLogger()); @@ -74,7 +82,8 @@ final class MakeNonceTraitTest extends TestCase { self::assertGreaterThan(time(), (int) $expiry); } - public function testTwoNoncesAreDifferent(): void { + public function testTwoNoncesAreDifferent(): void + { $pool = new ArrayAdapter(); $obj = $this->makeObject(); $obj->setLogger(new NullLogger()); @@ -86,7 +95,8 @@ final class MakeNonceTraitTest extends TestCase { self::assertNotSame($nonce1, $nonce2); } - public function testMakeNonceThrowsAfterMaxRetries(): void { + public function testMakeNonceThrowsAfterMaxRetries(): void + { // Create a stub pool that always reports every key as a hit (collision) $pool = $this->createStub(CacheItemPoolInterface::class); $item = $this->createStub(CacheItemInterface::class); @@ -105,46 +115,93 @@ final class MakeNonceTraitTest extends TestCase { $obj->publicMakeNonce(); } - public function testMakeNonceRetriesAndSucceedsAfterCollision(): void { + public function testMakeNonceRetriesAndSucceedsAfterCollision(): void + { // Use a spy pool that returns isHit=true on the first getItem call // (simulating a collision), then delegates to a real ArrayAdapter for // subsequent calls so the retry succeeds. $realPool = new ArrayAdapter(); $collisionCount = 0; - $spyPool = new class($realPool, $collisionCount) implements CacheItemPoolInterface { + $spyPool = new class ($realPool, $collisionCount) implements CacheItemPoolInterface { private int $hits = 0; public function __construct( private CacheItemPoolInterface $inner, private int &$hitCounter, - ) {} + ) { + } - public function getItem(string $key): CacheItemInterface { + public function getItem(string $key): CacheItemInterface + { $item = $this->inner->getItem($key); // pretend the first requested key is already a hit (collision) if ($this->hits === 0) { $this->hits++; $this->hitCounter++; - return new class($key) implements CacheItemInterface { - public function __construct(private string $key) {} - public function getKey(): string { return $this->key; } - public function get(): mixed { return true; } - public function isHit(): bool { return true; } - public function set(mixed $value): static { return $this; } - public function expiresAt(?\DateTimeInterface $expiration): static { return $this; } - public function expiresAfter(int|\DateInterval|null $time): static { return $this; } + return new class ($key) implements CacheItemInterface { + public function __construct(private string $key) + { + } + public function getKey(): string + { + return $this->key; + } + public function get(): mixed + { + return true; + } + public function isHit(): bool + { + return true; + } + public function set(mixed $value): static + { + return $this; + } + public function expiresAt(?\DateTimeInterface $expiration): static + { + return $this; + } + public function expiresAfter(int|\DateInterval|null $time): static + { + return $this; + } }; } return $item; } - public function getItems(array $keys = []): iterable { return $this->inner->getItems($keys); } - public function hasItem(string $key): bool { return $this->inner->hasItem($key); } - public function clear(): bool { return $this->inner->clear(); } - public function deleteItem(string $key): bool { return $this->inner->deleteItem($key); } - public function deleteItems(array $keys): bool { return $this->inner->deleteItems($keys); } - public function save(CacheItemInterface $item): bool { return $this->inner->save($item); } - public function saveDeferred(CacheItemInterface $item): bool { return $this->inner->saveDeferred($item); } - public function commit(): bool { return $this->inner->commit(); } + public function getItems(array $keys = []): iterable + { + return $this->inner->getItems($keys); + } + public function hasItem(string $key): bool + { + return $this->inner->hasItem($key); + } + public function clear(): bool + { + return $this->inner->clear(); + } + public function deleteItem(string $key): bool + { + return $this->inner->deleteItem($key); + } + public function deleteItems(array $keys): bool + { + return $this->inner->deleteItems($keys); + } + public function save(CacheItemInterface $item): bool + { + return $this->inner->save($item); + } + public function saveDeferred(CacheItemInterface $item): bool + { + return $this->inner->saveDeferred($item); + } + public function commit(): bool + { + return $this->inner->commit(); + } }; $obj = $this->makeObject(); @@ -158,7 +215,8 @@ final class MakeNonceTraitTest extends TestCase { self::assertSame(1, $collisionCount, 'Expected exactly one collision before success'); } - public function testMakeNonceThrowsImmediatelyWithZeroRetries(): void { + public function testMakeNonceThrowsImmediatelyWithZeroRetries(): void + { $pool = $this->createStub(CacheItemPoolInterface::class); $item = $this->createStub(CacheItemInterface::class); $item->method('isHit')->willReturn(true); diff --git a/tests/Unit/Trait/StringTraitTest.php b/tests/Unit/Trait/StringTraitTest.php index 03c5bb7..876f611 100644 --- a/tests/Unit/Trait/StringTraitTest.php +++ b/tests/Unit/Trait/StringTraitTest.php @@ -1,4 +1,5 @@ makeCacheKey('hello world')); self::assertSame('hello_world', $this->makeCacheKey('hello!world')); self::assertSame('a_b_c_d', $this->makeCacheKey('a/b@c#d')); } - public function testMakeCacheKeyPreservesValidChars(): void { + public function testMakeCacheKeyPreservesValidChars(): void + { self::assertSame('ABC_123.abc', $this->makeCacheKey('ABC_123.abc')); } - public function testMakeCacheKeyTruncatesLongNames(): void { + public function testMakeCacheKeyTruncatesLongNames(): void + { $long = str_repeat('a', 300); $result = $this->makeCacheKey($long); self::assertSame(128, mb_strlen($result)); } - public function testMakeCacheKeyEmptyString(): void { + public function testMakeCacheKeyEmptyString(): void + { self::assertSame('', $this->makeCacheKey('')); } - public function testMakeCacheKeyWithOnlyInvalidChars(): void { + public function testMakeCacheKeyWithOnlyInvalidChars(): void + { // preg_replace with + collapses consecutive invalid chars into one _ self::assertSame('_', $this->makeCacheKey('!!!')); self::assertSame('_', $this->makeCacheKey(' ')); @@ -37,7 +44,8 @@ final class StringTraitTest extends TestCase { self::assertSame('_', $this->makeCacheKey('!@ #')); } - public function testMakeCacheKeyTruncatesToExactly128(): void { + public function testMakeCacheKeyTruncatesToExactly128(): void + { $input = str_repeat('a', 128); self::assertSame(128, mb_strlen($this->makeCacheKey($input))); self::assertSame($input, $this->makeCacheKey($input)); @@ -46,14 +54,16 @@ final class StringTraitTest extends TestCase { self::assertSame(128, mb_strlen($this->makeCacheKey($input129))); } - public function testMakeCacheKeyWithMultibyteChars(): void { + public function testMakeCacheKeyWithMultibyteChars(): void + { // multibyte chars are replaced with a single underscore $result = $this->makeCacheKey('héllo wörld'); // é and ö are not in [A-Za-z0-9_.] so they become _ self::assertSame('h_llo_w_rld', $result); } - public function testMakeCacheKeyWithEmoji(): void { + public function testMakeCacheKeyWithEmoji(): void + { $result = $this->makeCacheKey('a🎉b'); self::assertSame('a_b', $result); } diff --git a/tests/Unit/UtilitiesTest.php b/tests/Unit/UtilitiesTest.php index c50b76c..89e13b3 100644 --- a/tests/Unit/UtilitiesTest.php +++ b/tests/Unit/UtilitiesTest.php @@ -1,4 +1,5 @@ createStub(ClockInterface::class); return new Utilities($clock, $pool); } - public function testLoadTotpReturnsCachedValueWhenPresent(): void { + public function testLoadTotpReturnsCachedValueWhenPresent(): void + { $pool = new ArrayAdapter(); $item = $pool->getItem('totp'); $item->set('otpauth://totp/cached?secret=ABCDEFGH'); @@ -28,7 +32,8 @@ final class UtilitiesTest extends TestCase { self::assertSame('otpauth://totp/cached?secret=ABCDEFGH', $result); } - public function testLoadTotpGeneratesAndStoresWhenMissing(): void { + public function testLoadTotpGeneratesAndStoresWhenMissing(): void + { $pool = new ArrayAdapter(); $utilities = $this->makeUtilities($pool); @@ -43,7 +48,8 @@ final class UtilitiesTest extends TestCase { self::assertSame($result, $cached->get()); } - public function testLoadTotpSetsFarFutureExpiry(): void { + public function testLoadTotpSetsFarFutureExpiry(): void + { $pool = new ArrayAdapter(); $utilities = $this->makeUtilities($pool); @@ -55,7 +61,8 @@ final class UtilitiesTest extends TestCase { self::assertGreaterThan((new \DateTimeImmutable('+10 years'))->getTimestamp(), (int) $expiry); } - public function testLoadTotpIsIdempotentAfterGeneration(): void { + public function testLoadTotpIsIdempotentAfterGeneration(): void + { $pool = new ArrayAdapter(); $utilities = $this->makeUtilities($pool);