diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..3242279 --- /dev/null +++ b/.env.test @@ -0,0 +1,26 @@ +APP_ENV=test +APP_DEBUG=0 +APP_SECRET=test_secret_key_change_me +TOTP_URI='' +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/.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/phpunit.dist.xml b/phpunit.dist.xml new file mode 100644 index 0000000..db201ce --- /dev/null +++ b/phpunit.dist.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + tests + + + + + + src + + + + trigger_deprecation + + + + + + 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/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/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/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..0647f49 --- /dev/null +++ b/tests/Unit/Data/PayloadTest.php @@ -0,0 +1,152 @@ + '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 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 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 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/MonitorCacheKeysTest.php b/tests/Unit/MonitorCacheKeysTest.php new file mode 100644 index 0000000..8b2dfaa --- /dev/null +++ b/tests/Unit/MonitorCacheKeysTest.php @@ -0,0 +1,212 @@ +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']); + } +} diff --git a/tests/Unit/PersistCacheTest.php b/tests/Unit/PersistCacheTest.php new file mode 100644 index 0000000..eea8ba8 --- /dev/null +++ b/tests/Unit/PersistCacheTest.php @@ -0,0 +1,153 @@ +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()); + } +} diff --git a/tests/Unit/Service/BackupCodeManagerTest.php b/tests/Unit/Service/BackupCodeManagerTest.php new file mode 100644 index 0000000..6cca98a --- /dev/null +++ b/tests/Unit/Service/BackupCodeManagerTest.php @@ -0,0 +1,171 @@ +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))); + } + } +} diff --git a/tests/Unit/Service/DomainManagerTest.php b/tests/Unit/Service/DomainManagerTest.php new file mode 100644 index 0000000..449098d --- /dev/null +++ b/tests/Unit/Service/DomainManagerTest.php @@ -0,0 +1,145 @@ +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')); + } +} diff --git a/tests/Unit/Service/LoginManagerTest.php b/tests/Unit/Service/LoginManagerTest.php new file mode 100644 index 0000000..f87ff08 --- /dev/null +++ b/tests/Unit/Service/LoginManagerTest.php @@ -0,0 +1,296 @@ +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()); + } +} 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..278bf10 --- /dev/null +++ b/tests/Unit/Trait/GetTotpTraitTest.php @@ -0,0 +1,72 @@ +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(); + } +} 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..a8aa464 --- /dev/null +++ b/tests/Unit/Trait/MakeNonceTraitTest.php @@ -0,0 +1,101 @@ +makeNonce($retries); + } + }; + } + + 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(); + + self::assertTrue($pool->hasItem($nonce)); + $item = $pool->getItem($nonce); + 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($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(); + } +} diff --git a/tests/Unit/Trait/StringTraitTest.php b/tests/Unit/Trait/StringTraitTest.php new file mode 100644 index 0000000..68b32be --- /dev/null +++ b/tests/Unit/Trait/StringTraitTest.php @@ -0,0 +1,31 @@ +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('')); + } +} 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; +}