diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..1055b7a --- /dev/null +++ b/.env.test @@ -0,0 +1,27 @@ +APP_ENV=test +APP_DEBUG=0 +APP_SECRET=test_secret_key_change_me +# fixed TOTP secret (JBSWY3DPEHPK3PXP) so functional tests can compute valid codes +TOTP_URI='otpauth://totp/Test-TOTP?secret=JBSWY3DPEHPK3PXP' +COOKIE_TTL=2592000 +SUBDOMAIN_REDIRECT=0 +AUTH_SUBDOMAIN='' +IP_TTL=0 +TEAPOT=1 +BURST_COUNT=10 +BURST_TIME=30 +UPPER_COUNT=100 +UPPER_TIME=3600 +TITLE='Pre-Authentication System' +BG_COLOR='#029386' +FG_COLOR='#ffffff' +ERROR_COLOR='#ffb16d' +ID_NAME='Session ID' +TOKEN_NAME='Authentication Token' +SUBMIT_NAME='Submit' +ERROR_MESSAGE='Unsuccessful login attempt' +TEAPOT_TITLE="I'm a teapot" +TEAPOT_MESSAGE='I refuse to brew coffee' +TOO_MANY_TITLE='Too many requests' +TOO_MANY_MESSAGE='Try again later' +SHELL_VERBOSITY=0 diff --git a/.gitea/workflows/tests.yaml b/.gitea/workflows/tests.yaml new file mode 100644 index 0000000..80992d3 --- /dev/null +++ b/.gitea/workflows/tests.yaml @@ -0,0 +1,33 @@ +name: Tests + +on: + push: + branches: + - 'main' + - 'develop' + pull_request: + branches: + - 'main' + - 'develop' + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.5' + extensions: apcu, mbstring + coverage: xdebug + ini-values: apc.enable_cli=1 + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run tests + run: XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text diff --git a/.gitignore b/.gitignore index f2bad8f..8813ff5 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,9 @@ /vendor/ ###< symfony/framework-bundle ### + +###> phpunit/phpunit ### +/phpunit.xml +/.phpunit.cache/ +/bin/.phpunit.result.cache +###< phpunit/phpunit ### diff --git a/bin/phpunit b/bin/phpunit new file mode 100755 index 0000000..ac5eef1 --- /dev/null +++ b/bin/phpunit @@ -0,0 +1,4 @@ +#!/usr/bin/env php +=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.10.1" + }, + "time": "2026-06-23T18:43:15+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + }, + "time": "2026-07-04T14:30:18+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "14.2.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "048a5c12bdb4580f4767ce2761793a16b170fbe4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/048a5c12bdb4580f4767ce2761793a16b170fbe4", + "reference": "048a5c12bdb4580f4767ce2761793a16b170fbe4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.8.0", + "php": ">=8.4", + "phpunit/php-text-template": "^6.0", + "sebastian/complexity": "^6.0", + "sebastian/environment": "^9.3.2", + "sebastian/git-state": "^1.0", + "sebastian/lines-of-code": "^5.0.1", + "sebastian/version": "^7.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^13.2.2" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "14.2.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.2.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2026-07-30T17:01:07+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:33:26+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^13.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-invoker", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:34:47+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/a47af19f93f76aa3368303d752aa5272ca3299f4", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-text-template", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:36:37+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "9.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/a0e12065831f6ab0d83120dc61513eb8d9a966f6", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/9.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-timer", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:37:53+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "13.2.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "5d2afe181339a56348ef9a80fa7eb806b7eae508" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5d2afe181339a56348ef9a80fa7eb806b7eae508", + "reference": "5d2afe181339a56348ef9a80fa7eb806b7eae508", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.4.1", + "phpunit/php-code-coverage": "^14.2.3", + "phpunit/php-file-iterator": "^7.0.0", + "phpunit/php-invoker": "^7.0.0", + "phpunit/php-text-template": "^6.0.0", + "phpunit/php-timer": "^9.0.0", + "sebastian/cli-parser": "^5.0.0", + "sebastian/comparator": "^8.3.0", + "sebastian/diff": "^9.0", + "sebastian/environment": "^9.3.2", + "sebastian/exporter": "^8.1.1", + "sebastian/file-filter": "^1.0", + "sebastian/git-state": "^1.0", + "sebastian/global-state": "^9.0.1", + "sebastian/object-enumerator": "^8.0.0", + "sebastian/recursion-context": "^8.0.0", + "sebastian/type": "^7.0.1", + "sebastian/version": "^7.0.0", + "staabm/side-effects-detector": "^1.0.5" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "13.2-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.2.6" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-07-28T14:00:09+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/48a4654fa5e48c1c81214e9930048a572d4b23ca", + "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:39:44+00:00" + }, + { + "name": "sebastian/comparator", + "version": "8.3.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "c025fc7604afab3f195fab7cdaf72327331af241" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c025fc7604afab3f195fab7cdaf72327331af241", + "reference": "c025fc7604afab3f195fab7cdaf72327331af241", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/diff": "^9.0", + "sebastian/exporter": "^8.1.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.2" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/8.3.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-06-05T03:06:45+00:00" + }, + { + "name": "sebastian/complexity", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/c5651c795c98093480df79350cb050813fc7a2f3", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/complexity", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:41:32+00:00" + }, + { + "name": "sebastian/diff", + "version": "9.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "a3fb6a298a265ff487a91bbea46e03cd01dbb226" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/a3fb6a298a265ff487a91bbea46e03cd01dbb226", + "reference": "a3fb6a298a265ff487a91bbea46e03cd01dbb226", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.2", + "symfony/process": "^7.4.13" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/9.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" + } + ], + "time": "2026-06-05T03:04:51+00:00" + }, + { + "name": "sebastian/environment", + "version": "9.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", + "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.1.11" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/9.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:41:38+00:00" + }, + { + "name": "sebastian/exporter", + "version": "8.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "cfaa77c750dcad6f44c9bac8f62ac486e1c82c26" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/cfaa77c750dcad6f44c9bac8f62ac486e1c82c26", + "reference": "cfaa77c750dcad6f44c9bac8f62ac486e1c82c26", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.2.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/8.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2026-07-13T11:35:11+00:00" + }, + { + "name": "sebastian/file-filter", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/file-filter.git", + "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/file-filter/zipball/33a26f394330f6faa7684bb9cc73afb7727aae93", + "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for filtering files", + "homepage": "https://github.com/sebastianbergmann/file-filter", + "support": { + "issues": "https://github.com/sebastianbergmann/file-filter/issues", + "security": "https://github.com/sebastianbergmann/file-filter/security/policy", + "source": "https://github.com/sebastianbergmann/file-filter/tree/1.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/file-filter", + "type": "tidelift" + } + ], + "time": "2026-04-22T07:20:04+00:00" + }, + { + "name": "sebastian/git-state", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/git-state.git", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/git-state/zipball/792a952e0eba55b6960a48aeceb9f371aad1f76b", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for describing the state of a Git checkout", + "homepage": "https://github.com/sebastianbergmann/git-state", + "support": { + "issues": "https://github.com/sebastianbergmann/git-state/issues", + "security": "https://github.com/sebastianbergmann/git-state/security/policy", + "source": "https://github.com/sebastianbergmann/git-state/tree/1.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/git-state", + "type": "tidelift" + } + ], + "time": "2026-03-21T12:54:28+00:00" + }, + { + "name": "sebastian/global-state", + "version": "9.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^13.1.13" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/9.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2026-06-01T15:11:33+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d1b6f8fce682505dbd048977f1abedf1b8ad3ff8", + "reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.8.0", + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.2.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" + } + ], + "time": "2026-07-09T08:42:34+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-enumerator", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:46:36+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-reflector", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:47:13+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/74c5af21f6a5833e91767ca068c4d3dfec15317e", + "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:51:28+00:00" + }, + { + "name": "sebastian/type", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "fee0309275847fefd7636167085e379c1dbf6990" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/fee0309275847fefd7636167085e379c1dbf6990", + "reference": "fee0309275847fefd7636167085e379c1dbf6990", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.1.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2026-05-20T06:49:11+00:00" + }, + { + "name": "sebastian/version", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/ad37a5552c8e2b88572249fdc19b6da7792e021b", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/version", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:52:52+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/browser-kit", + "version": "v7.4.14", + "source": { + "type": "git", + "url": "https://github.com/symfony/browser-kit.git", + "reference": "bb28e8761a6c33975972948010f00d4a10f0a634" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/bb28e8761a6c33975972948010f00d4a10f0a634", + "reference": "bb28e8761a6c33975972948010f00d4a10f0a634", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/dom-crawler": "^6.4|^7.0|^8.0" + }, + "require-dev": { + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\BrowserKit\\": "" + }, + "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": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/browser-kit/tree/v7.4.14" + }, + "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-06-08T20:24:16+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v7.4.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "b75663ed96cf4756e28e3105476f220f92886cc4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/b75663ed96cf4756e28e3105476f220f92886cc4", + "reference": "b75663ed96cf4756e28e3105476f220f92886cc4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v7.4.9" + }, + "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-04-18T13:18:21+00:00" + }, + { + "name": "symfony/dom-crawler", + "version": "v7.4.12", + "source": { + "type": "git", + "url": "https://github.com/symfony/dom-crawler.git", + "reference": "b59b59122690976550fd142c23fab62c84738db6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/b59b59122690976550fd142c23fab62c84738db6", + "reference": "b59b59122690976550fd142c23fab62c84738db6", + "shasum": "" + }, + "require": { + "masterminds/html5": "^2.6", + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.0" + }, + "require-dev": { + "symfony/css-selector": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DomCrawler\\": "" + }, + "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": "Eases DOM navigation for HTML and XML documents", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dom-crawler/tree/v7.4.12" + }, + "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-20T07:20:23+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^8.1" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-12-08T11:19:18+00:00" + } + ], "aliases": [], "minimum-stability": "stable", "stability-flags": {}, @@ -3670,5 +5871,5 @@ "ext-iconv": "*" }, "platform-dev": {}, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } diff --git a/config/packages/test/cache.yaml b/config/packages/test/cache.yaml new file mode 100644 index 0000000..8fd43b9 --- /dev/null +++ b/config/packages/test/cache.yaml @@ -0,0 +1,12 @@ +framework: + cache: + app: cache.adapter.array + pools: + nonceCache: + adapters: cache.adapter.array + rateLimitCache: + adapters: cache.adapter.array + sessionCache: + adapters: cache.adapter.array + sessionStorage: + adapters: cache.adapter.array diff --git a/config/packages/test/framework.yaml b/config/packages/test/framework.yaml new file mode 100644 index 0000000..ee44ae5 --- /dev/null +++ b/config/packages/test/framework.yaml @@ -0,0 +1,4 @@ +framework: + test: true + session: + storage_factory_id: session.storage.factory.mock_file diff --git a/phpunit.dist.xml b/phpunit.dist.xml new file mode 100644 index 0000000..1597ca7 --- /dev/null +++ b/phpunit.dist.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + tests + + + + + + src + + + + trigger_deprecation + + + + + + diff --git a/src/Kernel.php b/src/Kernel.php index 00b9874..3e58ff6 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -9,7 +9,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Kernel as BaseKernel; -final class Kernel extends BaseKernel { +class Kernel extends BaseKernel { use MicroKernelTrait; private PersistCache $persistCache; diff --git a/symfony.lock b/symfony.lock index 40bce60..90002fc 100644 --- a/symfony.lock +++ b/symfony.lock @@ -1,4 +1,19 @@ { + "phpunit/phpunit": { + "version": "13.2", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "11.1", + "ref": "ca0bc067abfb40a8de1b2561b96cbfc2b833c314" + }, + "files": [ + ".env.test", + "phpunit.dist.xml", + "tests/bootstrap.php", + "bin/phpunit" + ] + }, "symfony/console": { "version": "7.4", "recipe": { diff --git a/tests/Functional/AuthenticationFlowTest.php b/tests/Functional/AuthenticationFlowTest.php new file mode 100644 index 0000000..43b445c --- /dev/null +++ b/tests/Functional/AuthenticationFlowTest.php @@ -0,0 +1,485 @@ + LoginListener -> AllowListener -> + * AcceptListener -> InterceptListener and the services they orchestrate. + */ +final class AuthenticationFlowTest extends WebTestCase { + + private const string TOTP_SECRET = 'JBSWY3DPEHPK3PXP'; + private const string COOKIE_NAME = '__Host-Http-Preauth'; + + protected static function createClient(array $options = [], array $server = []): KernelBrowser + { + $client = parent::createClient($options, $server); + // The app stores nonces in the (in-memory) nonceCache pool. In + // production APCu keeps them across requests, but KernelBrowser + // reboots the kernel between requests by default which would lose + // them. Disable the reboot so the nonce issued on the login-page + // request survives to the login-submission request. + $client->disableReboot(); + + return $client; + } + + 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 { + $json = json_encode($data, JSON_THROW_ON_ERROR); + return rtrim(strtr(base64_encode($json), '+/', '-_'), '='); + } + + private function loginPayload( + string $id = 'testuser', + ?string $token = null, + string $nonce = 'test-nonce-abc', + bool $json = true, + ): string { + return $this->encodePayload([ + 'id' => $id, + 'token' => $token ?? $this->validTotpCode(), + 'nonce' => $nonce, + 'json' => $json, + ]); + } + + /* ── unauthenticated access ──────────────────────────────────────── */ + + public function testUnauthenticatedRequestShowsLoginPage(): void { + $client = static::createClient(); + $client->request('GET', '/'); + + // login page is served with 401 (Unauthorized) to signal the proxy + self::assertSame(401, $client->getResponse()->getStatusCode()); + self::assertSelectorExists('form#preauth-form'); + self::assertSelectorExists('input[name="nonce"]'); + self::assertSelectorExists('input[name="username"]'); + self::assertSelectorExists('input[name="totp"]'); + } + + public function testLoginPageContainsGeneratedNonce(): void { + $client = static::createClient(); + $crawler = $client->request('GET', '/'); + + $nonceInput = $crawler->filter('input[name="nonce"]')->attr('value'); + self::assertNotEmpty($nonceInput); + // base64url charset + self::assertMatchesRegularExpression('/^[A-Za-z0-9_-]+$/', $nonceInput); + } + + public function testLoginFormDoesNotUsePostMethodWithoutAuthSubdomain(): void { + $client = static::createClient(); + $crawler = $client->request('GET', '/'); + + $form = $crawler->filter('form#preauth-form'); + // without central auth, the form should NOT have method="post" + $method = $form->attr('method'); + self::assertNull($method); + } + + /* ── successful TOTP login ────────────────────────────────────────── */ + + public function testSuccessfulTotpLoginViaHeaderSetsCookieAndRedirects(): void { + $client = static::createClient(); + + // first, grab a valid nonce from the login page + $crawler = $client->request('GET', '/'); + $nonce = $crawler->filter('input[name="nonce"]')->attr('value'); + self::assertNotEmpty($nonce); + + // now submit a valid TOTP via the X-Preauth header + $client->request('GET', '/', [], [], [ + 'HTTP_X-Preauth' => $this->encodePayload([ + 'id' => 'alice', + 'token' => $this->validTotpCode(), + 'nonce' => $nonce, + 'json' => true, + ]), + ]); + + $response = $client->getResponse(); + self::assertSame(303, $response->getStatusCode()); // SEE_OTHER + self::assertTrue($response->headers->has('Location')); + // a session cookie should be set + $cookies = $response->headers->getCookies(); + $hasPreauthCookie = false; + foreach ($cookies as $cookie) { + if (str_contains($cookie->getName(), 'Preauth')) { + $hasPreauthCookie = true; + } + } + self::assertTrue($hasPreauthCookie, 'Expected a preauth cookie to be set after login'); + } + + public function testSuccessfulLoginReturnsJsonWhenJsonRequested(): void { + $client = static::createClient(); + + $crawler = $client->request('GET', '/'); + $nonce = $crawler->filter('input[name="nonce"]')->attr('value'); + + $client->request('GET', '/', [], [], [ + 'HTTP_X-Preauth' => $this->encodePayload([ + 'id' => 'bob', + 'token' => $this->validTotpCode(), + 'nonce' => $nonce, + 'json' => true, + ]), + ]); + + $response = $client->getResponse(); + self::assertSame(303, $response->getStatusCode()); + self::assertSame('application/json', $response->headers->get('Content-Type')); + $body = json_decode($response->getContent(), true); + self::assertSame('Login successful', $body['message']); + } + + public function testSuccessfulLoginReturnsHtmlWhenJsonFalse(): void { + $client = static::createClient(); + + $crawler = $client->request('GET', '/'); + $nonce = $crawler->filter('input[name="nonce"]')->attr('value'); + + $client->request('GET', '/', [], [], [ + 'HTTP_X-Preauth' => $this->encodePayload([ + 'id' => 'carol', + 'token' => $this->validTotpCode(), + 'nonce' => $nonce, + 'json' => false, + ]), + ]); + + $response = $client->getResponse(); + self::assertSame(303, $response->getStatusCode()); + self::assertStringStartsWith('text/html', $response->headers->get('Content-Type')); + } + + public function testAuthenticatedCookieAccessAfterLogin(): void { + $client = static::createClient(); + + // login + $crawler = $client->request('GET', '/'); + $nonce = $crawler->filter('input[name="nonce"]')->attr('value'); + + $client->request('GET', '/', [], [], [ + 'HTTP_X-Preauth' => $this->encodePayload([ + 'id' => 'dave', + 'token' => $this->validTotpCode(), + 'nonce' => $nonce, + 'json' => true, + ]), + ]); + + // grab the cookie value from the login response + $loginResponse = $client->getResponse(); + $cookieValue = null; + foreach ($loginResponse->headers->getCookies() as $cookie) { + if (str_contains($cookie->getName(), 'Preauth')) { + $cookieValue = $cookie->getValue(); + } + } + self::assertNotNull($cookieValue); + + // the cookie was set with secure=true, so the CookieJar will only + // send it over HTTPS; the KernelBrowser automatically updates the + // CookieJar from the login response, so the next request over HTTPS + // will include it + $client->request('GET', 'https://localhost/dashboard'); + + $response = $client->getResponse(); + self::assertSame(200, $response->getStatusCode()); + self::assertSame('dave', $response->headers->get('Remote-User')); + } + + public function testScopeNoneReturnsPlainTextWithoutRedirect(): void { + $client = static::createClient(); + + $crawler = $client->request('GET', '/'); + $nonce = $crawler->filter('input[name="nonce"]')->attr('value'); + + $client->request('GET', '/', [], [], [ + 'HTTP_X-Preauth' => $this->encodePayload([ + 'id' => 'eve', + 'token' => $this->validTotpCode(), + 'nonce' => $nonce, + 'scope' => 'none', + ]), + ]); + + $response = $client->getResponse(); + self::assertSame(200, $response->getStatusCode()); + self::assertStringStartsWith('text/plain', $response->headers->get('Content-Type')); + self::assertSame('eve', $response->headers->get('Remote-User')); + // no redirect for scope=none + self::assertFalse($response->headers->has('Location')); + } + + /* ── failed login ─────────────────────────────────────────────────── */ + + public function testFailedLoginReturnsUnauthorizedJsonWithError(): void { + $client = static::createClient(); + + $crawler = $client->request('GET', '/'); + $nonce = $crawler->filter('input[name="nonce"]')->attr('value'); + + $client->request('GET', '/', [], [], [ + 'HTTP_X-Preauth' => $this->encodePayload([ + 'id' => 'alice', + 'token' => '000000', // wrong code + 'nonce' => $nonce, + 'json' => true, + ]), + ]); + + $response = $client->getResponse(); + self::assertSame(401, $response->getStatusCode()); + self::assertSame('application/json', $response->headers->get('Content-Type')); + $body = json_decode($response->getContent(), true); + self::assertArrayHasKey('message', $body); + self::assertArrayHasKey('nonce', $body); + // a fresh nonce should be returned for the next attempt + self::assertNotEmpty($body['nonce']); + } + + public function testFailedLoginReturnsHtmlWhenJsonFalse(): void { + $client = static::createClient(); + + $crawler = $client->request('GET', '/'); + $nonce = $crawler->filter('input[name="nonce"]')->attr('value'); + + $client->request('GET', '/', [], [], [ + 'HTTP_X-Preauth' => $this->encodePayload([ + 'id' => 'alice', + 'token' => 'wrong-code', + 'nonce' => $nonce, + 'json' => false, + ]), + ]); + + $response = $client->getResponse(); + self::assertSame(401, $response->getStatusCode()); + self::assertStringStartsWith('text/html', $response->headers->get('Content-Type')); + self::assertSelectorExists('form#preauth-form'); + } + + public function testFailedLoginWithSpentNonceIsRejected(): void { + $client = static::createClient(); + + $crawler = $client->request('GET', '/'); + $nonce = $crawler->filter('input[name="nonce"]')->attr('value'); + + // first: successful login consumes the nonce + $client->request('GET', '/', [], [], [ + 'HTTP_X-Preauth' => $this->encodePayload([ + 'id' => 'alice', + 'token' => $this->validTotpCode(), + 'nonce' => $nonce, + 'json' => true, + ]), + ]); + self::assertSame(303, $client->getResponse()->getStatusCode()); + + // the successful login set a session cookie; clear it so the next + // request is not auto-authenticated by AcceptListener before the + // login attempt is even evaluated + $client->getCookieJar()->clear(); + + // reuse the same nonce — should fail even with a valid token + $client->request('GET', '/', [], [], [ + 'HTTP_X-Preauth' => $this->encodePayload([ + 'id' => 'alice', + 'token' => $this->validTotpCode(), + 'nonce' => $nonce, + 'json' => true, + ]), + ]); + self::assertSame(401, $client->getResponse()->getStatusCode()); + } + + public function testFailedLoginWithInvalidNonceIsRejected(): void { + $client = static::createClient(); + + // skip fetching a real nonce; use one that was never stored + $client->request('GET', '/', [], [], [ + 'HTTP_X-Preauth' => $this->encodePayload([ + 'id' => 'alice', + 'token' => $this->validTotpCode(), + 'nonce' => 'never-issued-nonce', + 'json' => true, + ]), + ]); + + self::assertSame(401, $client->getResponse()->getStatusCode()); + } + + /* ── invalid payload ──────────────────────────────────────────────── */ + + public function testInvalidHeaderPayloadReturnsUnauthorized(): void { + $client = static::createClient(); + + $client->request('GET', '/', [], [], [ + 'HTTP_X-Preauth' => '!!!not-valid-base64!!!', + ]); + + // decode fails -> null payload -> failure path -> 401 + self::assertSame(401, $client->getResponse()->getStatusCode()); + } + + public function testPayloadWithMissingFieldsReturnsUnauthorized(): void { + $client = static::createClient(); + + // payload missing token + $client->request('GET', '/', [], [], [ + 'HTTP_X-Preauth' => $this->encodePayload([ + 'id' => 'alice', 'nonce' => 'some-nonce', + ]), + ]); + + self::assertSame(401, $client->getResponse()->getStatusCode()); + } + + /* ── invalid cookie ───────────────────────────────────────────────── */ + + public function testInvalidCookieIsClearedAndLoginPageShown(): void { + $client = static::createClient(); + + // the cookie must be set via the CookieJar so that the HttpFoundation + // Request actually populates its cookies bag (HTTP_COOKIE alone is + // 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', + ) + ); + + $client->request('GET', 'https://localhost/'); + + $response = $client->getResponse(); + // not authenticated -> login page with 401 + self::assertSame(401, $response->getStatusCode()); + // the stale cookie should be cleared + $cleared = false; + foreach ($response->headers->getCookies() as $cookie) { + if ($cookie->getName() === self::COOKIE_NAME && $cookie->isCleared()) { + $cleared = true; + } + } + self::assertTrue($cleared, 'Expected the invalid cookie to be cleared'); + } + + /* ── backup code authentication ───────────────────────────────────── */ + + public function testBackupCodeAuthenticationWorks(): void { + $client = static::createClient(); + $container = $client->getContainer(); + + // generate a backup code via the BackupCodeManager + $manager = $container->get(\App\Service\BackupCodeInterface::class); + $codes = $manager->generate(1); + self::assertCount(1, $codes); + + $crawler = $client->request('GET', '/'); + $nonce = $crawler->filter('input[name="nonce"]')->attr('value'); + + $client->request('GET', '/', [], [], [ + 'HTTP_X-Preauth' => $this->encodePayload([ + 'id' => 'frank', + 'token' => $codes[0], + 'nonce' => $nonce, + 'json' => true, + ]), + ]); + + self::assertSame(303, $client->getResponse()->getStatusCode()); + } + + public function testConsumedBackupCodeCannotBeReused(): void { + $client = static::createClient(); + $container = $client->getContainer(); + + $manager = $container->get(\App\Service\BackupCodeInterface::class); + $codes = $manager->generate(1); + $code = $codes[0]; + + // first use + $crawler = $client->request('GET', '/'); + $nonce = $crawler->filter('input[name="nonce"]')->attr('value'); + $client->request('GET', '/', [], [], [ + 'HTTP_X-Preauth' => $this->encodePayload([ + 'id' => 'frank', 'token' => $code, 'nonce' => $nonce, 'json' => true, + ]), + ]); + self::assertSame(303, $client->getResponse()->getStatusCode()); + + // the successful login set a session cookie; clear it so the next + // request reaches the login page instead of being auto-authenticated + $client->getCookieJar()->clear(); + + // second use with a fresh nonce + $crawler = $client->request('GET', '/'); + $nonce2 = $crawler->filter('input[name="nonce"]')->attr('value'); + $client->request('GET', '/', [], [], [ + 'HTTP_X-Preauth' => $this->encodePayload([ + 'id' => 'frank', 'token' => $code, 'nonce' => $nonce2, 'json' => true, + ]), + ]); + self::assertSame(401, $client->getResponse()->getStatusCode()); + } + + /* ── return URL handling ──────────────────────────────────────────── */ + + public function testSuccessfulLoginWithValidReturnUrl(): void { + $client = static::createClient(); + + $crawler = $client->request('GET', '/?return=https://example.com/app'); + $nonce = $crawler->filter('input[name="nonce"]')->attr('value'); + + $client->request('GET', '/?return=https://example.com/app', [], [], [ + 'HTTP_X-Preauth' => $this->encodePayload([ + 'id' => 'alice', 'token' => $this->validTotpCode(), + 'nonce' => $nonce, 'json' => true, + ]), + ]); + + $response = $client->getResponse(); + self::assertSame(303, $response->getStatusCode()); + self::assertSame('https://example.com/app', $response->headers->get('Location')); + } + + public function testSuccessfulLoginWithInvalidReturnFallsBackToPath(): void { + $client = static::createClient(); + + $crawler = $client->request('GET', '/?return=not-a-url'); + $nonce = $crawler->filter('input[name="nonce"]')->attr('value'); + + $client->request('GET', '/?return=not-a-url', [], [], [ + 'HTTP_X-Preauth' => $this->encodePayload([ + 'id' => 'alice', 'token' => $this->validTotpCode(), + 'nonce' => $nonce, 'json' => true, + ]), + ]); + + $response = $client->getResponse(); + self::assertSame(303, $response->getStatusCode()); + $location = $response->headers->get('Location'); + // should fall back to the request path (with query string), + // not redirect to the invalid return URL as an absolute URL + self::assertStringStartsWith('/', $location); + // the invalid return URL is not used as the redirect target + self::assertStringNotContainsString('//not-a-url', $location); + } +} diff --git a/tests/Support/ListenerTestHelper.php b/tests/Support/ListenerTestHelper.php new file mode 100644 index 0000000..bd335b2 --- /dev/null +++ b/tests/Support/ListenerTestHelper.php @@ -0,0 +1,111 @@ + true]); + // the templates reference a global `env` object; supply one with the + // keys used by base/login/error/_script/_style + $twig->addGlobal('env', (object)[ + 'title' => 'Pre-Authentication System', + 'bg_color' => '#029386', + 'fg_color' => '#ffffff', + 'error_color' => '#ffb16d', + 'id_name' => 'Session ID', + 'token_name' => 'Authentication Token', + 'submit_name' => 'Submit', + 'error_message' => 'Unsuccessful login attempt', + 'teapot' => true, + 'teapot_title' => "I'm a teapot", + 'teapot_message' => 'I refuse to brew coffee', + 'too_many_title' => 'Too many requests', + 'too_many_message' => 'Try again later', + 'debug' => 0, + ]); + return $twig; + } + + /** + * A RateLimiterFactoryInterface whose created limiter returns a RateLimit + * with the given remaining tokens. + */ + 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 $this->limiter; + } + }; + } + + 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 { + throw new \Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException(); + } + public function consume(int $tokens = 1): RateLimit { + return $this->rateLimit; + } + public function reset(): void {} + }; + } + + /** + * 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 int $consumed = 0; + 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 { + $this->consumed += $tokens; + $remaining = max(0, $this->threshold - $this->consumed); + return new RateLimit( + $remaining, + new \DateTimeImmutable('+10 seconds'), + $remaining > 0, + $this->threshold, + ); + } + 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 $this->limiter; + } + }; + } +} diff --git a/tests/Support/TotpTestHelper.php b/tests/Support/TotpTestHelper.php new file mode 100644 index 0000000..43ca9d8 --- /dev/null +++ b/tests/Support/TotpTestHelper.php @@ -0,0 +1,89 @@ +time); + } + }; + } + + /** Provisioning URI built from the well-known secret + frozen clock. */ + 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 { + return TOTP::createFromSecret(self::TOTP_SECRET, $this->frozenClock())->now(); + } + + /** A fresh in-memory cache pool suitable for wrapping in MonitorCacheKeys. */ + private function emptyPool(): CacheItemPoolInterface { + return new ArrayAdapter(); + } + + /** + * Build a ConfigBag wired with the deterministic TOTP and frozen clock. + * Extra params override the sensible defaults. + */ + private function makeConfig( + ?int $cookieTtl = 3600, + ?int $ipTtl = 0, + bool $teapot = true, + string $errorMessage = 'Error', + string $teapotTitle = 'Teapot', + string $tooManyTitle = 'Too Many', + ): ConfigBag { + $clock = $this->frozenClock(); + $utilities = $this->createUtilities($clock); + return new ConfigBag( + $utilities, $clock, + $cookieTtl, $this->totpUri(), $ipTtl, $teapot, + $errorMessage, $teapotTitle, $tooManyTitle, + ); + } + + /** + * Minimal Utilities stub that never triggers TOTP generation when + * a non-empty totpUri is supplied to ConfigBag. + */ + private function createUtilities(?PsrClockInterface $clock = null): Utilities { + $clock ??= $this->frozenClock(); + $cache = $this->createStub(CacheItemPoolInterface::class); + $cache->method('hasItem')->willReturn(false); + $item = $this->createStub(CacheItemInterface::class); + $item->method('isHit')->willReturn(false); + $cache->method('getItem')->willReturn($item); + return new Utilities($clock, $cache); + } +} diff --git a/tests/TestKernel.php b/tests/TestKernel.php new file mode 100644 index 0000000..f4cc885 --- /dev/null +++ b/tests/TestKernel.php @@ -0,0 +1,41 @@ +addCompilerPass(new class implements CompilerPassInterface { + public function process(ContainerBuilder $container): void + { + foreach (['nonceCache', 'rateLimitCache', 'sessionCache', 'sessionStorage'] as $poolId) { + if ($container->hasDefinition($poolId)) { + $container->getDefinition($poolId)->clearTag('kernel.reset'); + } + } + } + }); + } +} diff --git a/tests/Unit/ClockTest.php b/tests/Unit/ClockTest.php new file mode 100644 index 0000000..559cd6a --- /dev/null +++ b/tests/Unit/ClockTest.php @@ -0,0 +1,20 @@ +now(); + $after = new \DateTimeImmutable(); + + self::assertInstanceOf(\DateTimeImmutable::class, $now); + self::assertGreaterThanOrEqual($before->getTimestamp(), $now->getTimestamp()); + self::assertLessThanOrEqual($after->getTimestamp(), $now->getTimestamp()); + } +} diff --git a/tests/Unit/Command/GenerateBackupCodesCommandTest.php b/tests/Unit/Command/GenerateBackupCodesCommandTest.php new file mode 100644 index 0000000..f8b5e62 --- /dev/null +++ b/tests/Unit/Command/GenerateBackupCodesCommandTest.php @@ -0,0 +1,120 @@ +createStub(BackupCodeInterface::class); + $manager->method('generate')->willReturn($generatedCodes); + return $manager; + } + + public function testGenerateDefaultCountOutputsCodes(): void { + $codes = ['abc123', 'def456', 'ghi789', 'jkl012', 'mno345', + 'pqr678', 'stu901', 'vwx234', 'yzA567', 'bCd890']; + $command = new GenerateBackupCodesCommand( + $this->makeManagerStub($codes), $this->makePersistCache() + ); + $command->setName('app:generate-backup-codes'); + + $tester = new CommandTester($command); + $exit = $tester->execute([]); + + self::assertSame(0, $exit); + $output = $tester->getDisplay(); + foreach ($codes as $code) { + self::assertStringContainsString($code, $output); + } + } + + public function testGenerateSpecificCountPassesCountToManager(): void { + $manager = $this->createMock(BackupCodeInterface::class); + $manager->expects(self::once()) + ->method('generate') + ->with(self::identicalTo(5)) + ->willReturn(['c1', 'c2', 'c3', 'c4', 'c5']); + + $command = new GenerateBackupCodesCommand($manager, $this->makePersistCache()); + $command->setName('app:generate-backup-codes'); + + $tester = new CommandTester($command); + $exit = $tester->execute(['count' => 5]); + + self::assertSame(0, $exit); + } + + public function testDefaultCountArgumentIsTen(): void { + // the configured default for the count argument should be 10 + $manager = $this->createMock(BackupCodeInterface::class); + $manager->expects(self::once()) + ->method('generate') + ->with(self::identicalTo(10)) + ->willReturn(array_fill(0, 10, 'code')); + + $command = new GenerateBackupCodesCommand($manager, $this->makePersistCache()); + $command->setName('app:generate-backup-codes'); + + $tester = new CommandTester($command); + $tester->execute([]); + + // assertion is in the mock expectation above + $this->addToAssertionCount(1); + } + + 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() + ); + $command->setName('app:generate-backup-codes'); + + $tester = new CommandTester($command); + $exit = $tester->execute([]); + + self::assertSame(0, $exit); + } + + public function testZeroCodesOutputsNothing(): void { + $command = new GenerateBackupCodesCommand( + $this->makeManagerStub([]), $this->makePersistCache() + ); + $command->setName('app:generate-backup-codes'); + + $tester = new CommandTester($command); + $exit = $tester->execute(['count' => 0]); + + self::assertSame(0, $exit); + self::assertSame('', trim($tester->getDisplay())); + } + + public function testCommandNameAndDescriptionAreConfigured(): void { + $command = new GenerateBackupCodesCommand( + $this->makeManagerStub([]), $this->makePersistCache() + ); + // configuring via the Application runs the protected configure() + $app = new \Symfony\Component\Console\Application(); + $app->addCommand($command); + self::assertSame('app:generate-backup-codes', $command->getName()); + // the source uses a non-breaking hyphen (U+2011) in "single‑use", + // so assert against the substring to avoid encoding fragility + self::assertStringContainsString('backup codes', $command->getDescription()); + } +} diff --git a/tests/Unit/ConfigBagTest.php b/tests/Unit/ConfigBagTest.php new file mode 100644 index 0000000..acddced --- /dev/null +++ b/tests/Unit/ConfigBagTest.php @@ -0,0 +1,90 @@ +createStub(ClockInterface::class); + $cache = $this->createStub(CacheItemPoolInterface::class); + + if ($totp !== null) { + $item = $this->createStub(CacheItemInterface::class); + $item->method('isHit')->willReturn(true); + $item->method('get')->willReturn($totp); + $cache->method('hasItem')->willReturn(true); + $cache->method('getItem')->willReturn($item); + } else { + $cache->method('hasItem')->willReturn(false); + } + + return new Utilities($clock, $cache); + } + + 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!' + ); + + self::assertSame($clock, $config->clock()); + self::assertSame(3600, $config->cookieTtl()); + self::assertSame('otpauth://totp/test', $config->totpUri()); + self::assertSame(1800, $config->ipTtl()); + self::assertTrue($config->teapot()); + self::assertSame('Error!', $config->errorMessage()); + self::assertSame('Teapot!', $config->teapotTitle()); + self::assertSame('Too Many!', $config->tooManyTitle()); + } + + 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' + ); + + self::assertSame('fallback-totp', $config->totpUri()); + } + + 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' + ); + + self::assertNull($config->ipTtl()); + } + + 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' + ); + + self::assertNull($config->ipTtl()); + } +} + diff --git a/tests/Unit/Data/PayloadTest.php b/tests/Unit/Data/PayloadTest.php new file mode 100644 index 0000000..b86fe30 --- /dev/null +++ b/tests/Unit/Data/PayloadTest.php @@ -0,0 +1,195 @@ + 'testuser', 'token' => '123456', 'nonce' => 'abc123', + 'json' => true, 'scope' => 'cookie', + ]); + $payload = Payload::decode(self::b64u($data)); + + self::assertInstanceOf(Payload::class, $payload); + self::assertSame('testuser', $payload->id); + self::assertSame('123456', $payload->token); + self::assertSame('abc123', $payload->nonce); + self::assertTrue($payload->json); + self::assertSame(Scope::Cookie, $payload->scope); + } + + public function testDecodeInvalidBase64UrlReturnsNull(): void { + self::assertNull(Payload::decode('!!!not-valid-base64!!!')); + } + + public function testDecodeNonObjectJsonReturnsNull(): void { + self::assertNull(Payload::decode(self::b64u('"just a string"'))); + } + + public function testDecodeInvalidJsonReturnsNull(): void { + // valid base64url but invalid JSON + self::assertNull(Payload::decode(self::b64u('{invalid json'))); + } + + public function testDecodeJsonArrayReturnsNull(): void { + self::assertNull(Payload::decode(self::b64u('[1,2,3]'))); + } + + public function testDecodeJsonNullReturnsNull(): void { + self::assertNull(Payload::decode(self::b64u('null'))); + } + + public function testDecodeJsonBooleanReturnsNull(): void { + self::assertNull(Payload::decode(self::b64u('true'))); + self::assertNull(Payload::decode(self::b64u('false'))); + } + + public function testDecodeJsonNumberReturnsNull(): void { + self::assertNull(Payload::decode(self::b64u('42'))); + } + + public function testDecodeEmptyStringReturnsNull(): void { + self::assertNull(Payload::decode('')); + } + + public function testLoadWithValidInputBag(): void { + $input = new InputBag([ + 'username' => 'alice', 'nonce' => 'nonce123', 'totp' => '654321', + ]); + $payload = Payload::load($input); + + self::assertInstanceOf(Payload::class, $payload); + self::assertSame('alice', $payload->id); + self::assertSame('nonce123', $payload->nonce); + self::assertSame('654321', $payload->token); + self::assertFalse($payload->json); + self::assertSame(Scope::Cookie, $payload->scope); + } + + public function testLoadMissingUsernameReturnsNull(): void { + $input = new InputBag(['nonce' => 'n', 'totp' => 't']); + self::assertNull(Payload::load($input)); + } + + public function testLoadMissingNonceReturnsNull(): void { + $input = new InputBag(['username' => 'u', 'totp' => 't']); + self::assertNull(Payload::load($input)); + } + + public function testLoadMissingTotpReturnsNull(): void { + $input = new InputBag(['username' => 'u', 'nonce' => 'n']); + self::assertNull(Payload::load($input)); + } + + 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 { + $data = (object)[ + 'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1', + 'json' => false, 'scope' => 'ip', + ]; + $payload = Payload::create($data); + + self::assertInstanceOf(Payload::class, $payload); + self::assertSame('user1', $payload->id); + self::assertSame('tok1', $payload->token); + self::assertSame('non1', $payload->nonce); + self::assertFalse($payload->json); + self::assertSame(Scope::Ip, $payload->scope); + } + + 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 { + $data = (object)[ + 'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1', + 'scope' => 'admin', + ]; + $payload = Payload::create($data); + self::assertSame(Scope::Cookie, $payload->scope); + } + + public function testCreateWithMissingJsonDefaultsToTrue(): void { + $data = (object)['id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1']; + $payload = Payload::create($data); + self::assertTrue($payload->json); + } + + public function testCreateWithNoneScopeSetsJsonFalse(): void { + $data = (object)[ + 'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1', + 'json' => true, 'scope' => 'none', + ]; + $payload = Payload::create($data); + self::assertSame(Scope::None, $payload->scope); + self::assertFalse($payload->json); + } + + public function testCreateWithEmptyIdReturnsNull(): void { + $data = (object)['id' => '', 'token' => 't', 'nonce' => 'n']; + self::assertNull(Payload::create($data)); + } + + public function testCreateWithWhitespaceIdReturnsNull(): void { + $data = (object)['id' => ' ', 'token' => 't', 'nonce' => 'n']; + self::assertNull(Payload::create($data)); + } + + public function testCreateWithEmptyTokenReturnsNull(): void { + $data = (object)['id' => 'u', 'token' => '', 'nonce' => 'n']; + self::assertNull(Payload::create($data)); + } + + public function testCreateWithEmptyNonceReturnsNull(): void { + $data = (object)['id' => 'u', 'token' => 't', 'nonce' => '']; + self::assertNull(Payload::create($data)); + } + + public function testCreateTrimsAndTruncatesFields(): void { + $long = str_repeat('a', 200); + $data = (object)[ + 'id' => ' ' . $long . ' ', + 'token' => ' ' . $long . ' ', + 'nonce' => ' ' . $long . ' ', + ]; + $payload = Payload::create($data); + $expected = mb_substr($long, 0, 128); + self::assertSame($expected, $payload->id); + self::assertSame($expected, $payload->token); + self::assertSame($expected, $payload->nonce); + } + + public function testToString(): void { + $payload = new Payload(); + $payload->id = 'u'; + $payload->token = 't'; + $payload->nonce = 'n'; + $payload->json = true; + $payload->scope = Scope::Cookie; + + $decoded = json_decode($payload->toString(), true); + self::assertSame('u', $decoded['id']); + self::assertSame('t', $decoded['token']); + self::assertSame('n', $decoded['nonce']); + self::assertTrue($decoded['json']); + self::assertSame('cookie', $decoded['scope']); + } +} diff --git a/tests/Unit/Enum/ScopeTest.php b/tests/Unit/Enum/ScopeTest.php new file mode 100644 index 0000000..071f58d --- /dev/null +++ b/tests/Unit/Enum/ScopeTest.php @@ -0,0 +1,26 @@ +value); + self::assertSame('ip', Scope::Ip->value); + self::assertSame('none', Scope::None->value); + } + + 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 { + self::assertNull(Scope::tryFrom('invalid')); + self::assertNull(Scope::tryFrom('')); + } +} diff --git a/tests/Unit/Listener/AcceptListenerTest.php b/tests/Unit/Listener/AcceptListenerTest.php new file mode 100644 index 0000000..41b04a9 --- /dev/null +++ b/tests/Unit/Listener/AcceptListenerTest.php @@ -0,0 +1,124 @@ +setLogger(new NullLogger()); + return $listener; + } + + private function makeEvent(Request $request): RequestEvent { + return new RequestEvent( + $this->createStub(\Symfony\Component\HttpKernel\HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + ); + } + + /* ── valid cookie session ─────────────────────────────────────────── */ + + public function testValidCookieSetsResponseWithRemoteUser(): void { + $pool = new ArrayAdapter(); + $ulid = '01HXY1234567890ABCDEFGHIJK'; + $item = $pool->getItem('cookie_' . $ulid); + $item->set('alice'); + $pool->save($item); + + $domainManager = new DomainManager(false, ''); + $listener = $this->makeListener($pool, $domainManager); + + $request = Request::create('/', 'GET'); + $request->cookies->set(self::COOKIE_NAME, $ulid); + + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + self::assertTrue($event->hasResponse()); + $response = $event->getResponse(); + self::assertSame(200, $response->getStatusCode()); + self::assertSame('alice', $response->headers->get('Remote-User')); + self::assertSame('text/plain', $response->headers->get('Content-Type')); + } + + public function testValidCookieUsesAuthCookieNameWhenUsingCentralAuth(): void { + $pool = new ArrayAdapter(); + $ulid = '01HXY1234567890ABCDEFGHIJK'; + $item = $pool->getItem('cookie_' . $ulid); + $item->set('bob'); + $pool->save($item); + + $domainManager = new DomainManager(true, 'auth.example.com'); + $listener = $this->makeListener($pool, $domainManager); + + $request = Request::create('/', 'GET'); + $request->cookies->set(self::AUTH_COOKIE_NAME, $ulid); + + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + self::assertTrue($event->hasResponse()); + self::assertSame('bob', $event->getResponse()->headers->get('Remote-User')); + } + + /* ── negative cases ───────────────────────────────────────────────── */ + + public function testNoCookieSetsNoResponse(): void { + $pool = new ArrayAdapter(); + $domainManager = new DomainManager(false, ''); + $listener = $this->makeListener($pool, $domainManager); + + $event = $this->makeEvent(Request::create('/', 'GET')); + $listener->onKernelRequest($event); + + self::assertFalse($event->hasResponse()); + } + + public function testCookieWithoutSessionSetsNoResponse(): void { + $pool = new ArrayAdapter(); + $domainManager = new DomainManager(false, ''); + $listener = $this->makeListener($pool, $domainManager); + + $request = Request::create('/', 'GET'); + $request->cookies->set(self::COOKIE_NAME, 'unknown-ulid'); + + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + self::assertFalse($event->hasResponse()); + } + + public function testEmptyCookieValueSetsNoResponse(): void { + $pool = new ArrayAdapter(); + $domainManager = new DomainManager(false, ''); + $listener = $this->makeListener($pool, $domainManager); + + // cookies->set with empty string + $request = Request::create('/', 'GET'); + $request->cookies->set(self::COOKIE_NAME, ''); + + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + // empty cookie value should not be treated as a valid session + self::assertFalse($event->hasResponse()); + } +} diff --git a/tests/Unit/Listener/AllowListenerTest.php b/tests/Unit/Listener/AllowListenerTest.php new file mode 100644 index 0000000..3b4917e --- /dev/null +++ b/tests/Unit/Listener/AllowListenerTest.php @@ -0,0 +1,95 @@ +setLogger(new NullLogger()); + return $listener; + } + + private function makeEvent(Request $request): RequestEvent { + return new RequestEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + ); + } + + public function testValidIpSessionSetsResponseWithRemoteUser(): void { + $pool = new ArrayAdapter(); + $item = $pool->getItem('ip_1.2.3.4'); + $item->set('carol'); + $pool->save($item); + + $config = $this->makeConfig(ipTtl: 1800); + $listener = $this->makeListener($pool, $config); + + $request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']); + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + self::assertTrue($event->hasResponse()); + $response = $event->getResponse(); + self::assertSame(200, $response->getStatusCode()); + self::assertSame('carol', $response->headers->get('Remote-User')); + self::assertSame('text/plain', $response->headers->get('Content-Type')); + } + + public function testNoIpSessionSetsNoResponse(): void { + $pool = new ArrayAdapter(); + $config = $this->makeConfig(ipTtl: 1800); + $listener = $this->makeListener($pool, $config); + + $request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '9.9.9.9']); + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + self::assertFalse($event->hasResponse()); + } + + 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'); + $item->set('carol'); + $pool->save($item); + + $config = $this->makeConfig(ipTtl: 0); + $listener = $this->makeListener($pool, $config); + + $request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']); + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + self::assertFalse($event->hasResponse()); + } + + public function testIpAccessDisabledDoesNotCheckCache(): void { + $pool = new ArrayAdapter(); + $config = $this->makeConfig(ipTtl: 0); + $listener = $this->makeListener($pool, $config); + + $request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']); + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + // when disabled, nothing should have been written/read as a session + self::assertFalse($event->hasResponse()); + self::assertFalse($pool->hasItem('ip_1.2.3.4')); + } +} diff --git a/tests/Unit/Listener/InterceptListenerTest.php b/tests/Unit/Listener/InterceptListenerTest.php new file mode 100644 index 0000000..8ca30c1 --- /dev/null +++ b/tests/Unit/Listener/InterceptListenerTest.php @@ -0,0 +1,206 @@ +makeConfig(), + $domainManager, + $this->makeTwig(), + ); + $listener->setLogger(new NullLogger()); + $listener->setNonceCache($nonceCache ?? new ArrayAdapter()); + return $listener; + } + + private function makeEvent(Request $request): RequestEvent { + return new RequestEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + ); + } + + /* ── central-auth redirect branch ─────────────────────────────────── */ + + public function testRedirectsToAuthSubdomainWhenHostMatchesBaseDomain(): void { + $domainManager = new DomainManager(true, 'auth.example.com'); + $listener = $this->makeListener($domainManager); + + $request = Request::create('https://app.example.com/dashboard', 'GET'); + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + self::assertTrue($event->hasResponse()); + $response = $event->getResponse(); + self::assertSame(Response::HTTP_SEE_OTHER, $response->getStatusCode()); + $location = $response->headers->get('Location'); + self::assertStringStartsWith('https://auth.example.com/?', $location); + // the return query should contain the original url + self::assertStringContainsString('return=', $location); + self::assertStringContainsString(urlencode('https://app.example.com/dashboard'), $location); + } + + public function testDoesNotRedirectWhenAlreadyOnAuthSubdomain(): void { + $domainManager = new DomainManager(true, 'auth.example.com'); + $listener = $this->makeListener($domainManager); + + $request = Request::create('https://auth.example.com/', 'GET'); + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + // should render login page, not redirect + self::assertTrue($event->hasResponse()); + $response = $event->getResponse(); + self::assertNotSame(Response::HTTP_SEE_OTHER, $response->getStatusCode()); + self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode()); + } + + /* ── login page rendering branch ──────────────────────────────────── */ + + public function testPresentsLoginPageWithUnauthorizedStatus(): void { + $domainManager = new DomainManager(false, ''); + $listener = $this->makeListener($domainManager); + + $request = Request::create('https://example.com/', 'GET'); + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + self::assertTrue($event->hasResponse()); + $response = $event->getResponse(); + self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode()); + self::assertSame('text/html', $response->headers->get('Content-Type')); + $content = $response->getContent(); + self::assertStringContainsString('makeListener($domainManager, $nonceCache); + + $request = Request::create('https://example.com/', 'GET'); + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + // exactly one nonce should now exist in the cache, marked valid + $found = false; + foreach ($nonceCache->getValues() as $key => $value) { + if (str_starts_with($key, 'test_') || preg_match('/^[A-Za-z0-9_.]+$/', $key)) { + $found = true; + } + } + // ArrayAdapter stores raw values; verify at least one item was saved + self::assertTrue(count($nonceCache->getValues()) > 0); + } + + public function testLoginTemplateUsesPostFormWhenOnAuthSubdomain(): void { + $domainManager = new DomainManager(true, 'auth.example.com'); + $listener = $this->makeListener($domainManager); + + $request = Request::create('https://auth.example.com/', 'GET'); + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + $content = $event->getResponse()->getContent(); + // when on the auth subdomain, post=true so the form has method="post" + self::assertStringContainsString('method="post"', $content); + } + + public function testLoginTemplateDoesNotUsePostFormWhenNotOnAuthSubdomain(): void { + $domainManager = new DomainManager(false, ''); + $listener = $this->makeListener($domainManager); + + $request = Request::create('https://example.com/', 'GET'); + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + $content = $event->getResponse()->getContent(); + // not on auth subdomain, so the form should NOT have method="post" + self::assertStringNotContainsString('method="post"', $content); + } + + /* ── invalid cookie pruning ───────────────────────────────────────── */ + + public function testInvalidCookieIsClearedWhenPresent(): void { + $domainManager = new DomainManager(false, ''); + $listener = $this->makeListener($domainManager); + + $request = Request::create('https://example.com/', 'GET'); + // send a cookie that won't match any session (so AcceptListener didn't fire) + $request->cookies->set(self::COOKIE_NAME, 'stale-ulid'); + + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + self::assertTrue($event->hasResponse()); + $response = $event->getResponse(); + // a Clear-Site-Data style clearCookie should produce a Set-Cookie that expires it + $cookies = $response->headers->getCookies(); + $cleared = false; + foreach ($cookies as $cookie) { + if ($cookie->getName() === self::COOKIE_NAME && $cookie->isCleared()) { + $cleared = true; + } + } + self::assertTrue($cleared, 'Expected the invalid cookie to be cleared'); + } + + public function testNoCookieClearingWhenNoCookiePresent(): void { + $domainManager = new DomainManager(false, ''); + $listener = $this->makeListener($domainManager); + + $request = Request::create('https://example.com/', 'GET'); + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + $response = $event->getResponse(); + self::assertSame([], $response->headers->getCookies()); + } + + public function testInvalidCookieUsesAuthCookieNameWithCentralAuth(): void { + $domainManager = new DomainManager(true, 'auth.example.com'); + $listener = $this->makeListener($domainManager); + + // request to auth subdomain with a stale auth-domain cookie + $request = Request::create('https://auth.example.com/', 'GET'); + $request->cookies->set(self::AUTH_COOKIE_NAME, 'stale-ulid'); + + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + $response = $event->getResponse(); + $cleared = false; + foreach ($response->headers->getCookies() as $cookie) { + if ($cookie->getName() === self::AUTH_COOKIE_NAME && $cookie->isCleared()) { + $cleared = true; + } + } + self::assertTrue($cleared, 'Expected the auth cookie to be cleared'); + } +} diff --git a/tests/Unit/Listener/LoginListenerTest.php b/tests/Unit/Listener/LoginListenerTest.php new file mode 100644 index 0000000..d8d1b65 --- /dev/null +++ b/tests/Unit/Listener/LoginListenerTest.php @@ -0,0 +1,295 @@ +makeTwig(), + $this->makeRateLimiterFactory($rateLimitRemaining ?? 5), + $domainManager ?? new DomainManager(false, ''), + $loginManager ?? $this->createStub(LoginInterface::class), + $this->makeConfig(), + ); + $listener->setLogger(new NullLogger()); + $listener->setNonceCache(new ArrayAdapter()); + return $listener; + } + + private function makeEvent(Request $request): RequestEvent { + return new RequestEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + ); + } + + /** Build a base64url-encoded X-Preauth header value for a payload. */ + 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 { + $listener = $this->makeListener(); + + $request = Request::create('https://example.com/', 'GET'); + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + self::assertFalse($event->hasResponse()); + } + + 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); + + $request = Request::create('https://app.example.com/', 'POST'); + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + self::assertFalse($event->hasResponse()); + } + + /* ── successful login via header ──────────────────────────────────── */ + + public function testSuccessfulLoginViaHeaderSetsResponseFromManager(): void { + $expected = new Response('hi alice', 200, ['Remote-User' => 'alice']); + $loginManager = $this->createStub(LoginInterface::class); + $loginManager->method('checkToken')->willReturn($expected); + + $listener = $this->makeListener(loginManager: $loginManager); + + $payload = $this->encodePayload([ + 'id' => 'alice', 'token' => '123456', 'nonce' => 'nonce-1', 'json' => true, + ]); + $request = Request::create('https://example.com/', 'GET'); + $request->headers->set(self::HEADER_NAME, $payload); + + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + self::assertTrue($event->hasResponse()); + self::assertSame($expected, $event->getResponse()); + } + + public function testSuccessfulLoginViaPostToAuthSubdomain(): void { + $expected = new Response('hi bob', 303, ['Location' => '/']); + $loginManager = $this->createStub(LoginInterface::class); + $loginManager->method('checkToken')->willReturn($expected); + + $domainManager = new DomainManager(true, 'auth.example.com'); + $listener = $this->makeListener(loginManager: $loginManager, domainManager: $domainManager); + + $request = Request::create('https://auth.example.com/', 'POST', [ + 'username' => 'bob', 'totp' => '654321', 'nonce' => 'nonce-2', + ]); + + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + self::assertTrue($event->hasResponse()); + self::assertSame($expected, $event->getResponse()); + } + + /* ── failed login ─────────────────────────────────────────────────── */ + + public function testFailedLoginReturnsJsonErrorWithNewNonce(): void { + $loginManager = $this->createStub(LoginInterface::class); + $loginManager->method('checkToken')->willReturn(null); + + $listener = $this->makeListener(loginManager: $loginManager); + + $payload = $this->encodePayload([ + 'id' => 'alice', 'token' => 'wrong', 'nonce' => 'nonce-1', 'json' => true, + ]); + $request = Request::create('https://example.com/', 'GET'); + $request->headers->set(self::HEADER_NAME, $payload); + + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + self::assertTrue($event->hasResponse()); + $response = $event->getResponse(); + self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode()); + self::assertSame('application/json', $response->headers->get('Content-Type')); + $body = json_decode($response->getContent(), true); + // the TotpTestHelper::makeConfig default errorMessage is 'Error' + self::assertSame('Error', $body['message']); + self::assertNotEmpty($body['nonce']); + self::assertFalse($body['post']); + // username is echoed back (sanitized via makeCacheKey) + self::assertSame('alice', $body['username']); + } + + public function testFailedLoginHtmlResponseWhenJsonFalse(): void { + $loginManager = $this->createStub(LoginInterface::class); + $loginManager->method('checkToken')->willReturn(null); + + $listener = $this->makeListener(loginManager: $loginManager); + + $payload = $this->encodePayload([ + 'id' => 'alice', 'token' => 'wrong', 'nonce' => 'nonce-1', 'json' => false, + ]); + $request = Request::create('https://example.com/', 'GET'); + $request->headers->set(self::HEADER_NAME, $payload); + + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + $response = $event->getResponse(); + self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode()); + self::assertSame('text/html', $response->headers->get('Content-Type')); + self::assertStringContainsString('getContent()); + } + + public function testFailedLoginOnAuthSubdomainUsesPostForm(): void { + $loginManager = $this->createStub(LoginInterface::class); + $loginManager->method('checkToken')->willReturn(null); + + $domainManager = new DomainManager(true, 'auth.example.com'); + $listener = $this->makeListener( + loginManager: $loginManager, + domainManager: $domainManager, + ); + + $payload = $this->encodePayload([ + 'id' => 'alice', 'token' => 'wrong', 'nonce' => 'nonce-1', 'json' => false, + ]); + $request = Request::create('https://auth.example.com/', 'GET'); + $request->headers->set(self::HEADER_NAME, $payload); + + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + $content = $event->getResponse()->getContent(); + self::assertStringContainsString('method="post"', $content); + } + + /* ── rate-limited (blocked) login ─────────────────────────────────── */ + + public function testRateLimitedLoginReturnsTeapotWhenTeapotEnabled(): void { + $loginManager = $this->createStub(LoginInterface::class); + $loginManager->method('checkToken')->willReturn(null); + + // limiter with 0 remaining tokens -> blocked + $listener = $this->makeListener( + loginManager: $loginManager, + rateLimitRemaining: 0, + ); + + $payload = $this->encodePayload([ + 'id' => 'alice', 'token' => 'wrong', 'nonce' => 'nonce-1', 'json' => true, + ]); + $request = Request::create('https://example.com/', 'GET'); + $request->headers->set(self::HEADER_NAME, $payload); + + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + $response = $event->getResponse(); + self::assertSame(Response::HTTP_I_AM_A_TEAPOT, $response->getStatusCode()); + $body = json_decode($response->getContent(), true); + // the TotpTestHelper::makeConfig default teapotTitle is 'Teapot' + self::assertSame('Teapot', $body['message']); + } + + public function testRateLimitedLoginReturnsTooManyRequestsWhenTeapotDisabled(): void { + $loginManager = $this->createStub(LoginInterface::class); + $loginManager->method('checkToken')->willReturn(null); + + $listener = new LoginListener( + $this->makeTwig(), + $this->makeRateLimiterFactory(0), + new DomainManager(false, ''), + $loginManager, + $this->makeConfig(teapot: false), + ); + $listener->setLogger(new NullLogger()); + $listener->setNonceCache(new ArrayAdapter()); + + $payload = $this->encodePayload([ + 'id' => 'alice', 'token' => 'wrong', 'nonce' => 'nonce-1', 'json' => true, + ]); + $request = Request::create('https://example.com/', 'GET'); + $request->headers->set(self::HEADER_NAME, $payload); + + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + $response = $event->getResponse(); + self::assertSame(Response::HTTP_TOO_MANY_REQUESTS, $response->getStatusCode()); + $body = json_decode($response->getContent(), true); + // teapot disabled, so tooManyTitle is used; helper default is 'Too Many' + self::assertSame('Too Many', $body['message']); + } + + /* ── invalid payload handling ─────────────────────────────────────── */ + + public function testInvalidHeaderPayloadStillRecordsFailureAndResponds(): void { + $loginManager = $this->createMock(LoginInterface::class); + // checkToken should not be called with a null payload + $loginManager->expects(self::never())->method('checkToken'); + + $listener = $this->makeListener(loginManager: $loginManager); + + // an un-decodable header value + $request = Request::create('https://example.com/', 'GET'); + $request->headers->set(self::HEADER_NAME, '!!!not-valid-base64!!!'); + + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + // Payload::decode returns null, so checkToken is skipped, but a + // failure response is still produced (the rate limiter is consulted) + self::assertTrue($event->hasResponse()); + self::assertSame(Response::HTTP_UNAUTHORIZED, $event->getResponse()->getStatusCode()); + } + + public function testPostWithoutRequiredFieldsDoesNotAttemptLogin(): void { + $loginManager = $this->createMock(LoginInterface::class); + $loginManager->expects(self::never())->method('checkToken'); + + $domainManager = new DomainManager(true, 'auth.example.com'); + $listener = $this->makeListener( + loginManager: $loginManager, + domainManager: $domainManager, + ); + + // POST to auth subdomain but missing the required fields + $request = Request::create('https://auth.example.com/', 'POST', ['username' => 'only-user']); + + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + // Payload::load returns null (missing totp & nonce), so it falls through + // to the failure path and produces a response + self::assertTrue($event->hasResponse()); + } +} diff --git a/tests/Unit/Listener/RejectListenerTest.php b/tests/Unit/Listener/RejectListenerTest.php new file mode 100644 index 0000000..2e8ab5d --- /dev/null +++ b/tests/Unit/Listener/RejectListenerTest.php @@ -0,0 +1,89 @@ +makeConfig(teapot: $teapot), + $this->makeTwig(), + $this->makeRateLimiterFactory($remainingTokens), + ); + $listener->setLogger(new NullLogger()); + return $listener; + } + + private function makeEvent(Request $request): RequestEvent { + return new RequestEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + ); + } + + public function testBlockedRequestReturnsTeapotWhenTeapotEnabled(): void { + $listener = $this->makeListener(teapot: true, remainingTokens: 0); + + $request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']); + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + self::assertTrue($event->hasResponse()); + $response = $event->getResponse(); + self::assertSame(Response::HTTP_I_AM_A_TEAPOT, $response->getStatusCode()); + self::assertSame('text/html', $response->headers->get('Content-Type')); + } + + public function testBlockedRequestReturnsTooManyRequestsWhenTeapotDisabled(): void { + $listener = $this->makeListener(teapot: false, remainingTokens: 0); + + $request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']); + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + self::assertTrue($event->hasResponse()); + $response = $event->getResponse(); + self::assertSame(Response::HTTP_TOO_MANY_REQUESTS, $response->getStatusCode()); + self::assertSame('text/html', $response->headers->get('Content-Type')); + } + + public function testUnblockedRequestSetsNoResponse(): void { + $listener = $this->makeListener(remainingTokens: 5); + + $request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']); + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + // consume(0) with remaining tokens > 0 should not block + self::assertFalse($event->hasResponse()); + } + + public function testBlockedResponseContainsErrorTemplateContent(): void { + $listener = $this->makeListener(teapot: true, remainingTokens: 0); + + $request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']); + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + $content = $event->getResponse()->getContent(); + // Twig escapes the apostrophe in "I'm a teapot" to ' + self::assertStringContainsString('a teapot', $content); + self::assertStringContainsString('I refuse to brew coffee', $content); + } +} diff --git a/tests/Unit/MonitorCacheKeysTest.php b/tests/Unit/MonitorCacheKeysTest.php new file mode 100644 index 0000000..32105e8 --- /dev/null +++ b/tests/Unit/MonitorCacheKeysTest.php @@ -0,0 +1,273 @@ +wrap(); + + self::assertSame([], $monitor->getKeys()); + self::assertSame([], $monitor->getChanges()); + } + + public function testSaveAddsKeyAndTracksChange(): void { + $monitor = $this->wrap(); + $item = $monitor->getItem('alpha'); + $item->set('value'); + $monitor->save($item); + + self::assertSame(['alpha'], $monitor->getKeys()); + self::assertSame(['alpha' => MonitorCacheKeys::UPDATED], $monitor->getChanges()); + } + + public function testSaveDeferredThenCommitAddsKey(): void { + $monitor = $this->wrap(); + $item = $monitor->getItem('beta'); + $item->set('value'); + $monitor->saveDeferred($item); + + // saveDeferred calls update() which commits immediately + self::assertSame(['beta'], $monitor->getKeys()); + self::assertSame(['beta' => MonitorCacheKeys::UPDATED], $monitor->getChanges()); + } + + public function testGetItemReturnsUnderlyingItem(): void { + $monitor = $this->wrap(); + $item = $monitor->getItem('mykey'); + $item->set('data'); + $monitor->save($item); + + $fetched = $monitor->getItem('mykey'); + self::assertTrue($fetched->isHit()); + self::assertSame('data', $fetched->get()); + } + + public function testGetItemsReturnsMultipleItems(): void { + $monitor = $this->wrap(); + $a = $monitor->getItem('a'); + $a->set(1); + $monitor->save($a); + $b = $monitor->getItem('b'); + $b->set(2); + $monitor->save($b); + + $items = $monitor->getItems(['a', 'b']); + $keys = []; + foreach ($items as $key => $item) { + $keys[$key] = $item->get(); + } + self::assertSame(['a' => 1, 'b' => 2], $keys); + } + + public function testHasItemReturnsTrueForExistingKey(): void { + $monitor = $this->wrap(); + $item = $monitor->getItem('exists'); + $item->set('v'); + $monitor->save($item); + + self::assertTrue($monitor->hasItem('exists')); + self::assertFalse($monitor->hasItem('missing')); + } + + public function testDeleteItemRemovesKeyAndTracksRemoval(): void { + $monitor = $this->wrap(); + $item = $monitor->getItem('doomed'); + $item->set('v'); + $monitor->save($item); + + $monitor->deleteItem('doomed'); + + self::assertSame([], $monitor->getKeys()); + self::assertSame(['doomed' => MonitorCacheKeys::REMOVED], $monitor->getChanges()); + self::assertFalse($monitor->hasItem('doomed')); + } + + public function testDeleteItemOnMissingKeyIsNoop(): void { + $monitor = $this->wrap(); + + $result = $monitor->deleteItem('nonexistent'); + + self::assertTrue($result); + self::assertSame([], $monitor->getKeys()); + } + + public function testDeleteItemsRemovesMultipleKeys(): void { + $monitor = $this->wrap(); + foreach (['x', 'y', 'z'] as $key) { + $item = $monitor->getItem($key); + $item->set($key); + $monitor->save($item); + } + + $monitor->deleteItems(['x', 'y']); + + self::assertSame(['z'], $monitor->getKeys()); + $changes = $monitor->getChanges(); + self::assertSame(MonitorCacheKeys::REMOVED, $changes['x']); + self::assertSame(MonitorCacheKeys::REMOVED, $changes['y']); + } + + public function testDeleteItemsWithMissingKeysStillReturnsTrue(): void { + $monitor = $this->wrap(); + + $result = $monitor->deleteItems(['ghost1', 'ghost2']); + + self::assertTrue($result); + } + + public function testClearWipesPoolWhenNotEmpty(): void { + $monitor = $this->wrap(); + $item = $monitor->getItem('keep'); + $item->set('v'); + $monitor->save($item); + + $result = $monitor->clear(); + + self::assertTrue($result); + self::assertSame([], $monitor->getKeys()); + } + + public function testClearIsNoopWhenEmpty(): void { + $monitor = $this->wrap(); + + $result = $monitor->clear(); + + self::assertTrue($result); + } + + public function testMarkCleanResetsChangeList(): void { + $monitor = $this->wrap(); + $item = $monitor->getItem('temp'); + $item->set('v'); + $monitor->save($item); + + self::assertNotEmpty($monitor->getChanges()); + + $monitor->markClean(); + + self::assertSame([], $monitor->getChanges()); + self::assertSame(['temp'], $monitor->getKeys()); + } + + public function testCommitPassesThrough(): void { + $monitor = $this->wrap(); + + self::assertTrue($monitor->commit()); + } + + public function testSaveKeyListThrowsOutOfBoundsException(): void { + $monitor = $this->wrap(); + $item = $monitor->getItem('__key_list'); + + $this->expectException(OutOfBoundsException::class); + $monitor->save($item); + } + + public function testSaveChangeListThrowsOutOfBoundsException(): void { + $monitor = $this->wrap(); + $item = $monitor->getItem('__chg_list'); + + $this->expectException(OutOfBoundsException::class); + $monitor->save($item); + } + + public function testDeleteKeyListThrowsOutOfBoundsException(): void { + $monitor = $this->wrap(); + + $this->expectException(OutOfBoundsException::class); + $monitor->deleteItem('__key_list'); + } + + public function testDeleteChangeListThrowsOutOfBoundsException(): void { + $monitor = $this->wrap(); + + $this->expectException(OutOfBoundsException::class); + $monitor->deleteItem('__chg_list'); + } + + public function testDeleteItemsWithKeyListThrowsOutOfBoundsException(): void { + $monitor = $this->wrap(); + + $this->expectException(OutOfBoundsException::class); + $monitor->deleteItems(['safe', '__key_list']); + } + + public function testDeleteItemsWithChangeListThrowsOutOfBoundsException(): void { + $monitor = $this->wrap(); + + $this->expectException(OutOfBoundsException::class); + $monitor->deleteItems(['__chg_list']); + } + + public function testSaveDeferredOnKeyListThrowsOutOfBoundsException(): void { + $monitor = $this->wrap(); + $item = $monitor->getItem('safe'); + $item->set('value'); + + // getItem returns the real item, but saveDeferred calls update() which + // validates the key — so we need to get the __key_list item and try to save it + $keyListItem = $monitor->getItem('__key_list'); + + $this->expectException(OutOfBoundsException::class); + $monitor->saveDeferred($keyListItem); + } + + public function testSaveDeferredOnChangeListThrowsOutOfBoundsException(): void { + $monitor = $this->wrap(); + $changeListItem = $monitor->getItem('__chg_list'); + + $this->expectException(OutOfBoundsException::class); + $monitor->saveDeferred($changeListItem); + } + + public function testGetKeysReturnsEmptyArrayWhenKeyListMissing(): void { + // If the underlying pool loses its key list, getKeys should return [] + $pool = new ArrayAdapter(); + $monitor = new MonitorCacheKeys($pool); + + $item = $monitor->getItem('alpha'); + $item->set('value'); + $monitor->save($item); + + // delete the key list directly from the underlying pool + $pool->deleteItem('__key_list'); + + $monitor2 = new MonitorCacheKeys($pool); + // the constructor will re-initialize since __key_list is missing + // but getKeys on the new monitor should be empty + self::assertSame([], $monitor2->getKeys()); + } + + public function testDeleteItemReturnsTrueForExistingKey(): void { + $monitor = $this->wrap(); + $item = $monitor->getItem('to-delete'); + $item->set('value'); + $monitor->save($item); + + self::assertTrue($monitor->deleteItem('to-delete')); + self::assertNotContains('to-delete', $monitor->getKeys()); + } + + public function testDeleteItemsReturnsTrue(): void { + $monitor = $this->wrap(); + foreach (['a', 'b', 'c'] as $key) { + $item = $monitor->getItem($key); + $item->set('value'); + $monitor->save($item); + } + + self::assertTrue($monitor->deleteItems(['a', 'b', 'c'])); + self::assertSame([], $monitor->getKeys()); + } +} diff --git a/tests/Unit/PersistCacheTest.php b/tests/Unit/PersistCacheTest.php new file mode 100644 index 0000000..34bc45e --- /dev/null +++ b/tests/Unit/PersistCacheTest.php @@ -0,0 +1,220 @@ +boot(); + + // nothing was loaded since storage is empty + $monitor = new MonitorCacheKeys($sessionCache); + self::assertSame([], $monitor->getKeys()); + } + + public function testBootLoadsFromStorageIntoCache(): void { + $sessionCache = new ArrayAdapter(); + $sessionStorage = new ArrayAdapter(); + + // populate storage with some session data + $storageMonitor = new MonitorCacheKeys($sessionStorage); + $item = $storageMonitor->getItem('cookie_abc'); + $item->set('user1'); + $storageMonitor->save($item); + $storageMonitor->markClean(); + + $persist = new PersistCache($sessionCache, $sessionStorage); + $persist->boot(); + + // session cache should now contain the loaded data + $cacheMonitor = new MonitorCacheKeys($sessionCache); + self::assertContains('cookie_abc', $cacheMonitor->getKeys()); + self::assertSame('user1', $cacheMonitor->getItem('cookie_abc')->get()); + // boot should mark clean so no changes are pending + self::assertSame([], $cacheMonitor->getChanges()); + } + + public function testBootDoesNotReloadWhenCacheAlreadyWarm(): void { + $sessionCache = new ArrayAdapter(); + $sessionStorage = new ArrayAdapter(); + + // warm up the cache with existing data + $cacheMonitor = new MonitorCacheKeys($sessionCache); + $item = $cacheMonitor->getItem('cookie_existing'); + $item->set('old-user'); + $cacheMonitor->save($item); + + // put different data in storage + $storageMonitor = new MonitorCacheKeys($sessionStorage); + $item = $storageMonitor->getItem('cookie_new'); + $item->set('new-user'); + $storageMonitor->save($item); + + $persist = new PersistCache($sessionCache, $sessionStorage); + $persist->boot(); + + // existing data should be preserved, storage data NOT loaded + $monitor = new MonitorCacheKeys($sessionCache); + self::assertContains('cookie_existing', $monitor->getKeys()); + self::assertNotContains('cookie_new', $monitor->getKeys()); + } + + public function testPersistWritesChangesToStorage(): void { + $sessionCache = new ArrayAdapter(); + $sessionStorage = new ArrayAdapter(); + + $persist = new PersistCache($sessionCache, $sessionStorage); + $persist->boot(); + + // write something to the session cache + $cacheMonitor = new MonitorCacheKeys($sessionCache); + $item = $cacheMonitor->getItem('cookie_xyz'); + $item->set('user2'); + $cacheMonitor->save($item); + + $persist->persist(); + + // storage should now contain the change + $storageMonitor = new MonitorCacheKeys($sessionStorage); + self::assertContains('cookie_xyz', $storageMonitor->getKeys()); + self::assertSame('user2', $storageMonitor->getItem('cookie_xyz')->get()); + } + + public function testPersistHandlesRemovals(): void { + $sessionCache = new ArrayAdapter(); + $sessionStorage = new ArrayAdapter(); + + // seed storage with an item + $storageMonitor = new MonitorCacheKeys($sessionStorage); + $item = $storageMonitor->getItem('cookie_to_remove'); + $item->set('user3'); + $storageMonitor->save($item); + $storageMonitor->markClean(); + + $persist = new PersistCache($sessionCache, $sessionStorage); + $persist->boot(); + + // now delete it from session cache + $cacheMonitor = new MonitorCacheKeys($sessionCache); + $cacheMonitor->deleteItem('cookie_to_remove'); + + $persist->persist(); + + // storage should no longer have it + $storageMonitor = new MonitorCacheKeys($sessionStorage); + self::assertNotContains('cookie_to_remove', $storageMonitor->getKeys()); + } + + public function testPersistIsNoopWhenNoChanges(): void { + $sessionCache = new ArrayAdapter(); + $sessionStorage = new ArrayAdapter(); + + $persist = new PersistCache($sessionCache, $sessionStorage); + $persist->boot(); + $persist->persist(); + + $storageMonitor = new MonitorCacheKeys($sessionStorage); + self::assertSame([], $storageMonitor->getKeys()); + } + + public function testFullBootModifyPersistCycle(): void { + $sessionCache = new ArrayAdapter(); + $sessionStorage = new ArrayAdapter(); + + // boot (empty), add data, persist + $persist = new PersistCache($sessionCache, $sessionStorage); + $persist->boot(); + + $cacheMonitor = new MonitorCacheKeys($sessionCache); + $item = $cacheMonitor->getItem('cookie_cycle'); + $item->set('cycled-user'); + $cacheMonitor->save($item); + + $persist->persist(); + + // simulate a new request: fresh cache, same storage + $newCache = new ArrayAdapter(); + $persist2 = new PersistCache($newCache, $sessionStorage); + $persist2->boot(); + + $monitor = new MonitorCacheKeys($newCache); + self::assertContains('cookie_cycle', $monitor->getKeys()); + self::assertSame('cycled-user', $monitor->getItem('cookie_cycle')->get()); + } + + public function testPersistHandlesMixedUpdatesAndRemovals(): void { + $sessionCache = new ArrayAdapter(); + $sessionStorage = new ArrayAdapter(); + + // seed storage with two items + $storageMonitor = new MonitorCacheKeys($sessionStorage); + $item1 = $storageMonitor->getItem('cookie_keep'); + $item1->set('user-keep'); + $storageMonitor->save($item1); + $item2 = $storageMonitor->getItem('cookie_remove'); + $item2->set('user-remove'); + $storageMonitor->save($item2); + $storageMonitor->markClean(); + + $persist = new PersistCache($sessionCache, $sessionStorage); + $persist->boot(); + + // update one item and delete the other in the same cycle + $cacheMonitor = new MonitorCacheKeys($sessionCache); + $item1 = $cacheMonitor->getItem('cookie_keep'); + $item1->set('user-updated'); + $cacheMonitor->save($item1); + $cacheMonitor->deleteItem('cookie_remove'); + + $persist->persist(); + + // storage should reflect both changes + $storageMonitor = new MonitorCacheKeys($sessionStorage); + self::assertContains('cookie_keep', $storageMonitor->getKeys()); + self::assertSame('user-updated', $storageMonitor->getItem('cookie_keep')->get()); + self::assertNotContains('cookie_remove', $storageMonitor->getKeys()); + } + + public function testMultipleBootModifyPersistCycles(): void { + $sessionCache = new ArrayAdapter(); + $sessionStorage = new ArrayAdapter(); + + // cycle 1: add item A + $persist = new PersistCache($sessionCache, $sessionStorage); + $persist->boot(); + $cacheMonitor = new MonitorCacheKeys($sessionCache); + $item = $cacheMonitor->getItem('cookie_a'); + $item->set('user-a'); + $cacheMonitor->save($item); + $persist->persist(); + + // cycle 2: fresh cache, add item B, keep A from storage + $newCache = new ArrayAdapter(); + $persist2 = new PersistCache($newCache, $sessionStorage); + $persist2->boot(); + $cacheMonitor2 = new MonitorCacheKeys($newCache); + $item = $cacheMonitor2->getItem('cookie_b'); + $item->set('user-b'); + $cacheMonitor2->save($item); + $persist2->persist(); + + // cycle 3: fresh cache, both A and B should be loaded from storage + $newCache2 = new ArrayAdapter(); + $persist3 = new PersistCache($newCache2, $sessionStorage); + $persist3->boot(); + $monitor = new MonitorCacheKeys($newCache2); + self::assertContains('cookie_a', $monitor->getKeys()); + self::assertSame('user-a', $monitor->getItem('cookie_a')->get()); + self::assertContains('cookie_b', $monitor->getKeys()); + self::assertSame('user-b', $monitor->getItem('cookie_b')->get()); + } +} diff --git a/tests/Unit/Service/BackupCodeManagerTest.php b/tests/Unit/Service/BackupCodeManagerTest.php new file mode 100644 index 0000000..32fe766 --- /dev/null +++ b/tests/Unit/Service/BackupCodeManagerTest.php @@ -0,0 +1,215 @@ +setConfig($this->makeConfig()); + $manager->setLogger(new NullLogger()); + return $manager; + } + + public function testGenerateReturnsRequestedCount(): void { + $manager = $this->makeManager(); + + $codes = $manager->generate(5); + + self::assertCount(5, $codes); + foreach ($codes as $code) { + self::assertIsString($code); + // codes are lowercase alphanumeric + self::assertMatchesRegularExpression('/^[a-z0-9]+$/', $code); + } + } + + public function testGenerateDefaultCount(): void { + $manager = $this->makeManager(); + + $codes = $manager->generate(); + + self::assertCount(10, $codes); + } + + public function testGenerateZeroReturnsEmptyArray(): void { + $manager = $this->makeManager(); + + $codes = $manager->generate(0); + + self::assertSame([], $codes); + } + + public function testGeneratedCodesAreStoredInCache(): void { + $pool = new ArrayAdapter(); + $manager = $this->makeManager($pool); + + $codes = $manager->generate(3); + + // each code should be stored as a backup_ key + foreach ($codes as $code) { + $key = 'backup_' . strtolower($code); + // the manager uses makeCacheKey which sanitizes, but for alphanumeric it's identity + $item = $pool->getItem($key); + self::assertTrue($item->isHit(), "Expected cache hit for key: $key"); + self::assertTrue($item->get(), "Expected code to be marked valid (true)"); + } + } + + public function testGeneratedCodesHaveFarFutureExpiry(): void { + $pool = new ArrayAdapter(); + $manager = $this->makeManager($pool); + + $codes = $manager->generate(1); + $code = $codes[0]; + + $item = $pool->getItem('backup_' . strtolower($code)); + $expiry = $item->getMetadata()['expiry']; + self::assertGreaterThan((new \DateTimeImmutable('+10 years'))->getTimestamp(), (int) $expiry); + } + + public function testVerifyAndConsumeValidCode(): void { + $manager = $this->makeManager(); + $codes = $manager->generate(2); + + $code = $codes[0]; + + self::assertTrue($manager->verifyAndConsume($code)); + } + + public function testVerifyAndConsumeMarksCodeAsUsed(): void { + $pool = new ArrayAdapter(); + $manager = $this->makeManager($pool); + $codes = $manager->generate(1); + $code = $codes[0]; + + // first use succeeds + self::assertTrue($manager->verifyAndConsume($code)); + + // second use fails (already consumed) + self::assertFalse($manager->verifyAndConsume($code)); + } + + public function testVerifyAndConsumeInvalidCode(): void { + $manager = $this->makeManager(); + + self::assertFalse($manager->verifyAndConsume('nonexistent_code')); + } + + public function testVerifyAndConsumeIsCaseInsensitive(): void { + $manager = $this->makeManager(); + $codes = $manager->generate(1); + $code = $codes[0]; + + // uppercase version should still work + self::assertTrue($manager->verifyAndConsume(strtoupper($code))); + } + + public function testVerifyAndConsumeStripsInvalidCharacters(): void { + $manager = $this->makeManager(); + $codes = $manager->generate(1); + $code = $codes[0]; + + // inject spaces and special chars — should be stripped + self::assertTrue($manager->verifyAndConsume(' ' . $code . '!!')); + } + + public function testExpireRemovesAllBackupCodes(): void { + $pool = new ArrayAdapter(); + $manager = $this->makeManager($pool); + $codes = $manager->generate(5); + + $manager->expire(); + + // all backup keys should be gone + foreach ($codes as $code) { + self::assertFalse($pool->hasItem('backup_' . strtolower($code))); + } + } + + public function testExpireWhenNoBackupCodesIsNoop(): void { + $pool = new ArrayAdapter(); + $manager = $this->makeManager($pool); + + // should not throw + $manager->expire(); + + // this passes if no exception was thrown + self::assertTrue(true); + } + + public function testExpireRemovesOnlyBackupPrefixedKeys(): void { + $pool = new ArrayAdapter(); + $manager = $this->makeManager($pool); + + $codes = $manager->generate(3); + + // add a non-backup key + $item = $pool->getItem('cookie_session'); + $item->set('data'); + $pool->save($item); + + $manager->expire(); + + // non-backup key survives + self::assertTrue($pool->hasItem('cookie_session')); + + // backup keys are gone + foreach ($codes as $code) { + self::assertFalse($pool->hasItem('backup_' . strtolower($code))); + } + } + + 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 { + $pool = new ArrayAdapter(); + $manager = $this->makeManager($pool); + $codes = $manager->generate(1); + $code = $codes[0]; + + // first use succeeds + self::assertTrue($manager->verifyAndConsume($code)); + + // the code is now marked as false (used); isHit is true but get() is false + $key = 'backup_' . strtolower($code); + $item = $pool->getItem($key); + self::assertTrue($item->isHit()); + self::assertFalse($item->get()); + + // second use should fail because get() returns false + self::assertFalse($manager->verifyAndConsume($code)); + } + + public function testGenerateProducesUniqueCodes(): void { + $manager = $this->makeManager(); + + $codes = $manager->generate(50); + + self::assertCount(50, $codes); + self::assertCount(50, array_unique($codes), 'All generated codes should be unique'); + } + + public function testGenerateCodeLengthIsDigitsPlusTwo(): void { + $manager = $this->makeManager(); + + $codes = $manager->generate(1); + + // default TOTP digits is 6, so code length should be 6 + 2 = 8 + self::assertSame(8, strlen($codes[0])); + } +} diff --git a/tests/Unit/Service/DomainManagerTest.php b/tests/Unit/Service/DomainManagerTest.php new file mode 100644 index 0000000..4deca4a --- /dev/null +++ b/tests/Unit/Service/DomainManagerTest.php @@ -0,0 +1,241 @@ +createManager(false, 'auth.example.com'); + self::assertNull($manager->authBase()); + self::assertNull($manager->getAuthSubdomain()); + } + + public function testAuthBaseIsNullWhenAuthSubdomainIsEmpty(): void { + $manager = $this->createManager(true, ''); + self::assertNull($manager->authBase()); + self::assertNull($manager->getAuthSubdomain()); + } + + 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 { + $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 { + $manager = $this->createManager(true, 'localhost'); + self::assertNull($manager->authBase()); + self::assertNull($manager->getAuthSubdomain()); + } + + public function testAuthBaseIsNullForIpAuth(): void { + $manager = $this->createManager(true, '192.168.1.1'); + self::assertNull($manager->authBase()); + self::assertNull($manager->getAuthSubdomain()); + } + + /* ── validReturn ──────────────────────────────────────────────────────── */ + + 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 { + $manager = $this->createManager(true, 'auth.example.com'); + self::assertFalse($manager->validReturn('not-a-url')); + self::assertFalse($manager->validReturn('')); + } + + 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 { + $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 { + $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 { + $manager = $this->createManager(true, 'auth.example.com'); + self::assertFalse($manager->validReturn('mailto:test@example.com')); + } + + /* ── matchesAuth ──────────────────────────────────────────────────────── */ + + 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 { + $manager = $this->createManager(true, ''); + self::assertFalse($manager->matchesAuth('example.com')); + } + + 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 { + $manager = $this->createManager(true, 'auth.example.com'); + self::assertFalse($manager->matchesAuth('evil.com')); + self::assertFalse($manager->matchesAuth('example.org')); + } + + 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 { + $manager = $this->createManager(true, 'auth.example.com'); + self::assertFalse($manager->matchesAuth('192.168.1.1')); + } + + 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 { + $manager = $this->createManager(true, 'auth.example.com'); + self::assertTrue($manager->matchesAuth('a.b.c.example.com')); + } + + 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()); + self::assertTrue($manager->matchesAuth('auth.local')); + self::assertFalse($manager->matchesAuth('local')); + self::assertFalse($manager->matchesAuth('app.local')); + } + + /* ── TLD table coverage ──────────────────────────────────────────────── */ + + 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'); + self::assertSame('com.au', $manager->authBase()); + self::assertTrue($manager->matchesAuth('app.example.com.au')); + self::assertFalse($manager->matchesAuth('example.com')); + } + + 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'); + self::assertSame('co.jp', $manager->authBase()); + self::assertTrue($manager->matchesAuth('www.example.co.jp')); + } + + 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'); + self::assertSame('com.br', $manager->authBase()); + self::assertTrue($manager->matchesAuth('app.example.com.br')); + } + + 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 { + // 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 { + // 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 { + // 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 { + // example.com is a standard 2-part domain, not multi-part + $manager = $this->createManager(true, 'auth.example.com'); + self::assertSame('example.com', $manager->authBase()); + // auth.example.org should NOT match example.com + self::assertFalse($manager->matchesAuth('app.example.org')); + } + + /* ── baseDomain edge cases ───────────────────────────────────────────── */ + + 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 + $manager = $this->createManager(true, 'auth.local'); + // auth.local base is 'auth.local', 'local' base is 'local' -> no match + self::assertFalse($manager->matchesAuth('local')); + // but a subdomain of auth.local does match + self::assertTrue($manager->matchesAuth('app.auth.local')); + } + + public function testMatchesAuthWithEmptyStringHost(): void { + $manager = $this->createManager(true, 'auth.example.com'); + self::assertFalse($manager->matchesAuth('')); + } + + public function testValidReturnAcceptsUrlWithPort(): void { + $manager = $this->createManager(true, 'auth.example.com'); + self::assertTrue($manager->validReturn('https://example.com:8080/path')); + } + + public function testValidReturnAcceptsUrlWithoutPath(): void { + $manager = $this->createManager(true, 'auth.example.com'); + self::assertTrue($manager->validReturn('https://example.com')); + } + + 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 new file mode 100644 index 0000000..291cd39 --- /dev/null +++ b/tests/Unit/Service/LoginManagerTest.php @@ -0,0 +1,424 @@ +pool = new ArrayAdapter(); + $this->backupCodeManager = $this->createStub(BackupCodeInterface::class); + $this->domainManager = new DomainManager($subdomainRedirect, $authSubdomain); + + $manager = new LoginManager($this->pool, $this->backupCodeManager, $this->domainManager); + $manager->setConfig($this->makeConfig(ipTtl: $ipTtl)); + $manager->setLogger(new NullLogger()); + $manager->setNonceCache(new ArrayAdapter()); + return $manager; + } + + /** Build a Payload with a valid server-side nonce already stored. */ + private function makePayloadWithNonce( + LoginManager $manager, + string $id = 'testuser', + Scope $scope = Scope::Cookie, + ?string $token = null, + ): Payload { + $token ??= $this->validTotpCode(); + $nonce = $this->insertNonce($manager, 'test-nonce-123'); + + $payload = new Payload(); + $payload->id = $id; + $payload->token = $token; + $payload->nonce = $nonce; + $payload->json = true; + $payload->scope = $scope; + return $payload; + } + + /** Inject a nonce directly into the manager's nonce cache. */ + private function insertNonce(LoginManager $manager, string $nonce): string { + $reflection = new \ReflectionProperty(LoginManager::class, 'nonceCache'); + $nonceCache = $reflection->getValue($manager); + + $key = $this->makeCacheKey($nonce); + $item = $nonceCache->getItem($key); + $item->set(true); + $nonceCache->save($item); + + return $nonce; + } + + public function testCheckTokenReturnsNullForInvalidTotp(): void { + $manager = $this->makeLoginManager(); + $payload = $this->makePayloadWithNonce($manager, token: 'wrong-code'); + + $this->backupCodeManager->method('verifyAndConsume')->willReturn(false); + + $request = Request::create('/', 'GET'); + + self::assertNull($manager->checkToken($payload, $request)); + } + + public function testCheckTokenReturnsNullForSpentNonce(): void { + $manager = $this->makeLoginManager(); + $payload = $this->makePayloadWithNonce($manager); + + $this->backupCodeManager->method('verifyAndConsume')->willReturn(false); + + // spend the nonce first (use the same cache key the manager does) + $reflection = new \ReflectionProperty(LoginManager::class, 'nonceCache'); + $nonceCache = $reflection->getValue($manager); + $nonceItem = $nonceCache->getItem($this->makeCacheKey('test-nonce-123')); + $nonceItem->set(false); + $nonceCache->save($nonceItem); + + $request = Request::create('/', 'GET'); + + self::assertNull($manager->checkToken($payload, $request)); + } + + public function testCheckTokenReturnsNullForMissingNonce(): void { + $manager = $this->makeLoginManager(); + + $this->backupCodeManager->method('verifyAndConsume')->willReturn(false); + + $payload = new Payload(); + $payload->id = 'user1'; + $payload->token = $this->validTotpCode(); + $payload->nonce = 'never-stored'; + $payload->json = true; + $payload->scope = Scope::Cookie; + + $request = Request::create('/', 'GET'); + + self::assertNull($manager->checkToken($payload, $request)); + } + + public function testSuccessfulTotpLoginWithCookieScopeReturnsRedirect(): void { + $manager = $this->makeLoginManager(); + $payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie); + + $this->backupCodeManager->method('verifyAndConsume')->willReturn(false); + + $request = Request::create('/dashboard', 'GET'); + + $response = $manager->checkToken($payload, $request); + + self::assertNotNull($response); + self::assertSame(303, $response->getStatusCode()); // HTTP_SEE_OTHER + self::assertTrue($response->headers->has('Location')); + self::assertTrue($response->headers->has('Set-Cookie')); + } + + public function testSuccessfulLoginWithNoneScopeReturnsPlainResponse(): void { + $manager = $this->makeLoginManager(); + $payload = $this->makePayloadWithNonce($manager, scope: Scope::None); + + $this->backupCodeManager->method('verifyAndConsume')->willReturn(false); + + $request = Request::create('/', 'GET'); + + $response = $manager->checkToken($payload, $request); + + self::assertNotNull($response); + self::assertSame(200, $response->getStatusCode()); + self::assertSame('text/plain', $response->headers->get('Content-Type')); + self::assertTrue($response->headers->has('Remote-User')); + // no redirect for Scope::None + self::assertFalse($response->headers->has('Location')); + } + + public function testSuccessfulLoginSetsRemoteUserHeader(): void { + $manager = $this->makeLoginManager(); + $payload = $this->makePayloadWithNonce($manager, id: 'alice', scope: Scope::None); + + $this->backupCodeManager->method('verifyAndConsume')->willReturn(false); + + $request = Request::create('/', 'GET'); + + $response = $manager->checkToken($payload, $request); + + self::assertNotNull($response); + self::assertSame('alice', $response->headers->get('Remote-User')); + } + + public function testSuccessfulLoginJsonResponse(): void { + $manager = $this->makeLoginManager(); + $payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie, token: null); + $payload->json = true; + + $this->backupCodeManager->method('verifyAndConsume')->willReturn(false); + + $request = Request::create('/protected', 'GET'); + + $response = $manager->checkToken($payload, $request); + + self::assertNotNull($response); + self::assertSame('application/json', $response->headers->get('Content-Type')); + $body = json_decode($response->getContent(), true); + self::assertSame('Login successful', $body['message']); + } + + public function testSuccessfulLoginHtmlResponse(): void { + $manager = $this->makeLoginManager(); + $payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie); + $payload->json = false; + + $this->backupCodeManager->method('verifyAndConsume')->willReturn(false); + + $request = Request::create('/protected', 'GET'); + + $response = $manager->checkToken($payload, $request); + + self::assertNotNull($response); + self::assertSame('text/html', $response->headers->get('Content-Type')); + } + + public function testSuccessfulLoginWithReturnUrl(): void { + $manager = $this->makeLoginManager(); + $payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie); + + $this->backupCodeManager->method('verifyAndConsume')->willReturn(false); + + $request = Request::create('/login?return=https://example.com/app', 'GET'); + + $response = $manager->checkToken($payload, $request); + + self::assertNotNull($response); + self::assertSame('https://example.com/app', $response->headers->get('Location')); + } + + public function testSuccessfulLoginWithInvalidReturnFallsBackToPath(): void { + $manager = $this->makeLoginManager(); + $payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie); + + $this->backupCodeManager->method('verifyAndConsume')->willReturn(false); + + $request = Request::create('/login?return=not-a-url', 'GET'); + + $response = $manager->checkToken($payload, $request); + + self::assertNotNull($response); + $location = $response->headers->get('Location'); + self::assertStringStartsWith('/login', $location); + } + + public function testIpScopeDowngradesToCookieWhenIpAccessDisabled(): void { + $manager = $this->makeLoginManager(ipTtl: 0); + $payload = $this->makePayloadWithNonce($manager, scope: Scope::Ip); + + $this->backupCodeManager->method('verifyAndConsume')->willReturn(false); + + $request = Request::create('/', 'GET'); + + $response = $manager->checkToken($payload, $request); + + // Should have a Set-Cookie (downgraded to cookie scope) + self::assertNotNull($response); + self::assertTrue($response->headers->has('Set-Cookie')); + } + + public function testIpScopeWhenEnabledSetsIpSession(): void { + $manager = $this->makeLoginManager(ipTtl: 1800); + $payload = $this->makePayloadWithNonce($manager, scope: Scope::Ip); + + $this->backupCodeManager->method('verifyAndConsume')->willReturn(false); + + $request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']); + + $response = $manager->checkToken($payload, $request); + + self::assertNotNull($response); + // IP session should be stored; no Set-Cookie for IP scope + self::assertFalse($response->headers->has('Set-Cookie')); + + // verify the IP session exists in the cache + $reflection = new \ReflectionProperty(LoginManager::class, 'sessionCache'); + $sessionCache = $reflection->getValue($manager); + self::assertTrue($sessionCache->hasItem('ip_1.2.3.4')); + } + + public function testBackupCodeAuthentication(): void { + $manager = $this->makeLoginManager(); + $payload = $this->makePayloadWithNonce($manager, token: 'backup-code-123'); + + $this->backupCodeManager->method('verifyAndConsume')->willReturn(true); + + $request = Request::create('/', 'GET'); + + $response = $manager->checkToken($payload, $request); + + self::assertNotNull($response); + self::assertSame(303, $response->getStatusCode()); + } + + public function testNonceIsConsumedAfterSuccessfulLogin(): void { + $manager = $this->makeLoginManager(); + $payload = $this->makePayloadWithNonce($manager); + + $this->backupCodeManager->method('verifyAndConsume')->willReturn(false); + + $request = Request::create('/', 'GET'); + + $manager->checkToken($payload, $request); + + // nonce should now be marked invalid (false); look it up via the same + // cache key the manager uses (makeCacheKey rewrites '-' to '_') + $reflection = new \ReflectionProperty(LoginManager::class, 'nonceCache'); + $nonceCache = $reflection->getValue($manager); + $nonceItem = $nonceCache->getItem($this->makeCacheKey('test-nonce-123')); + self::assertFalse($nonceItem->get()); + } + + 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); + $item->method('isHit')->willReturn(true); + $item->method('get')->willReturn('existing'); + // The nonce cache needs to work, so we return the stub item for + // cookie_ keys but a real working item for nonce keys. + $pool->method('getItem')->willReturnCallback(function (string $key) use ($item) { + if (str_starts_with($key, 'cookie_')) { + return $item; // collision + } + // For nonce keys, return a real item from an ArrayAdapter + static $realPool = null; + $realPool ??= new \Symfony\Component\Cache\Adapter\ArrayAdapter(); + return $realPool->getItem($key); + }); + $pool->method('hasItem')->willReturnCallback(function (string $key) use ($item) { + if (str_starts_with($key, 'cookie_')) { + return true; + } + static $realPool = null; + $realPool ??= new \Symfony\Component\Cache\Adapter\ArrayAdapter(); + return $realPool->hasItem($key); + }); + $pool->method('save')->willReturn(true); + $pool->method('saveDeferred')->willReturn(true); + $pool->method('commit')->willReturn(true); + $pool->method('getItems')->willReturnCallback(function (array $keys) { + static $realPool = null; + $realPool ??= new \Symfony\Component\Cache\Adapter\ArrayAdapter(); + return $realPool->getItems($keys); + }); + $pool->method('clear')->willReturn(true); + $pool->method('deleteItem')->willReturn(true); + $pool->method('deleteItems')->willReturn(true); + + $this->domainManager = new DomainManager(false, ''); + $this->backupCodeManager = $this->createStub(BackupCodeInterface::class); + $this->backupCodeManager->method('verifyAndConsume')->willReturn(false); + + $manager = new LoginManager($pool, $this->backupCodeManager, $this->domainManager); + $manager->setConfig($this->makeConfig()); + $manager->setLogger(new NullLogger()); + $manager->setNonceCache(new \Symfony\Component\Cache\Adapter\ArrayAdapter()); + + $payload = new Payload(); + $payload->id = 'collide-user'; + $payload->token = $this->validTotpCode(); + $payload->nonce = 'test-nonce-123'; + $payload->json = true; + $payload->scope = Scope::Cookie; + + // inject the nonce + $this->insertNonce($manager, 'test-nonce-123'); + + $request = Request::create('/', 'GET'); + + $this->expectException(HttpException::class); + $manager->checkToken($payload, $request); + } + + public function testCookieScopeWithCentralAuthSetsDomainOnMatchingHost(): void { + $manager = $this->makeLoginManager( + subdomainRedirect: true, + authSubdomain: 'auth.example.com', + ); + $payload = $this->makePayloadWithNonce($manager, id: 'alice', scope: Scope::Cookie); + + $this->backupCodeManager->method('verifyAndConsume')->willReturn(false); + + // host matches the auth base domain + $request = Request::create('https://auth.example.com/', 'GET'); + + $response = $manager->checkToken($payload, $request); + + self::assertNotNull($response); + $cookies = $response->headers->getCookies(); + self::assertCount(1, $cookies); + // when using central auth and host matches, the cookie domain is set + self::assertSame('example.com', $cookies[0]->getDomain()); + // the auth cookie name is used instead of the host-prefixed name + self::assertSame('__Http-Domain-Preauth', $cookies[0]->getName()); + } + + public function testCookieScopeWithCentralAuthOnNonMatchingHostUsesNullDomain(): void { + $manager = $this->makeLoginManager( + subdomainRedirect: true, + authSubdomain: 'auth.example.com', + ); + $payload = $this->makePayloadWithNonce($manager, id: 'bob', scope: Scope::Cookie); + + $this->backupCodeManager->method('verifyAndConsume')->willReturn(false); + + // host does NOT match the auth base domain + $request = Request::create('https://other.com/', 'GET'); + + $response = $manager->checkToken($payload, $request); + + self::assertNotNull($response); + $cookies = $response->headers->getCookies(); + self::assertCount(1, $cookies); + // domain is null when host does not match + self::assertNull($cookies[0]->getDomain()); + // still uses auth cookie name since authBase is set + self::assertSame('__Http-Domain-Preauth', $cookies[0]->getName()); + } + + public function testCheckTokenWithEmptyReturnParameterFallsBackToPath(): void { + $manager = $this->makeLoginManager(); + $payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie); + + $this->backupCodeManager->method('verifyAndConsume')->willReturn(false); + + // return parameter is present but empty string + $request = Request::create('/?return=', 'GET'); + + $response = $manager->checkToken($payload, $request); + + self::assertNotNull($response); + $location = $response->headers->get('Location'); + self::assertNotNull($location); + // should fall back to path since empty string is not a valid URL + self::assertStringStartsWith('/', $location); + } +} diff --git a/tests/Unit/Trait/CookieNameTraitTest.php b/tests/Unit/Trait/CookieNameTraitTest.php new file mode 100644 index 0000000..cf809e9 --- /dev/null +++ b/tests/Unit/Trait/CookieNameTraitTest.php @@ -0,0 +1,23 @@ +cookieName()); + } + + public function testAuthCookieName(): void { + self::assertSame('__Http-Domain-Preauth', $this->authCookieName()); + } + + public function testHeaderName(): void { + self::assertSame('X-Preauth', $this->headerName()); + } +} diff --git a/tests/Unit/Trait/GetTotpTraitTest.php b/tests/Unit/Trait/GetTotpTraitTest.php new file mode 100644 index 0000000..03d0937 --- /dev/null +++ b/tests/Unit/Trait/GetTotpTraitTest.php @@ -0,0 +1,90 @@ +getTotp(); + } + }; + } + + public function testSetConfigSetsProperty(): void { + $obj = $this->makeObject(); + $config = $this->makeConfig(); + + $obj->setConfig($config); + + $reflection = new \ReflectionProperty($obj, 'config'); + self::assertSame($config, $reflection->getValue($obj)); + } + + public function testGetTotpReturnsTotpInterface(): void { + $obj = $this->makeObject(); + $obj->setConfig($this->makeConfig()); + + $totp = $obj->publicGetTotp(); + + self::assertInstanceOf(TOTPInterface::class, $totp); + } + + public function testGetTotpReturnsValidCode(): void { + $obj = $this->makeObject(); + $obj->setConfig($this->makeConfig()); + + $totp = $obj->publicGetTotp(); + + // the code at the frozen time should match our helper + self::assertSame($this->validTotpCode(), $totp->now()); + } + + 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' + ); + $obj->setConfig($config); + + // Factory::loadFromProvisioningUri throws InvalidProvisioningUriException + // which is not caught by getTotp() since the instanceof check only runs + // after a successful load — so we expect a Throwable here + $this->expectException(\Throwable::class); + $obj->publicGetTotp(); + } + + 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' + ); + $obj->setConfig($config); + + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Internal Server Exception'); + $obj->publicGetTotp(); + } +} diff --git a/tests/Unit/Trait/HasLoggerTraitTest.php b/tests/Unit/Trait/HasLoggerTraitTest.php new file mode 100644 index 0000000..ca480d5 --- /dev/null +++ b/tests/Unit/Trait/HasLoggerTraitTest.php @@ -0,0 +1,18 @@ +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 new file mode 100644 index 0000000..f0fcd11 --- /dev/null +++ b/tests/Unit/Trait/MakeNonceTraitTest.php @@ -0,0 +1,176 @@ +makeNonce($retries); + } + + public function publicMakeCacheKey(string $name): string { + return $this->makeCacheKey($name); + } + }; + } + + public function testMakeNonceReturnsBase64UrlString(): void { + $obj = $this->makeObject(); + $obj->setLogger(new NullLogger()); + $obj->setNonceCache(new ArrayAdapter()); + + $nonce = $obj->publicMakeNonce(); + + self::assertIsString($nonce); + // 15 bytes -> 20 base64 chars without padding + self::assertSame(20, strlen($nonce)); + // base64url charset only + self::assertMatchesRegularExpression('/^[A-Za-z0-9_-]+$/', $nonce); + } + + public function testMakeNonceStoresNonceInCache(): void { + $pool = new ArrayAdapter(); + $obj = $this->makeObject(); + $obj->setLogger(new NullLogger()); + $obj->setNonceCache($pool); + + $nonce = $obj->publicMakeNonce(); + + // makeNonce stores via makeCacheKey() which rewrites '-' to '_' + $key = $obj->publicMakeCacheKey($nonce); + self::assertTrue($pool->hasItem($key)); + $item = $pool->getItem($key); + self::assertTrue($item->get()); + } + + public function testMakeNonceSetsExpiry(): void { + $pool = new ArrayAdapter(); + $obj = $this->makeObject(); + $obj->setLogger(new NullLogger()); + $obj->setNonceCache($pool); + + $nonce = $obj->publicMakeNonce(); + + $item = $pool->getItem($obj->publicMakeCacheKey($nonce)); + $expiry = $item->getMetadata()['expiry']; + // NONCE_TTL is 120 seconds + self::assertLessThanOrEqual(120, (int) $expiry - time()); + self::assertGreaterThan(time(), (int) $expiry); + } + + public function testTwoNoncesAreDifferent(): void { + $pool = new ArrayAdapter(); + $obj = $this->makeObject(); + $obj->setLogger(new NullLogger()); + $obj->setNonceCache($pool); + + $nonce1 = $obj->publicMakeNonce(); + $nonce2 = $obj->publicMakeNonce(); + + self::assertNotSame($nonce1, $nonce2); + } + + 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); + $item->method('isHit')->willReturn(true); + $item->method('get')->willReturn(true); + $pool->method('getItem')->willReturn($item); + $pool->method('save')->willReturn(true); + + $obj = $this->makeObject(); + $obj->setLogger(new NullLogger()); + $obj->setNonceCache($pool); + + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Internal Server Error'); + + $obj->publicMakeNonce(); + } + + 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 { + private int $hits = 0; + public function __construct( + private CacheItemPoolInterface $inner, + private int &$hitCounter, + ) {} + + 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 $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(); } + }; + + $obj = $this->makeObject(); + $obj->setLogger(new NullLogger()); + $obj->setNonceCache($spyPool); + + // should retry and succeed on the second attempt + $nonce = $obj->publicMakeNonce(); + self::assertIsString($nonce); + self::assertSame(20, strlen($nonce)); + self::assertSame(1, $collisionCount, 'Expected exactly one collision before success'); + } + + public function testMakeNonceThrowsImmediatelyWithZeroRetries(): void { + $pool = $this->createStub(CacheItemPoolInterface::class); + $item = $this->createStub(CacheItemInterface::class); + $item->method('isHit')->willReturn(true); + $item->method('get')->willReturn(true); + $pool->method('getItem')->willReturn($item); + $pool->method('save')->willReturn(true); + + $obj = $this->makeObject(); + $obj->setLogger(new NullLogger()); + $obj->setNonceCache($pool); + + $this->expectException(HttpException::class); + $obj->publicMakeNonce(0); + } +} diff --git a/tests/Unit/Trait/StringTraitTest.php b/tests/Unit/Trait/StringTraitTest.php new file mode 100644 index 0000000..03c5bb7 --- /dev/null +++ b/tests/Unit/Trait/StringTraitTest.php @@ -0,0 +1,60 @@ +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 { + self::assertSame('ABC_123.abc', $this->makeCacheKey('ABC_123.abc')); + } + + public function testMakeCacheKeyTruncatesLongNames(): void { + $long = str_repeat('a', 300); + $result = $this->makeCacheKey($long); + self::assertSame(128, mb_strlen($result)); + } + + public function testMakeCacheKeyEmptyString(): void { + self::assertSame('', $this->makeCacheKey('')); + } + + public function testMakeCacheKeyWithOnlyInvalidChars(): void { + // preg_replace with + collapses consecutive invalid chars into one _ + self::assertSame('_', $this->makeCacheKey('!!!')); + self::assertSame('_', $this->makeCacheKey(' ')); + self::assertSame('_', $this->makeCacheKey('!@#')); + self::assertSame('_', $this->makeCacheKey('!@ #')); + } + + public function testMakeCacheKeyTruncatesToExactly128(): void { + $input = str_repeat('a', 128); + self::assertSame(128, mb_strlen($this->makeCacheKey($input))); + self::assertSame($input, $this->makeCacheKey($input)); + + $input129 = str_repeat('a', 129); + self::assertSame(128, mb_strlen($this->makeCacheKey($input129))); + } + + 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 { + $result = $this->makeCacheKey('a🎉b'); + self::assertSame('a_b', $result); + } +} diff --git a/tests/Unit/UtilitiesTest.php b/tests/Unit/UtilitiesTest.php new file mode 100644 index 0000000..c50b76c --- /dev/null +++ b/tests/Unit/UtilitiesTest.php @@ -0,0 +1,69 @@ +createStub(ClockInterface::class); + return new Utilities($clock, $pool); + } + + public function testLoadTotpReturnsCachedValueWhenPresent(): void { + $pool = new ArrayAdapter(); + $item = $pool->getItem('totp'); + $item->set('otpauth://totp/cached?secret=ABCDEFGH'); + $pool->save($item); + + $utilities = $this->makeUtilities($pool); + + $result = $utilities->loadTotp(); + + self::assertSame('otpauth://totp/cached?secret=ABCDEFGH', $result); + } + + public function testLoadTotpGeneratesAndStoresWhenMissing(): void { + $pool = new ArrayAdapter(); + $utilities = $this->makeUtilities($pool); + + $result = $utilities->loadTotp(); + + self::assertNotEmpty($result); + self::assertStringStartsWith('otpauth://totp/', $result); + + // stored in cache for next boot + $cached = $pool->getItem('totp'); + self::assertTrue($cached->isHit()); + self::assertSame($result, $cached->get()); + } + + public function testLoadTotpSetsFarFutureExpiry(): void { + $pool = new ArrayAdapter(); + $utilities = $this->makeUtilities($pool); + + $utilities->loadTotp(); + + $cached = $pool->getItem('totp'); + $expiry = $cached->getMetadata()['expiry']; + // 2999-12-31 is well in the future, far beyond any reasonable test timestamp + self::assertGreaterThan((new \DateTimeImmutable('+10 years'))->getTimestamp(), (int) $expiry); + } + + public function testLoadTotpIsIdempotentAfterGeneration(): void { + $pool = new ArrayAdapter(); + $utilities = $this->makeUtilities($pool); + + $first = $utilities->loadTotp(); + + // second call should find it in cache and return the same value + $second = $utilities->loadTotp(); + + self::assertSame($first, $second); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..e16021e --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,13 @@ +bootEnv(dirname(__DIR__).'/.env.test'); +} + +if (!isset($_SERVER['APP_DEBUG'])) { + $_SERVER['APP_DEBUG'] = false; +}