diff --git a/.gitignore b/.gitignore index f2bad8f..75ca991 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ /vendor/ ###< symfony/framework-bundle ### +###> phpunit/phpunit ### +/phpunit.xml +/.phpunit.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 +=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.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "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.7.0" + }, + "time": "2025-12-06T11:56:16+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.1.10", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "3719c5b6c045761798238ebacfee1fe06e4ce5be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/3719c5b6c045761798238ebacfee1fe06e4ce5be", + "reference": "3719c5b6c045761798238ebacfee1fe06e4ce5be", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.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.1.13" + }, + "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.1.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.1.10" + }, + "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-06-01T13:26:42+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.1.14", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "cdd419c33c040c6b570e51dba8ecbe81d399da53" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/cdd419c33c040c6b570e51dba8ecbe81d399da53", + "reference": "cdd419c33c040c6b570e51dba8ecbe81d399da53", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "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.1.10", + "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.2.1", + "sebastian/diff": "^8.3.0", + "sebastian/environment": "^9.3.2", + "sebastian/exporter": "^8.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.1-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.1.14" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-06-04T06:16:42+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.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "ce999bf08b2c387a5423fe56961c32eed3f88089" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/ce999bf08b2c387a5423fe56961c32eed3f88089", + "reference": "ce999bf08b2c387a5423fe56961c32eed3f88089", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/diff": "^8.3", + "sebastian/exporter": "^8.0.3" + }, + "require-dev": { + "phpunit/phpunit": "^13.1.10" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.2-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.2.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/comparator", + "type": "tidelift" + } + ], + "time": "2026-05-21T04:46:40+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": "8.3.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b36d33b6e796513de7cb7df053afb3f55eefcd47" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b36d33b6e796513de7cb7df053afb3f55eefcd47", + "reference": "b36d33b6e796513de7cb7df053afb3f55eefcd47", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0", + "symfony/process": "^7.2" + }, + "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": "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/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/diff", + "type": "tidelift" + } + ], + "time": "2026-05-15T04:58:09+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.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "c0d29a945f8cf82f300a05e69874508e307ca4c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c0d29a945f8cf82f300a05e69874508e307ca4c6", + "reference": "c0d29a945f8cf82f300a05e69874508e307ca4c6", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.1.10" + }, + "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.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/exporter", + "type": "tidelift" + } + ], + "time": "2026-05-21T11:50:56+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.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d2cff273a90c79b0eb590baa682d4b5c318bdbb7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d2cff273a90c79b0eb590baa682d4b5c318bdbb7", + "reference": "d2cff273a90c79b0eb590baa682d4b5c318bdbb7", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.7.0", + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.1.10" + }, + "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.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/lines-of-code", + "type": "tidelift" + } + ], + "time": "2026-05-19T16:23:37+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/phpunit-bridge", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/phpunit-bridge.git", + "reference": "1fed488f8033f2dece371e60a1c66f2add274916" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/1fed488f8033f2dece371e60a1c66f2add274916", + "reference": "1fed488f8033f2dece371e60a1c66f2add274916", + "shasum": "" + }, + "require": { + "php": ">=8.1.0" + }, + "require-dev": { + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4.3|^7.0.3|^8.0" + }, + "bin": [ + "bin/simple-phpunit" + ], + "type": "symfony-bridge", + "extra": { + "thanks": { + "url": "https://github.com/sebastianbergmann/phpunit", + "name": "phpunit/phpunit" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Bridge\\PhpUnit\\": "" + }, + "exclude-from-classmap": [ + "/Tests/", + "/bin/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides utilities for PHPUnit, especially user deprecation notices management", + "homepage": "https://symfony.com", + "keywords": [ + "testing" + ], + "support": { + "source": "https://github.com/symfony/phpunit-bridge/tree/v8.1.0" + }, + "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-29T05:06:50+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": {}, diff --git a/config/reference.php b/config/reference.php index 515b64f..daa8a0f 100644 --- a/config/reference.php +++ b/config/reference.php @@ -128,7 +128,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * @psalm-type FrameworkConfig = array{ * secret?: scalar|Param|null, * http_method_override?: bool|Param, // Set true to enable support for the '_method' request parameter to determine the intended HTTP method on POST requests. // Default: false - * allowed_http_method_override?: list|null, + * allowed_http_method_override?: null|list, * trust_x_sendfile_type_header?: scalar|Param|null, // Set true to enable support for xsendfile in binary file responses. // Default: "%env(bool:default::SYMFONY_TRUST_X_SENDFILE_TYPE_HEADER)%" * ide?: scalar|Param|null, // Default: "%env(default::SYMFONY_IDE)%" * test?: bool|Param, @@ -136,9 +136,9 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * set_locale_from_accept_language?: bool|Param, // Whether to use the Accept-Language HTTP header to set the Request locale (only when the "_locale" request attribute is not passed). // Default: false * set_content_language_from_locale?: bool|Param, // Whether to set the Content-Language HTTP header on the Response using the Request locale. // Default: false * enabled_locales?: list, - * trusted_hosts?: list, + * trusted_hosts?: string|list, * trusted_proxies?: mixed, // Default: ["%env(default::SYMFONY_TRUSTED_PROXIES)%"] - * trusted_headers?: list, + * trusted_headers?: string|list, * error_controller?: scalar|Param|null, // Default: "error_controller" * handle_all_throwables?: bool|Param, // HttpKernel will handle all kinds of \Throwable. // Default: true * csrf_protection?: bool|array{ @@ -202,23 +202,23 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * property?: scalar|Param|null, * service?: scalar|Param|null, * }, - * supports?: list, + * supports?: string|list, * definition_validators?: list, * support_strategy?: scalar|Param|null, - * initial_marking?: list, - * events_to_dispatch?: list|null, - * places?: list, + * events_to_dispatch?: null|list, + * places?: string|list, * }>, * transitions?: list, - * to?: list, @@ -271,7 +271,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * version_format?: scalar|Param|null, // Default: "%%s?%%s" * json_manifest_path?: scalar|Param|null, // Default: null * base_path?: scalar|Param|null, // Default: "" - * base_urls?: list, + * base_urls?: string|list, * packages?: array, + * base_urls?: string|list, * }>, * }, * asset_mapper?: bool|array{ // Asset Mapper configuration * enabled?: bool|Param, // Default: false - * paths?: array, + * paths?: string|array, * excluded_patterns?: list, * exclude_dotfiles?: bool|Param, // If true, any files starting with "." will be excluded from the asset mapper. // Default: true * server?: bool|Param, // If true, a "dev server" will return the assets from the public directory (true in "debug" mode only by default). // Default: true @@ -303,7 +303,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * }, * translator?: bool|array{ // Translator configuration * enabled?: bool|Param, // Default: false - * fallbacks?: list, + * fallbacks?: string|list, * logging?: bool|Param, // Default: false * formatter?: scalar|Param|null, // Default: "translator.formatter.default" * cache_dir?: scalar|Param|null, // Default: "%kernel.cache_dir%/translations" @@ -333,7 +333,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * enabled?: bool|Param, // Default: false * cache?: scalar|Param|null, // Deprecated: Setting the "framework.validation.cache.cache" configuration option is deprecated. It will be removed in version 8.0. * enable_attributes?: bool|Param, // Default: true - * static_method?: list, + * static_method?: string|list, * translation_domain?: scalar|Param|null, // Default: "validators" * email_validation_mode?: "html5"|"html5-allow-no-tld"|"strict"|"loose"|Param, // Default: "html5" * mapping?: array{ @@ -396,7 +396,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * default_doctrine_dbal_provider?: scalar|Param|null, // Default: "database_connection" * default_pdo_provider?: scalar|Param|null, // Default: null * pools?: array, + * adapters?: string|list, * tags?: scalar|Param|null, // Default: null * public?: bool|Param, // Default: false * default_lifetime?: scalar|Param|null, // Default lifetime of the pool. @@ -419,11 +419,11 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * }, * lock?: bool|string|array{ // Lock configuration * enabled?: bool|Param, // Default: false - * resources?: array>, + * resources?: string|array>, * }, * semaphore?: bool|string|array{ // Semaphore configuration * enabled?: bool|Param, // Default: false - * resources?: array, + * resources?: string|array, * }, * messenger?: bool|array{ // Messenger configuration * enabled?: bool|Param, // Default: false @@ -453,7 +453,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * rate_limiter?: scalar|Param|null, // Rate limiter name to use when processing messages. // Default: null * }>, * failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null - * stop_worker_on_signals?: list, + * stop_worker_on_signals?: int|string|list, * default_bus?: scalar|Param|null, // Default: null * buses?: array, * }>, @@ -510,9 +510,9 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * retry_failed?: bool|array{ * enabled?: bool|Param, // Default: false * retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null - * http_codes?: array, + * methods?: string|list, * }>, * max_retries?: int|Param, // Default: 3 * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 @@ -563,9 +563,9 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * retry_failed?: bool|array{ * enabled?: bool|Param, // Default: false * retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null - * http_codes?: array, + * methods?: string|list, * }>, * max_retries?: int|Param, // Default: 3 * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 @@ -582,8 +582,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * transports?: array, * envelope?: array{ // Mailer Envelope configuration * sender?: scalar|Param|null, - * recipients?: list, - * allowed_recipients?: list, + * recipients?: string|list, + * allowed_recipients?: string|list, * }, * headers?: array, + * limiters?: string|list, * limit?: int|Param, // The maximum allowed hits in a fixed interval or burst. * interval?: scalar|Param|null, // Configures the fixed interval if "policy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent). * rate?: array{ // Configures the fill rate if "policy" is set to "token_bucket". @@ -658,20 +658,20 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * allow_safe_elements?: bool|Param, // Allows "safe" elements and attributes. // Default: false * allow_static_elements?: bool|Param, // Allows all static elements and attributes from the W3C Sanitizer API standard. // Default: false * allow_elements?: array, - * block_elements?: list, - * drop_elements?: list, + * block_elements?: string|list, + * drop_elements?: string|list, * allow_attributes?: array, * drop_attributes?: array, * force_attributes?: array>, * force_https_urls?: bool|Param, // Transforms URLs using the HTTP scheme to use the HTTPS scheme instead. // Default: false - * allowed_link_schemes?: list, - * allowed_link_hosts?: list|null, + * allowed_link_schemes?: string|list, + * allowed_link_hosts?: null|string|list, * allow_relative_links?: bool|Param, // Allows relative URLs to be used in links href attributes. // Default: false - * allowed_media_schemes?: list, - * allowed_media_hosts?: list|null, + * allowed_media_schemes?: string|list, + * allowed_media_hosts?: null|string|list, * allow_relative_medias?: bool|Param, // Allows relative URLs to be used in media source attributes (img, audio, video, ...). // Default: false - * with_attribute_sanitizers?: list, - * without_attribute_sanitizers?: list, + * with_attribute_sanitizers?: string|list, + * without_attribute_sanitizers?: string|list, * max_input_length?: int|Param, // The maximum length allowed for the sanitized input. // Default: 0 * }>, * }, @@ -707,7 +707,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * auto_reload?: scalar|Param|null, * optimizations?: int|Param, * default_path?: scalar|Param|null, // The default path used to load templates. // Default: "%kernel.project_dir%/templates" - * file_name_pattern?: list, + * file_name_pattern?: string|list, * paths?: array, * date?: array{ // The default format options used by the date filter. * format?: scalar|Param|null, // Default: "F j, Y H:i" diff --git a/phpunit.dist.xml b/phpunit.dist.xml new file mode 100644 index 0000000..07d5366 --- /dev/null +++ b/phpunit.dist.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + tests + + + + + + src + + + + trigger_deprecation + + + + + + + + + + diff --git a/symfony.lock b/symfony.lock index 40bce60..0be09fb 100644 --- a/symfony.lock +++ b/symfony.lock @@ -1,4 +1,19 @@ { + "phpunit/phpunit": { + "version": "13.1", + "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": { @@ -44,6 +59,15 @@ ".editorconfig" ] }, + "symfony/phpunit-bridge": { + "version": "8.1", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.3", + "ref": "56f87409c45ff6654b0bf07c5cc359aebf6016ab" + } + }, "symfony/routing": { "version": "7.4", "recipe": { diff --git a/tests/Unit/Command/GenerateBackupCodesCommandTest.php b/tests/Unit/Command/GenerateBackupCodesCommandTest.php new file mode 100644 index 0000000..8f607d8 --- /dev/null +++ b/tests/Unit/Command/GenerateBackupCodesCommandTest.php @@ -0,0 +1,122 @@ +createMock(CacheItemInterface::class); + $item->method('getKey')->willReturn($key); + $item->method('get')->willReturn($value); + $item->method('isHit')->willReturn($isHit); + $item->method('set')->willReturnSelf(); + $item->method('expiresAt')->willReturnSelf(); + return $item; + } + + private function createMockPool(): CacheItemPoolInterface { + $pool = $this->createMock(CacheItemPoolInterface::class); + $keyListItem = $this->createMockItem('__key_list', [], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + + $pool->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + $pool->method('getItem') + ->willReturnCallback(function ($key) use ($keyListItem, $changeListItem) { + return match ($key) { + '__key_list' => $keyListItem, + '__chg_list' => $changeListItem, + default => $this->createMockItem($key, null, false), + }; + }); + $pool->method('saveDeferred')->willReturn(true); + $pool->method('commit')->willReturn(true); + $pool->method('save')->willReturn(true); + return $pool; + } + + private function createBackupManager(): BackupCodeManager { + $clock = $this->createMock(ClockInterface::class); + $cache = $this->createMock(CacheItemPoolInterface::class); + $utilities = new Utilities($clock, $cache); + $totp = TOTP::generate($clock); + $totp->setLabel('Test'); + + $config = new ConfigBag( + $utilities, + $clock, + 3600, + $totp->getProvisioningUri(), + 1800, + false, + 'Error', + 'Teapot', + 'Too Many' + ); + + $pool = $this->createMockPool(); + $manager = new BackupCodeManager($pool); + $manager->setConfig($config); + $manager->setLogger($this->createMock(LoggerInterface::class)); + return $manager; + } + + private function createPersistCache(): PersistCache { + $sessionCache = new MonitorCacheKeys($this->createMockPool()); + $sessionStorage = $this->createMockPool(); + return new PersistCache($sessionCache, $sessionStorage); + } + + public function testExecuteWithDefaultCount(): void { + $backupManager = $this->createBackupManager(); + $persistCache = $this->createPersistCache(); + + $command = new GenerateBackupCodesCommand($backupManager, $persistCache); + + $tester = new CommandTester($command); + $tester->execute([]); + + self::assertSame(0, $tester->getStatusCode()); + $output = $tester->getDisplay(); + self::assertStringContainsString('Generated', $output); + } + + public function testExecuteWithCustomCount(): void { + $backupManager = $this->createBackupManager(); + $persistCache = $this->createPersistCache(); + + $command = new GenerateBackupCodesCommand($backupManager, $persistCache); + + $tester = new CommandTester($command); + $tester->execute(['count' => 5]); + + self::assertSame(0, $tester->getStatusCode()); + } + + public function testExecuteWithZeroCount(): void { + $backupManager = $this->createBackupManager(); + $persistCache = $this->createPersistCache(); + + $command = new GenerateBackupCodesCommand($backupManager, $persistCache); + + $tester = new CommandTester($command); + $tester->execute(['count' => 0]); + + self::assertSame(0, $tester->getStatusCode()); + } +} diff --git a/tests/Unit/Core/ClockTest.php b/tests/Unit/Core/ClockTest.php new file mode 100644 index 0000000..3d3fe73 --- /dev/null +++ b/tests/Unit/Core/ClockTest.php @@ -0,0 +1,29 @@ +now(); + $after = new \DateTimeImmutable(); + + self::assertInstanceOf(\DateTimeImmutable::class, $now); + self::assertGreaterThanOrEqual($before->getTimestamp(), $now->getTimestamp()); + self::assertLessThanOrEqual($after->getTimestamp(), $now->getTimestamp()); + } + + public function testNowReturnsDifferentInstances(): void { + $clock = new Clock(); + $first = $clock->now(); + usleep(1000); + $second = $clock->now(); + + self::assertNotSame($first, $second); + } +} diff --git a/tests/Unit/Core/ConfigBagTest.php b/tests/Unit/Core/ConfigBagTest.php new file mode 100644 index 0000000..8e52ca8 --- /dev/null +++ b/tests/Unit/Core/ConfigBagTest.php @@ -0,0 +1,113 @@ +createMock(ClockInterface::class); + $cache = $this->createMock(CacheItemPoolInterface::class); + + if ($totp !== null) { + $item = $this->createMock(CacheItemInterface::class); + $item->method('isHit')->willReturn(true); + $item->method('get')->willReturn($totp); + $cache->method('hasItem')->with('totp')->willReturn(true); + $cache->method('getItem')->with('totp')->willReturn($item); + } else { + $cache->method('hasItem')->with('totp')->willReturn(false); + } + + return new Utilities($clock, $cache); + } + + public function testGettersWithExplicitValues(): void { + $clock = $this->createMock(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->createMock(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->createMock(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->createMock(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/Core/MonitorCacheKeysTest.php b/tests/Unit/Core/MonitorCacheKeysTest.php new file mode 100644 index 0000000..f262970 --- /dev/null +++ b/tests/Unit/Core/MonitorCacheKeysTest.php @@ -0,0 +1,347 @@ +createMock(CacheItemPoolInterface::class); + } + + private function createMockItem(string $key, mixed $value = null, bool $isHit = true): CacheItemInterface { + $item = $this->createMock(CacheItemInterface::class); + $item->method('getKey')->willReturn($key); + $item->method('get')->willReturn($value); + $item->method('isHit')->willReturn($isHit); + return $item; + } + + public function testConstructorInitializesWhenKeyListMissing(): void { + $pool = $this->createMockPool(); + $keyListItem = $this->createMockItem('__key_list', null, false); + $changeListItem = $this->createMockItem('__chg_list', [], true); + + $pool->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + + $pool->expects(self::exactly(2)) + ->method('saveDeferred') + ->willReturnCallback(function ($item) { + self::assertContains($item->getKey(), ['__key_list', '__chg_list']); + return true; + }); + $pool->expects(self::once())->method('commit')->willReturn(true); + + new MonitorCacheKeys($pool); + } + + public function testConstructorDoesNotInitializeWhenItemsExist(): void { + $pool = $this->createMockPool(); + $keyListItem = $this->createMockItem('__key_list', [], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + + $pool->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + + $pool->expects(self::never())->method('saveDeferred'); + $pool->expects(self::never())->method('commit'); + + new MonitorCacheKeys($pool); + } + + public function testGetKeys(): void { + $pool = $this->createMockPool(); + $keyListItem = $this->createMockItem('__key_list', ['key1' => true, 'key2' => true], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + + $pool->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + $pool->method('getItem') + ->with('__key_list') + ->willReturn($keyListItem); + + $monitor = new MonitorCacheKeys($pool); + self::assertSame(['key1', 'key2'], $monitor->getKeys()); + } + + public function testGetChanges(): void { + $pool = $this->createMockPool(); + $keyListItem = $this->createMockItem('__key_list', [], true); + $changeListItem = $this->createMockItem('__chg_list', ['key1' => MonitorCacheKeys::UPDATED], true); + + $pool->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + $pool->method('getItem') + ->with('__chg_list') + ->willReturn($changeListItem); + + $monitor = new MonitorCacheKeys($pool); + self::assertSame(['key1' => MonitorCacheKeys::UPDATED], $monitor->getChanges()); + } + + public function testMarkClean(): void { + $pool = $this->createMockPool(); + $keyListItem = $this->createMockItem('__key_list', [], true); + $changeListItem = $this->createMockItem('__chg_list', ['key1' => MonitorCacheKeys::UPDATED], true); + + $pool->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + $pool->method('getItem') + ->with('__chg_list') + ->willReturn($changeListItem); + $pool->expects(self::once()) + ->method('save') + ->with(self::callback(function ($item) { + return $item->getKey() === '__chg_list' && $item->get() === []; + })) + ->willReturn(true); + + $monitor = new MonitorCacheKeys($pool); + $monitor->markClean(); + } + + public function testGetItemDelegates(): void { + $pool = $this->createMockPool(); + $keyListItem = $this->createMockItem('__key_list', [], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + $requestedItem = $this->createMockItem('mykey', 'value', true); + + $pool->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + $pool->method('getItem') + ->with('mykey') + ->willReturn($requestedItem); + + $monitor = new MonitorCacheKeys($pool); + self::assertSame($requestedItem, $monitor->getItem('mykey')); + } + + public function testGetItemsDelegates(): void { + $pool = $this->createMockPool(); + $keyListItem = $this->createMockItem('__key_list', [], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + + $pool->method('getItems') + ->willReturnCallback(function ($keys) { + return array_map(fn($k) => $this->createMockItem($k, null, true), $keys); + }); + + $monitor = new MonitorCacheKeys($pool); + $items = iterator_to_array($monitor->getItems(['a', 'b'])); + self::assertCount(2, $items); + } + + public function testHasItemDelegates(): void { + $pool = $this->createMockPool(); + $keyListItem = $this->createMockItem('__key_list', [], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + + $pool->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + $pool->method('hasItem') + ->with('mykey') + ->willReturn(true); + + $monitor = new MonitorCacheKeys($pool); + self::assertTrue($monitor->hasItem('mykey')); + } + + public function testClearWhenNotEmpty(): void { + $pool = $this->createMockPool(); + $keyListItem = $this->createMockItem('__key_list', ['key1' => true], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + + $pool->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + $pool->method('getItem') + ->with('__key_list') + ->willReturn($keyListItem); + $pool->expects(self::once())->method('clear')->willReturn(true); + $pool->expects(self::exactly(2))->method('saveDeferred')->willReturn(true); + $pool->expects(self::once())->method('commit')->willReturn(true); + + $monitor = new MonitorCacheKeys($pool); + self::assertTrue($monitor->clear()); + } + + public function testClearWhenEmpty(): void { + $pool = $this->createMockPool(); + $keyListItem = $this->createMockItem('__key_list', [], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + + $pool->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + $pool->method('getItem') + ->with('__key_list') + ->willReturn($keyListItem); + $pool->expects(self::never())->method('clear'); + + $monitor = new MonitorCacheKeys($pool); + self::assertTrue($monitor->clear()); + } + + public function testDeleteItemUpdatesKeyList(): void { + $pool = $this->createMockPool(); + $keyListItem = $this->createMockItem('__key_list', ['mykey' => true], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + + $pool->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + $pool->method('getItem') + ->willReturnCallback(function ($key) use ($keyListItem, $changeListItem) { + return match ($key) { + '__key_list' => $keyListItem, + '__chg_list' => $changeListItem, + default => $this->createMockItem($key, null, false), + }; + }); + $pool->expects(self::once())->method('deleteItem')->with('mykey')->willReturn(true); + $pool->expects(self::exactly(2))->method('saveDeferred')->willReturn(true); + $pool->expects(self::once())->method('commit')->willReturn(true); + + $monitor = new MonitorCacheKeys($pool); + self::assertTrue($monitor->deleteItem('mykey')); + } + + public function testDeleteItemThrowsOnPrivateKey(): void { + $pool = $this->createMockPool(); + $keyListItem = $this->createMockItem('__key_list', [], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + + $pool->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + + $monitor = new MonitorCacheKeys($pool); + $this->expectException(OutOfBoundsException::class); + $monitor->deleteItem('__key_list'); + } + + public function testDeleteItemsUpdatesKeyList(): void { + $pool = $this->createMockPool(); + $keyListItem = $this->createMockItem('__key_list', ['a' => true, 'b' => true], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + + $pool->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + $pool->method('getItem') + ->with('__key_list') + ->willReturn($keyListItem); + $pool->expects(self::once())->method('deleteItems')->with(['a', 'b'])->willReturn(true); + $pool->expects(self::once())->method('saveDeferred')->willReturn(true); + $pool->expects(self::once())->method('commit')->willReturn(true); + + $monitor = new MonitorCacheKeys($pool); + self::assertTrue($monitor->deleteItems(['a', 'b'])); + } + + public function testDeleteItemsThrowsOnPrivateKey(): void { + $pool = $this->createMockPool(); + $keyListItem = $this->createMockItem('__key_list', [], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + + $pool->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + + $monitor = new MonitorCacheKeys($pool); + $this->expectException(OutOfBoundsException::class); + $monitor->deleteItems(['a', '__chg_list']); + } + + public function testSaveUpdatesKeyList(): void { + $pool = $this->createMockPool(); + $keyListItem = $this->createMockItem('__key_list', [], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + $newItem = $this->createMockItem('newkey', 'value', true); + + $pool->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + $pool->method('getItem') + ->willReturnCallback(function ($key) use ($keyListItem, $changeListItem) { + return match ($key) { + '__key_list' => $keyListItem, + '__chg_list' => $changeListItem, + default => $this->createMockItem($key, null, false), + }; + }); + $pool->expects(self::once())->method('save')->with($newItem)->willReturn(true); + $pool->expects(self::exactly(2))->method('saveDeferred')->willReturn(true); + $pool->expects(self::once())->method('commit')->willReturn(true); + + $monitor = new MonitorCacheKeys($pool); + self::assertTrue($monitor->save($newItem)); + } + + public function testSaveThrowsOnPrivateKey(): void { + $pool = $this->createMockPool(); + $keyListItem = $this->createMockItem('__key_list', [], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + $badItem = $this->createMockItem('__key_list', 'value', true); + + $pool->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + + $monitor = new MonitorCacheKeys($pool); + $this->expectException(OutOfBoundsException::class); + $monitor->save($badItem); + } + + public function testSaveDeferredUpdatesKeyList(): void { + $pool = $this->createMockPool(); + $keyListItem = $this->createMockItem('__key_list', [], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + $newItem = $this->createMockItem('newkey', 'value', true); + + $pool->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + $pool->method('getItem') + ->willReturnCallback(function ($key) use ($keyListItem, $changeListItem) { + return match ($key) { + '__key_list' => $keyListItem, + '__chg_list' => $changeListItem, + default => $this->createMockItem($key, null, false), + }; + }); + $pool->expects(self::once())->method('saveDeferred')->with($newItem)->willReturn(true); + $pool->expects(self::exactly(2))->method('saveDeferred')->willReturn(true); + $pool->expects(self::once())->method('commit')->willReturn(true); + + $monitor = new MonitorCacheKeys($pool); + self::assertTrue($monitor->saveDeferred($newItem)); + } + + public function testCommitDelegates(): void { + $pool = $this->createMockPool(); + $keyListItem = $this->createMockItem('__key_list', [], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + + $pool->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + $pool->expects(self::once())->method('commit')->willReturn(true); + + $monitor = new MonitorCacheKeys($pool); + self::assertTrue($monitor->commit()); + } +} diff --git a/tests/Unit/Core/PersistCacheTest.php b/tests/Unit/Core/PersistCacheTest.php new file mode 100644 index 0000000..a35012d --- /dev/null +++ b/tests/Unit/Core/PersistCacheTest.php @@ -0,0 +1,136 @@ +createMock(CacheItemInterface::class); + $item->method('getKey')->willReturn($key); + $item->method('get')->willReturn($value); + $item->method('isHit')->willReturn($isHit); + return $item; + } + + private function createMockPool(): CacheItemPoolInterface { + return $this->createMock(CacheItemPoolInterface::class); + } + + public function testBootWhenCacheIsEmpty(): void { + $sessionCache = $this->createMockPool(); + $sessionStorage = $this->createMockPool(); + + $keyListItem = $this->createMockItem('__key_list', [], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + + $sessionCache->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + $sessionCache->method('getItem') + ->with('__key_list') + ->willReturn($keyListItem); + + $storageKeyList = $this->createMockItem('__key_list', ['item1' => true], true); + $storageChangeList = $this->createMockItem('__chg_list', [], true); + $storageItem = $this->createMockItem('item1', 'value1', true); + + $sessionStorage->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$storageKeyList, $storageChangeList]); + $sessionStorage->method('getItem') + ->with('__key_list') + ->willReturn($storageKeyList); + $sessionStorage->method('getItems') + ->with(['item1']) + ->willReturn(['item1' => $storageItem]); + + $sessionCache->expects(self::once())->method('saveDeferred')->with($storageItem)->willReturn(true); + $sessionCache->expects(self::once())->method('commit')->willReturn(true); + + $persistCache = new PersistCache($sessionCache, $sessionStorage); + $persistCache->boot(); + } + + public function testBootWhenCacheIsNotEmpty(): void { + $sessionCache = $this->createMockPool(); + $sessionStorage = $this->createMockPool(); + + $keyListItem = $this->createMockItem('__key_list', ['item1' => true], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + + $sessionCache->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + $sessionCache->method('getItem') + ->with('__key_list') + ->willReturn($keyListItem); + + $sessionStorage->expects(self::never())->method('getItems'); + $sessionCache->expects(self::never())->method('saveDeferred'); + + $persistCache = new PersistCache($sessionCache, $sessionStorage); + $persistCache->boot(); + } + + public function testPersistWithChanges(): void { + $sessionCache = $this->createMockPool(); + $sessionStorage = $this->createMockPool(); + + $keyListItem = $this->createMockItem('__key_list', ['item1' => true, 'item2' => true], true); + $changeListItem = $this->createMockItem('__chg_list', ['item1' => MonitorCacheKeys::UPDATED, 'item2' => MonitorCacheKeys::REMOVED], true); + + $sessionCache->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + $sessionCache->method('getItem') + ->willReturnCallback(function ($key) use ($keyListItem, $changeListItem) { + return match ($key) { + '__key_list' => $keyListItem, + '__chg_list' => $changeListItem, + default => $this->createMockItem($key, 'value', true), + }; + }); + + $item1 = $this->createMockItem('item1', 'value1', true); + $item2 = $this->createMockItem('item2', null, false); + + $sessionCache->method('getItems') + ->with(['item1', 'item2']) + ->willReturn(['item1' => $item1, 'item2' => $item2]); + + $sessionStorage->expects(self::once())->method('saveDeferred')->with($item1)->willReturn(true); + $sessionStorage->expects(self::once())->method('deleteItem')->with('item2')->willReturn(true); + $sessionStorage->expects(self::once())->method('commit')->willReturn(true); + + $persistCache = new PersistCache($sessionCache, $sessionStorage); + $persistCache->persist(); + } + + public function testPersistWithNoChanges(): void { + $sessionCache = $this->createMockPool(); + $sessionStorage = $this->createMockPool(); + + $keyListItem = $this->createMockItem('__key_list', [], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + + $sessionCache->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + $sessionCache->method('getItem') + ->with('__chg_list') + ->willReturn($changeListItem); + + $sessionStorage->expects(self::never())->method('saveDeferred'); + $sessionStorage->expects(self::never())->method('deleteItem'); + $sessionStorage->expects(self::never())->method('commit'); + + $persistCache = new PersistCache($sessionCache, $sessionStorage); + $persistCache->persist(); + } +} diff --git a/tests/Unit/Data/PayloadTest.php b/tests/Unit/Data/PayloadTest.php new file mode 100644 index 0000000..976e81c --- /dev/null +++ b/tests/Unit/Data/PayloadTest.php @@ -0,0 +1,205 @@ + 'testuser', + 'token' => '123456', + 'nonce' => 'abc123', + 'json' => true, + 'scope' => 'cookie', + ]); + $base64url = rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + + $payload = Payload::decode($base64url); + + 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 { + $base64url = rtrim(strtr(base64_encode('"just a string"'), '+/', '-_'), '='); + self::assertNull(Payload::decode($base64url)); + } + + 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' => 'nonce123', + 'totp' => '654321', + ]); + self::assertNull(Payload::load($input)); + } + + public function testLoadMissingNonceReturnsNull(): void { + $input = new InputBag([ + 'username' => 'alice', + 'totp' => '654321', + ]); + self::assertNull(Payload::load($input)); + } + + public function testLoadMissingTotpReturnsNull(): void { + $input = new InputBag([ + 'username' => 'alice', + 'nonce' => 'nonce123', + ]); + self::assertNull(Payload::load($input)); + } + + public function testLoadEmptyInputBagReturnsNull(): void { + $input = new InputBag([]); + 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' => 'tok1', + 'nonce' => 'non1', + ]; + self::assertNull(Payload::create($data)); + } + + public function testCreateWithWhitespaceIdReturnsNull(): void { + $data = (object)[ + 'id' => ' ', + 'token' => 'tok1', + 'nonce' => 'non1', + ]; + self::assertNull(Payload::create($data)); + } + + public function testCreateWithEmptyTokenReturnsNull(): void { + $data = (object)[ + 'id' => 'user1', + 'token' => '', + 'nonce' => 'non1', + ]; + self::assertNull(Payload::create($data)); + } + + public function testCreateWithEmptyNonceReturnsNull(): void { + $data = (object)[ + 'id' => 'user1', + 'token' => 'tok1', + '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); + + self::assertSame(mb_substr($long, 0, 128), $payload->id); + self::assertSame(mb_substr($long, 0, 128), $payload->token); + self::assertSame(mb_substr($long, 0, 128), $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; + + $json = $payload->toString(); + $decoded = json_decode($json, 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..9720e65 --- /dev/null +++ b/tests/Unit/Enum/ScopeTest.php @@ -0,0 +1,27 @@ +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 testTryFromInvalidReturnsNull(): void { + self::assertNull(Scope::tryFrom('invalid')); + self::assertNull(Scope::tryFrom('')); + self::assertNull(Scope::tryFrom('COOKIE')); + } +} diff --git a/tests/Unit/Listener/AcceptListenerTest.php b/tests/Unit/Listener/AcceptListenerTest.php new file mode 100644 index 0000000..7fdfb73 --- /dev/null +++ b/tests/Unit/Listener/AcceptListenerTest.php @@ -0,0 +1,118 @@ +createMock(HttpKernelInterface::class); + return new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST); + } + + private function createMockItem(string $key, mixed $value = null, bool $isHit = true): CacheItemInterface { + $item = $this->createMock(CacheItemInterface::class); + $item->method('getKey')->willReturn($key); + $item->method('get')->willReturn($value); + $item->method('isHit')->willReturn($isHit); + return $item; + } + + public function testOnKernelRequestWithValidCookie(): void { + $cache = $this->createMock(CacheItemPoolInterface::class); + $domainManager = new DomainManager(false, ''); + $listener = new AcceptListener($cache, $domainManager); + $listener->setLogger($this->createMock(LoggerInterface::class)); + + $cacheItem = $this->createMockItem('cookie_abc123', 'user1', true); + $cache->method('hasItem')->with('cookie_abc123')->willReturn(true); + $cache->method('getItem')->with('cookie_abc123')->willReturn($cacheItem); + + $request = Request::create('https://example.com/'); + $request->cookies->set('__Host-Http-Preauth', 'abc123'); + + $event = $this->createEvent($request); + $listener->onKernelRequest($event); + + $response = $event->getResponse(); + self::assertNotNull($response); + self::assertSame(200, $response->getStatusCode()); + self::assertSame('hi user1', $response->getContent()); + self::assertSame('user1', $response->headers->get('Remote-User')); + } + + public function testOnKernelRequestWithNoCookie(): void { + $cache = $this->createMock(CacheItemPoolInterface::class); + $domainManager = new DomainManager(false, ''); + $listener = new AcceptListener($cache, $domainManager); + $listener->setLogger($this->createMock(LoggerInterface::class)); + + $request = Request::create('https://example.com/'); + $event = $this->createEvent($request); + $listener->onKernelRequest($event); + + self::assertNull($event->getResponse()); + } + + public function testOnKernelRequestWithInvalidCookie(): void { + $cache = $this->createMock(CacheItemPoolInterface::class); + $domainManager = new DomainManager(false, ''); + $listener = new AcceptListener($cache, $domainManager); + $listener->setLogger($this->createMock(LoggerInterface::class)); + + $cache->method('hasItem')->with('cookie_badcookie')->willReturn(false); + + $request = Request::create('https://example.com/'); + $request->cookies->set('__Host-Http-Preauth', 'badcookie'); + + $event = $this->createEvent($request); + $listener->onKernelRequest($event); + + self::assertNull($event->getResponse()); + } + + public function testOnKernelRequestWithAuthBaseUsesAuthCookieName(): void { + $cache = $this->createMock(CacheItemPoolInterface::class); + $domainManager = new DomainManager(true, 'auth.example.com'); + $listener = new AcceptListener($cache, $domainManager); + $listener->setLogger($this->createMock(LoggerInterface::class)); + + $cacheItem = $this->createMockItem('cookie_xyz789', 'user2', true); + $cache->method('hasItem')->with('cookie_xyz789')->willReturn(true); + $cache->method('getItem')->with('cookie_xyz789')->willReturn($cacheItem); + + $request = Request::create('https://example.com/'); + $request->cookies->set('__Http-Domain-Preauth', 'xyz789'); + + $event = $this->createEvent($request); + $listener->onKernelRequest($event); + + $response = $event->getResponse(); + self::assertNotNull($response); + self::assertSame('hi user2', $response->getContent()); + } + + public function testOnKernelRequestWithEmptyCookieValue(): void { + $cache = $this->createMock(CacheItemPoolInterface::class); + $domainManager = new DomainManager(false, ''); + $listener = new AcceptListener($cache, $domainManager); + $listener->setLogger($this->createMock(LoggerInterface::class)); + + $request = Request::create('https://example.com/'); + $request->cookies->set('__Host-Http-Preauth', ''); + + $event = $this->createEvent($request); + $listener->onKernelRequest($event); + + self::assertNull($event->getResponse()); + } +} diff --git a/tests/Unit/Listener/AllowListenerTest.php b/tests/Unit/Listener/AllowListenerTest.php new file mode 100644 index 0000000..340a3cf --- /dev/null +++ b/tests/Unit/Listener/AllowListenerTest.php @@ -0,0 +1,122 @@ +createMock(HttpKernelInterface::class); + return new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST); + } + + private function createMockItem(string $key, mixed $value = null, bool $isHit = true): CacheItemInterface { + $item = $this->createMock(CacheItemInterface::class); + $item->method('getKey')->willReturn($key); + $item->method('get')->willReturn($value); + $item->method('isHit')->willReturn($isHit); + return $item; + } + + private function createConfigBag(int $ipTtl = 1800): ConfigBag { + $clock = $this->createMock(ClockInterface::class); + $cache = $this->createMock(CacheItemPoolInterface::class); + $utilities = new Utilities($clock, $cache); + $totp = TOTP::generate($clock); + $totp->setLabel('Test'); + + return new ConfigBag( + $utilities, + $clock, + 3600, + $totp->getProvisioningUri(), + $ipTtl, + false, + 'Error', + 'Teapot', + 'Too Many' + ); + } + + public function testOnKernelRequestWithValidIp(): void { + $cache = $this->createMock(CacheItemPoolInterface::class); + $config = $this->createConfigBag(1800); + $listener = new AllowListener($cache, $config); + $listener->setLogger($this->createMock(LoggerInterface::class)); + + $cacheItem = $this->createMockItem('ip_192.168.1.1', 'user1', true); + $cache->method('hasItem')->with('ip_192.168.1.1')->willReturn(true); + $cache->method('getItem')->with('ip_192.168.1.1')->willReturn($cacheItem); + + $request = Request::create('https://example.com/'); + $request->server->set('REMOTE_ADDR', '192.168.1.1'); + + $event = $this->createEvent($request); + $listener->onKernelRequest($event); + + $response = $event->getResponse(); + self::assertNotNull($response); + self::assertSame(200, $response->getStatusCode()); + self::assertSame('hi user1', $response->getContent()); + self::assertSame('user1', $response->headers->get('Remote-User')); + } + + public function testOnKernelRequestWithIpTtlZero(): void { + $cache = $this->createMock(CacheItemPoolInterface::class); + $config = $this->createConfigBag(0); + $listener = new AllowListener($cache, $config); + $listener->setLogger($this->createMock(LoggerInterface::class)); + + $request = Request::create('https://example.com/'); + $request->server->set('REMOTE_ADDR', '192.168.1.1'); + + $event = $this->createEvent($request); + $listener->onKernelRequest($event); + + self::assertNull($event->getResponse()); + } + + public function testOnKernelRequestWithIpTtlNull(): void { + $cache = $this->createMock(CacheItemPoolInterface::class); + $config = $this->createConfigBag(0); + $listener = new AllowListener($cache, $config); + $listener->setLogger($this->createMock(LoggerInterface::class)); + + $request = Request::create('https://example.com/'); + $request->server->set('REMOTE_ADDR', '192.168.1.1'); + + $event = $this->createEvent($request); + $listener->onKernelRequest($event); + + self::assertNull($event->getResponse()); + } + + public function testOnKernelRequestWithUnknownIp(): void { + $cache = $this->createMock(CacheItemPoolInterface::class); + $config = $this->createConfigBag(1800); + $listener = new AllowListener($cache, $config); + $listener->setLogger($this->createMock(LoggerInterface::class)); + + $cache->method('hasItem')->with('ip_192.168.1.1')->willReturn(false); + + $request = Request::create('https://example.com/'); + $request->server->set('REMOTE_ADDR', '192.168.1.1'); + + $event = $this->createEvent($request); + $listener->onKernelRequest($event); + + self::assertNull($event->getResponse()); + } +} diff --git a/tests/Unit/Listener/InterceptListenerTest.php b/tests/Unit/Listener/InterceptListenerTest.php new file mode 100644 index 0000000..618e01d --- /dev/null +++ b/tests/Unit/Listener/InterceptListenerTest.php @@ -0,0 +1,169 @@ +createMock(HttpKernelInterface::class); + return new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST); + } + + private function createConfigBag(): ConfigBag { + $clock = $this->createMock(ClockInterface::class); + $cache = $this->createMock(CacheItemPoolInterface::class); + $utilities = new Utilities($clock, $cache); + $totp = TOTP::generate($clock); + $totp->setLabel('Test'); + + return new ConfigBag( + $utilities, + $clock, + 3600, + $totp->getProvisioningUri(), + 1800, + false, + 'Error', + 'Teapot', + 'Too Many' + ); + } + + private function createTwig(): Environment { + $twig = $this->createMock(Environment::class); + $twig->method('render')->with('login.html.twig', self::anything())->willReturn('login'); + return $twig; + } + + private function createListener( + ?DomainManager $dm = null, + ?Environment $twig = null, + ?CacheItemPoolInterface $nonceCache = null + ): InterceptListener { + $config = $this->createConfigBag(); + $domainManager = $dm ?? new DomainManager(false, ''); + $twig = $twig ?? $this->createTwig(); + $listener = new InterceptListener($config, $domainManager, $twig); + $listener->setLogger($this->createMock(LoggerInterface::class)); + + if ($nonceCache) { + $listener->setNonceCache($nonceCache); + } else { + $cache = $this->createMock(CacheItemPoolInterface::class); + $item = $this->createMock(CacheItemInterface::class); + $item->method('isHit')->willReturn(false); + $item->method('set')->willReturnSelf(); + $item->method('expiresAfter')->willReturnSelf(); + $cache->method('getItem')->willReturn($item); + $cache->method('save')->willReturn(true); + $listener->setNonceCache($cache); + } + + return $listener; + } + + public function testOnKernelRequestRedirectsToAuthSubdomain(): void { + $dm = new DomainManager(true, 'auth.example.com'); + $listener = $this->createListener($dm); + + $request = Request::create('https://www.example.com/protected'); + $event = $this->createEvent($request); + $listener->onKernelRequest($event); + + $response = $event->getResponse(); + self::assertNotNull($response); + self::assertSame(303, $response->getStatusCode()); + self::assertStringContainsString('auth.example.com', $response->headers->get('Location')); + self::assertStringContainsString('return=', $response->headers->get('Location')); + } + + public function testOnKernelRequestPresentsLoginPageOnAuthSubdomain(): void { + $dm = new DomainManager(true, 'auth.example.com'); + $listener = $this->createListener($dm); + + $request = Request::create('https://auth.example.com/'); + $event = $this->createEvent($request); + $listener->onKernelRequest($event); + + $response = $event->getResponse(); + self::assertNotNull($response); + self::assertSame(401, $response->getStatusCode()); + self::assertSame('login', $response->getContent()); + } + + public function testOnKernelRequestPresentsLoginPageWithoutAuthSubdomain(): void { + $dm = new DomainManager(false, ''); + $listener = $this->createListener($dm); + + $request = Request::create('https://example.com/'); + $event = $this->createEvent($request); + $listener->onKernelRequest($event); + + $response = $event->getResponse(); + self::assertNotNull($response); + self::assertSame(401, $response->getStatusCode()); + } + + public function testOnKernelRequestPrunesInvalidCookie(): void { + $dm = new DomainManager(false, ''); + $listener = $this->createListener($dm); + + $request = Request::create('https://example.com/'); + $request->cookies->set('__Host-Http-Preauth', 'invalid'); + + $event = $this->createEvent($request); + $listener->onKernelRequest($event); + + $response = $event->getResponse(); + self::assertNotNull($response); + $cookies = $response->headers->getCookies(); + self::assertCount(1, $cookies); + self::assertSame('__Host-Http-Preauth', $cookies[0]->getName()); + self::assertNull($cookies[0]->getValue()); + } + + public function testOnKernelRequestDoesNotPruneCookieWhenNotPresent(): void { + $dm = new DomainManager(false, ''); + $listener = $this->createListener($dm); + + $request = Request::create('https://example.com/'); + $event = $this->createEvent($request); + $listener->onKernelRequest($event); + + $response = $event->getResponse(); + self::assertNotNull($response); + self::assertCount(0, $response->headers->getCookies()); + } + + public function testOnKernelRequestWithAuthBaseUsesAuthCookieName(): void { + $dm = new DomainManager(true, 'auth.example.com'); + $listener = $this->createListener($dm); + + $request = Request::create('https://auth.example.com/'); + $request->cookies->set('__Http-Domain-Preauth', 'invalid'); + + $event = $this->createEvent($request); + $listener->onKernelRequest($event); + + $response = $event->getResponse(); + self::assertNotNull($response); + $cookies = $response->headers->getCookies(); + self::assertCount(1, $cookies); + self::assertSame('__Http-Domain-Preauth', $cookies[0]->getName()); + } +} diff --git a/tests/Unit/Listener/LoginListenerTest.php b/tests/Unit/Listener/LoginListenerTest.php new file mode 100644 index 0000000..ee7eb1d --- /dev/null +++ b/tests/Unit/Listener/LoginListenerTest.php @@ -0,0 +1,253 @@ +createMock(HttpKernelInterface::class); + return new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST); + } + + private function createConfigBag(bool $teapot = false): ConfigBag { + $clock = $this->createMock(ClockInterface::class); + $cache = $this->createMock(CacheItemPoolInterface::class); + $utilities = new Utilities($clock, $cache); + $totp = TOTP::generate($clock); + $totp->setLabel('Test'); + + return new ConfigBag( + $utilities, + $clock, + 3600, + $totp->getProvisioningUri(), + 1800, + $teapot, + 'Error!', + 'Teapot!', + 'Too Many!' + ); + } + + private function createLimiter(int $remainingTokens): LimiterInterface { + $limit = $this->createMock(RateLimit::class); + $limit->method('getRemainingTokens')->willReturn($remainingTokens); + + $limiter = $this->createMock(LimiterInterface::class); + $limiter->method('consume')->with(1)->willReturn($limit); + return $limiter; + } + + private function createListener( + ?LoginManager $loginManager = null, + ?DomainManager $domainManager = null, + ?RateLimiterFactoryInterface $rateLimiter = null, + bool $teapot = false + ): LoginListener { + $twig = $this->createMock(Environment::class); + $twig->method('render')->with('login.html.twig', self::anything())->willReturn('login'); + + $rateLimiter = $rateLimiter ?? $this->createMock(RateLimiterFactoryInterface::class); + $rateLimiter->method('create')->willReturn($this->createLimiter(5)); + + $dm = $domainManager ?? new DomainManager(false, ''); + $lm = $loginManager ?? $this->createMock(LoginManager::class); + $config = $this->createConfigBag($teapot); + + $listener = new LoginListener($twig, $rateLimiter, $dm, $lm, $config); + $listener->setLogger($this->createMock(LoggerInterface::class)); + + $cache = $this->createMock(CacheItemPoolInterface::class); + $item = $this->createMock(CacheItemInterface::class); + $item->method('isHit')->willReturn(false); + $item->method('set')->willReturnSelf(); + $item->method('expiresAfter')->willReturnSelf(); + $cache->method('getItem')->willReturn($item); + $cache->method('save')->willReturn(true); + $listener->setNonceCache($cache); + + return $listener; + } + + public function testOnKernelRequestWithNoLoginAttempt(): void { + $listener = $this->createListener(); + + $request = Request::create('https://example.com/'); + $event = $this->createEvent($request); + $listener->onKernelRequest($event); + + self::assertNull($event->getResponse()); + } + + public function testOnKernelRequestWithHeaderLoginSuccess(): void { + $loginManager = $this->createMock(LoginManager::class); + $loginManager->method('checkToken')->willReturnCallback(function (Payload $payload) { + return new \Symfony\Component\HttpFoundation\Response('success'); + }); + + $listener = $this->createListener($loginManager); + + $request = Request::create('https://example.com/'); + $request->headers->set('X-Preauth', base64_encode(json_encode([ + 'id' => 'user1', 'token' => '123456', 'nonce' => 'abc', 'json' => true, 'scope' => 'cookie' + ]))); + + $event = $this->createEvent($request); + $listener->onKernelRequest($event); + + $response = $event->getResponse(); + self::assertNotNull($response); + self::assertSame('success', $response->getContent()); + } + + public function testOnKernelRequestWithPostLoginSuccess(): void { + $loginManager = $this->createMock(LoginManager::class); + $loginManager->method('checkToken')->willReturn(new \Symfony\Component\HttpFoundation\Response('success')); + + $dm = new DomainManager(true, 'auth.example.com'); + $listener = $this->createListener($loginManager, $dm); + + $request = Request::create('https://auth.example.com/', 'POST', [ + 'username' => 'user1', + 'nonce' => 'abc', + 'totp' => '123456', + ]); + + $event = $this->createEvent($request); + $listener->onKernelRequest($event); + + $response = $event->getResponse(); + self::assertNotNull($response); + self::assertSame('success', $response->getContent()); + } + + public function testOnKernelRequestWithFailedLoginReturnsUnauthorized(): void { + $loginManager = $this->createMock(LoginManager::class); + $loginManager->method('checkToken')->willReturn(null); + + $listener = $this->createListener($loginManager); + + $request = Request::create('https://example.com/'); + $request->headers->set('X-Preauth', base64_encode(json_encode([ + 'id' => 'user1', 'token' => 'bad', 'nonce' => 'abc', 'json' => true, 'scope' => 'cookie' + ]))); + + $event = $this->createEvent($request); + $listener->onKernelRequest($event); + + $response = $event->getResponse(); + self::assertNotNull($response); + self::assertSame(401, $response->getStatusCode()); + } + + public function testOnKernelRequestWithRateLimitReached(): void { + $loginManager = $this->createMock(LoginManager::class); + $loginManager->method('checkToken')->willReturn(null); + + $rateLimiter = $this->createMock(RateLimiterFactoryInterface::class); + $limit = $this->createMock(RateLimit::class); + $limit->method('getRemainingTokens')->willReturn(0); + $limiter = $this->createMock(LimiterInterface::class); + $limiter->method('consume')->with(1)->willReturn($limit); + $rateLimiter->method('create')->willReturn($limiter); + + $listener = $this->createListener($loginManager, null, $rateLimiter); + + $request = Request::create('https://example.com/'); + $request->headers->set('X-Preauth', base64_encode(json_encode([ + 'id' => 'user1', 'token' => 'bad', 'nonce' => 'abc', 'json' => true, 'scope' => 'cookie' + ]))); + + $event = $this->createEvent($request); + $listener->onKernelRequest($event); + + $response = $event->getResponse(); + self::assertNotNull($response); + self::assertSame(429, $response->getStatusCode()); + } + + public function testOnKernelRequestWithRateLimitReachedAndTeapot(): void { + $loginManager = $this->createMock(LoginManager::class); + $loginManager->method('checkToken')->willReturn(null); + + $rateLimiter = $this->createMock(RateLimiterFactoryInterface::class); + $limit = $this->createMock(RateLimit::class); + $limit->method('getRemainingTokens')->willReturn(0); + $limiter = $this->createMock(LimiterInterface::class); + $limiter->method('consume')->with(1)->willReturn($limit); + $rateLimiter->method('create')->willReturn($limiter); + + $listener = $this->createListener($loginManager, null, $rateLimiter, true); + + $request = Request::create('https://example.com/'); + $request->headers->set('X-Preauth', base64_encode(json_encode([ + 'id' => 'user1', 'token' => 'bad', 'nonce' => 'abc', 'json' => true, 'scope' => 'cookie' + ]))); + + $event = $this->createEvent($request); + $listener->onKernelRequest($event); + + $response = $event->getResponse(); + self::assertNotNull($response); + self::assertSame(418, $response->getStatusCode()); + } + + public function testOnKernelRequestWithJsonResponse(): void { + $loginManager = $this->createMock(LoginManager::class); + $loginManager->method('checkToken')->willReturn(null); + + $listener = $this->createListener($loginManager); + + $request = Request::create('https://example.com/'); + $request->headers->set('X-Preauth', base64_encode(json_encode([ + 'id' => 'user1', 'token' => 'bad', 'nonce' => 'abc', 'json' => true, 'scope' => 'cookie' + ]))); + + $event = $this->createEvent($request); + $listener->onKernelRequest($event); + + $response = $event->getResponse(); + self::assertNotNull($response); + self::assertSame('application/json', $response->headers->get('Content-Type')); + } + + public function testOnKernelRequestWithHtmlResponse(): void { + $loginManager = $this->createMock(LoginManager::class); + $loginManager->method('checkToken')->willReturn(null); + + $listener = $this->createListener($loginManager); + + $request = Request::create('https://auth.example.com/', 'POST', [ + 'username' => 'user1', + 'nonce' => 'abc', + 'totp' => 'bad', + ]); + + $event = $this->createEvent($request); + $listener->onKernelRequest($event); + + $response = $event->getResponse(); + self::assertNotNull($response); + self::assertSame('text/html', $response->headers->get('Content-Type')); + } +} diff --git a/tests/Unit/Listener/RejectListenerTest.php b/tests/Unit/Listener/RejectListenerTest.php new file mode 100644 index 0000000..23ac810 --- /dev/null +++ b/tests/Unit/Listener/RejectListenerTest.php @@ -0,0 +1,110 @@ +createMock(HttpKernelInterface::class); + return new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST); + } + + private function createConfigBag(bool $teapot = false): ConfigBag { + $clock = $this->createMock(ClockInterface::class); + $utilities = $this->createMock(\App\Utilities::class); + $totp = TOTP::generate($clock); + $totp->setLabel('Test'); + + return new ConfigBag( + $utilities, + $clock, + 3600, + $totp->getProvisioningUri(), + 1800, + $teapot, + 'Error', + 'Teapot!', + 'Too Many' + ); + } + + private function createLimiter(int $remainingTokens): LimiterInterface { + $limit = $this->createMock(RateLimit::class); + $limit->method('getRemainingTokens')->willReturn($remainingTokens); + + $limiter = $this->createMock(LimiterInterface::class); + $limiter->method('consume')->with(0)->willReturn($limit); + return $limiter; + } + + public function testOnKernelRequestWhenNotBlocked(): void { + $twig = $this->createMock(Environment::class); + $config = $this->createConfigBag(); + $rateLimiter = $this->createMock(RateLimiterFactoryInterface::class); + $rateLimiter->method('create')->willReturn($this->createLimiter(5)); + + $listener = new RejectListener($config, $twig, $rateLimiter); + $listener->setLogger($this->createMock(LoggerInterface::class)); + + $request = Request::create('https://example.com/'); + $event = $this->createEvent($request); + $listener->onKernelRequest($event); + + self::assertNull($event->getResponse()); + } + + public function testOnKernelRequestWhenBlocked(): void { + $twig = $this->createMock(Environment::class); + $twig->method('render')->with('error.html.twig')->willReturn('blocked'); + + $config = $this->createConfigBag(); + $rateLimiter = $this->createMock(RateLimiterFactoryInterface::class); + $rateLimiter->method('create')->willReturn($this->createLimiter(0)); + + $listener = new RejectListener($config, $twig, $rateLimiter); + $listener->setLogger($this->createMock(LoggerInterface::class)); + + $request = Request::create('https://example.com/'); + $event = $this->createEvent($request); + $listener->onKernelRequest($event); + + $response = $event->getResponse(); + self::assertNotNull($response); + self::assertSame(429, $response->getStatusCode()); + self::assertSame('blocked', $response->getContent()); + } + + public function testOnKernelRequestWhenBlockedWithTeapot(): void { + $twig = $this->createMock(Environment::class); + $twig->method('render')->with('error.html.twig')->willReturn('teapot'); + + $config = $this->createConfigBag(true); + $rateLimiter = $this->createMock(RateLimiterFactoryInterface::class); + $rateLimiter->method('create')->willReturn($this->createLimiter(0)); + + $listener = new RejectListener($config, $twig, $rateLimiter); + $listener->setLogger($this->createMock(LoggerInterface::class)); + + $request = Request::create('https://example.com/'); + $event = $this->createEvent($request); + $listener->onKernelRequest($event); + + $response = $event->getResponse(); + self::assertNotNull($response); + self::assertSame(418, $response->getStatusCode()); + } +} diff --git a/tests/Unit/Service/BackupCodeManagerTest.php b/tests/Unit/Service/BackupCodeManagerTest.php new file mode 100644 index 0000000..b929760 --- /dev/null +++ b/tests/Unit/Service/BackupCodeManagerTest.php @@ -0,0 +1,253 @@ +createMock(CacheItemPoolInterface::class); + } + + private function createMockItem(string $key, mixed $value = null, bool $isHit = true): CacheItemInterface { + $item = $this->createMock(CacheItemInterface::class); + $item->method('getKey')->willReturn($key); + $item->method('get')->willReturn($value); + $item->method('isHit')->willReturn($isHit); + $item->method('set')->willReturnSelf(); + $item->method('expiresAt')->willReturnSelf(); + return $item; + } + + private function createConfigBag(): ConfigBag { + $clock = $this->createMock(ClockInterface::class); + $utilities = $this->createMock(\App\Utilities::class); + $totp = TOTP::generate($clock); + $totp->setLabel('Test'); + + return new ConfigBag( + $utilities, + $clock, + 3600, + $totp->getProvisioningUri(), + 1800, + false, + 'Error', + 'Teapot', + 'Too Many' + ); + } + + private function createManager(CacheItemPoolInterface $pool): BackupCodeManager { + $manager = new BackupCodeManager($pool); + $manager->setConfig($this->createConfigBag()); + $manager->setLogger($this->createMock(LoggerInterface::class)); + return $manager; + } + + public function testGenerateCreatesCodes(): void { + $pool = $this->createMockPool(); + $keyListItem = $this->createMockItem('__key_list', [], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + + $pool->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + $pool->method('getItem') + ->willReturnCallback(function ($key) { + return $this->createMockItem($key, null, false); + }); + $pool->expects(self::atLeastOnce())->method('saveDeferred')->willReturn(true); + $pool->expects(self::atLeastOnce())->method('commit')->willReturn(true); + + $manager = $this->createManager($pool); + $codes = $manager->generate(5); + + self::assertCount(5, $codes); + foreach ($codes as $code) { + self::assertMatchesRegularExpression('/^[a-z0-9]+$/', $code); + self::assertLessThanOrEqual(BackupCodeManager::MAX_LENGTH, strlen($code)); + } + } + + public function testGenerateCodesAreUnique(): void { + $pool = $this->createMockPool(); + $keyListItem = $this->createMockItem('__key_list', [], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + + $pool->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + $pool->method('getItem') + ->willReturnCallback(function ($key) { + return $this->createMockItem($key, null, false); + }); + $pool->method('saveDeferred')->willReturn(true); + $pool->method('commit')->willReturn(true); + + $manager = $this->createManager($pool); + $codes = $manager->generate(10); + + self::assertSame($codes, array_unique($codes)); + } + + public function testVerifyAndConsumeWithValidCode(): void { + $pool = $this->createMockPool(); + $keyListItem = $this->createMockItem('__key_list', [], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + $backupItem = $this->createMockItem('backup_abc123', true, true); + + $pool->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + $pool->method('getItem') + ->willReturnCallback(function ($key) use ($backupItem) { + if (str_contains($key, 'backup_')) { + return $backupItem; + } + return $this->createMockItem($key, null, false); + }); + $pool->expects(self::atLeastOnce())->method('save')->willReturn(true); + + $manager = $this->createManager($pool); + self::assertTrue($manager->verifyAndConsume('abc123')); + } + + public function testVerifyAndConsumeWithInvalidCode(): void { + $pool = $this->createMockPool(); + $keyListItem = $this->createMockItem('__key_list', [], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + $backupItem = $this->createMockItem('backup_invalid', false, true); + + $pool->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + $pool->method('getItem') + ->willReturnCallback(function ($key) use ($backupItem) { + if (str_contains($key, 'backup_')) { + return $backupItem; + } + return $this->createMockItem($key, null, false); + }); + + $manager = $this->createManager($pool); + self::assertFalse($manager->verifyAndConsume('invalid')); + } + + public function testVerifyAndConsumeWithMissingCode(): void { + $pool = $this->createMockPool(); + $keyListItem = $this->createMockItem('__key_list', [], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + $backupItem = $this->createMockItem('backup_missing', null, false); + + $pool->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + $pool->method('getItem') + ->willReturnCallback(function ($key) use ($backupItem) { + if (str_contains($key, 'backup_')) { + return $backupItem; + } + return $this->createMockItem($key, null, false); + }); + + $manager = $this->createManager($pool); + self::assertFalse($manager->verifyAndConsume('missing')); + } + + public function testVerifyAndConsumeIsCaseInsensitive(): void { + $pool = $this->createMockPool(); + $keyListItem = $this->createMockItem('__key_list', [], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + $backupItem = $this->createMockItem('backup_abc123', true, true); + + $pool->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + $pool->method('getItem') + ->willReturnCallback(function ($key) use ($backupItem) { + if (str_contains($key, 'backup_')) { + return $backupItem; + } + return $this->createMockItem($key, null, false); + }); + $pool->expects(self::atLeastOnce())->method('save')->willReturn(true); + + $manager = $this->createManager($pool); + self::assertTrue($manager->verifyAndConsume('ABC123')); + self::assertTrue($manager->verifyAndConsume('AbC123')); + } + + public function testVerifyAndConsumeStripsInvalidChars(): void { + $pool = $this->createMockPool(); + $keyListItem = $this->createMockItem('__key_list', [], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + $backupItem = $this->createMockItem('backup_abc123', true, true); + + $pool->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + $pool->method('getItem') + ->willReturnCallback(function ($key) use ($backupItem) { + if (str_contains($key, 'backup_')) { + return $backupItem; + } + return $this->createMockItem($key, null, false); + }); + $pool->expects(self::atLeastOnce())->method('save')->willReturn(true); + + $manager = $this->createManager($pool); + self::assertTrue($manager->verifyAndConsume('abc-123')); + self::assertTrue($manager->verifyAndConsume('abc 123')); + self::assertTrue($manager->verifyAndConsume('abc!@#123')); + } + + public function testExpireRemovesBackupCodes(): void { + $pool = $this->createMockPool(); + $keyListItem = $this->createMockItem('__key_list', ['backup_a' => true, 'backup_b' => true, 'other' => true], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + + $pool->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + $pool->method('getItem') + ->with('__key_list') + ->willReturn($keyListItem); + $pool->expects(self::once()) + ->method('deleteItems') + ->with(self::callback(function ($keys) { + return count($keys) === 2 && in_array('backup_a', $keys) && in_array('backup_b', $keys); + })) + ->willReturn(true); + $pool->expects(self::once())->method('saveDeferred')->willReturn(true); + $pool->expects(self::once())->method('commit')->willReturn(true); + + $manager = $this->createManager($pool); + $manager->expire(); + } + + public function testExpireDoesNothingWhenNoBackupCodes(): void { + $pool = $this->createMockPool(); + $keyListItem = $this->createMockItem('__key_list', ['other' => true], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + + $pool->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + $pool->method('getItem') + ->with('__key_list') + ->willReturn($keyListItem); + $pool->expects(self::never())->method('deleteItems'); + + $manager = $this->createManager($pool); + $manager->expire(); + } +} diff --git a/tests/Unit/Service/DomainManagerTest.php b/tests/Unit/Service/DomainManagerTest.php new file mode 100644 index 0000000..89abd21 --- /dev/null +++ b/tests/Unit/Service/DomainManagerTest.php @@ -0,0 +1,126 @@ +getAuthSubdomain()); + } + + public function testGetAuthSubdomainWhenDisabled(): void { + $dm = new DomainManager(false, 'auth.example.com'); + self::assertNull($dm->getAuthSubdomain()); + } + + public function testGetAuthSubdomainWithEmptySubdomain(): void { + $dm = new DomainManager(true, ''); + self::assertNull($dm->getAuthSubdomain()); + } + + public function testAuthBaseWithSubdomain(): void { + $dm = new DomainManager(true, 'auth.example.com'); + self::assertSame('example.com', $dm->authBase()); + } + + public function testAuthBaseWithoutSubdomain(): void { + $dm = new DomainManager(false, 'auth.example.com'); + self::assertNull($dm->authBase()); + } + + public function testAuthBaseWithEmptySubdomain(): void { + $dm = new DomainManager(true, ''); + self::assertNull($dm->authBase()); + } + + public function testAuthBaseWithLocalhost(): void { + $dm = new DomainManager(true, 'localhost'); + self::assertNull($dm->authBase()); + } + + public function testAuthBaseWithIp(): void { + $dm = new DomainManager(true, '192.168.1.1'); + self::assertNull($dm->authBase()); + } + + public function testMatchesAuthWithSameBase(): void { + $dm = new DomainManager(true, 'auth.example.com'); + self::assertTrue($dm->matchesAuth('www.example.com')); + self::assertTrue($dm->matchesAuth('app.example.com')); + } + + public function testMatchesAuthWithDifferentBase(): void { + $dm = new DomainManager(true, 'auth.example.com'); + self::assertFalse($dm->matchesAuth('other.com')); + } + + public function testMatchesAuthWithIp(): void { + $dm = new DomainManager(true, 'auth.example.com'); + self::assertFalse($dm->matchesAuth('192.168.1.1')); + } + + public function testMatchesAuthWithLocalhost(): void { + $dm = new DomainManager(true, 'auth.example.com'); + self::assertFalse($dm->matchesAuth('localhost')); + } + + public function testMatchesAuthWhenDisabled(): void { + $dm = new DomainManager(false, 'auth.example.com'); + self::assertFalse($dm->matchesAuth('www.example.com')); + } + + public function testMatchesAuthWithCoUk(): void { + $dm = new DomainManager(true, 'auth.example.co.uk'); + self::assertTrue($dm->matchesAuth('www.example.co.uk')); + self::assertFalse($dm->matchesAuth('other.co.uk')); + } + + public function testValidReturnWithValidUrl(): void { + $dm = new DomainManager(false, ''); + self::assertTrue($dm->validReturn('https://example.com/path')); + } + + public function testValidReturnWithInvalidUrl(): void { + $dm = new DomainManager(false, ''); + self::assertFalse($dm->validReturn('not-a-url')); + } + + public function testValidReturnWithEmptyString(): void { + $dm = new DomainManager(false, ''); + self::assertFalse($dm->validReturn('')); + } + + public function testValidReturnWithAuthSubdomainMatching(): void { + $dm = new DomainManager(true, 'auth.example.com'); + self::assertTrue($dm->validReturn('https://www.example.com/path')); + } + + public function testValidReturnWithAuthSubdomainNotMatching(): void { + $dm = new DomainManager(true, 'auth.example.com'); + self::assertFalse($dm->validReturn('https://evil.com/path')); + } + + public function testValidReturnWithNoHost(): void { + $dm = new DomainManager(true, 'auth.example.com'); + self::assertFalse($dm->validReturn('javascript:void(0)')); + } + + public function testMatchesAuthWithMultiPartTld(): void { + $dm = new DomainManager(true, 'auth.example.com.br'); + self::assertTrue($dm->matchesAuth('www.example.com.br')); + } + + public function testMatchesAuthWithUkCo(): void { + $dm = new DomainManager(true, 'auth.example.co.uk'); + self::assertTrue($dm->matchesAuth('www.example.co.uk')); + } + + public function testMatchesAuthWithOrgUk(): void { + $dm = new DomainManager(true, 'auth.example.org.uk'); + self::assertTrue($dm->matchesAuth('www.example.org.uk')); + } +} diff --git a/tests/Unit/Service/LoginManagerTest.php b/tests/Unit/Service/LoginManagerTest.php new file mode 100644 index 0000000..c887b57 --- /dev/null +++ b/tests/Unit/Service/LoginManagerTest.php @@ -0,0 +1,345 @@ +createMock(CacheItemInterface::class); + $item->method('getKey')->willReturn($key); + $item->method('get')->willReturn($value); + $item->method('isHit')->willReturn($isHit); + $item->method('set')->willReturnSelf(); + $item->method('expiresAfter')->willReturnSelf(); + return $item; + } + + private function createConfigBag(int $ipTtl = 1800): ConfigBag { + $clock = $this->createMock(ClockInterface::class); + $utilities = $this->createMock(\App\Utilities::class); + $totp = TOTP::generate($clock); + $totp->setLabel('Test'); + + return new ConfigBag( + $utilities, + $clock, + 3600, + $totp->getProvisioningUri(), + $ipTtl, + false, + 'Error', + 'Teapot', + 'Too Many' + ); + } + + private function createManager( + CacheItemPoolInterface $cache, + ?BackupCodeManager $backupCodeManager = null, + ?DomainManager $domainManager = null + ): LoginManager { + $bcm = $backupCodeManager ?? $this->createMock(BackupCodeManager::class); + $dm = $domainManager ?? new DomainManager(false, ''); + $manager = new LoginManager($cache, $bcm, $dm); + $manager->setConfig($this->createConfigBag()); + $manager->setLogger($this->createMock(LoggerInterface::class)); + + $nonceCache = $this->createMock(CacheItemPoolInterface::class); + $nonceItem = $this->createMockItem('nonce_abc', true, true); + $nonceCache->method('getItem')->willReturn($nonceItem); + $nonceCache->method('save')->willReturn(true); + $manager->setNonceCache($nonceCache); + + return $manager; + } + + public function testCheckTokenWithValidTotpAndCookieScope(): void { + $cache = $this->createMock(CacheItemPoolInterface::class); + $keyListItem = $this->createMockItem('__key_list', [], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + + $cache->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + $cache->method('getItem') + ->willReturnCallback(function ($key) { + if (str_starts_with($key, 'cookie_')) { + return $this->createMockItem($key, null, false); + } + return $this->createMockItem($key, null, false); + }); + $cache->method('saveDeferred')->willReturn(true); + $cache->method('commit')->willReturn(true); + $cache->method('save')->willReturn(true); + + $manager = $this->createManager($cache); + + $clock = $this->createMock(ClockInterface::class); + $totp = TOTP::generate($clock); + $totp->setLabel('Test'); + + $payload = new Payload(); + $payload->id = 'user1'; + $payload->token = $totp->now(); + $payload->nonce = 'abc'; + $payload->json = true; + $payload->scope = Scope::Cookie; + + $request = Request::create('https://example.com/'); + $response = $manager->checkToken($payload, $request); + + self::assertNotNull($response); + self::assertSame(303, $response->getStatusCode()); + self::assertStringContainsString('Login successful', $response->getContent()); + self::assertTrue($response->headers->hasCookie('__Host-Http-Preauth')); + } + + public function testCheckTokenWithValidTotpAndNoneScope(): void { + $cache = $this->createMock(CacheItemPoolInterface::class); + $keyListItem = $this->createMockItem('__key_list', [], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + + $cache->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + + $manager = $this->createManager($cache); + + $clock = $this->createMock(ClockInterface::class); + $totp = TOTP::generate($clock); + $totp->setLabel('Test'); + + $payload = new Payload(); + $payload->id = 'user1'; + $payload->token = $totp->now(); + $payload->nonce = 'abc'; + $payload->json = false; + $payload->scope = Scope::None; + + $request = Request::create('https://example.com/'); + $response = $manager->checkToken($payload, $request); + + self::assertNotNull($response); + self::assertSame(200, $response->getStatusCode()); + self::assertSame('hi user1', $response->getContent()); + self::assertFalse($response->headers->hasCookie('__Host-Http-Preauth')); + } + + public function testCheckTokenWithInvalidToken(): void { + $cache = $this->createMock(CacheItemPoolInterface::class); + $keyListItem = $this->createMockItem('__key_list', [], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + + $cache->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + + $manager = $this->createManager($cache); + + $payload = new Payload(); + $payload->id = 'user1'; + $payload->token = 'invalid'; + $payload->nonce = 'abc'; + $payload->json = true; + $payload->scope = Scope::Cookie; + + $request = Request::create('https://example.com/'); + $response = $manager->checkToken($payload, $request); + + self::assertNull($response); + } + + public function testCheckTokenWithValidBackupCode(): void { + $cache = $this->createMock(CacheItemPoolInterface::class); + $keyListItem = $this->createMockItem('__key_list', [], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + + $cache->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + $cache->method('getItem') + ->willReturnCallback(function ($key) { + if (str_starts_with($key, 'cookie_')) { + return $this->createMockItem($key, null, false); + } + return $this->createMockItem($key, null, false); + }); + $cache->method('saveDeferred')->willReturn(true); + $cache->method('commit')->willReturn(true); + $cache->method('save')->willReturn(true); + + $backupManager = $this->createMock(BackupCodeManager::class); + $backupManager->method('verifyAndConsume')->willReturn(true); + + $manager = $this->createManager($cache, $backupManager); + + $payload = new Payload(); + $payload->id = 'user1'; + $payload->token = 'backup123'; + $payload->nonce = 'abc'; + $payload->json = true; + $payload->scope = Scope::Cookie; + + $request = Request::create('https://example.com/'); + $response = $manager->checkToken($payload, $request); + + self::assertNotNull($response); + } + + public function testCheckTokenWithIpScopeWhenDisabled(): void { + $cache = $this->createMock(CacheItemPoolInterface::class); + $keyListItem = $this->createMockItem('__key_list', [], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + + $cache->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + $cache->method('getItem') + ->willReturnCallback(function ($key) { + if (str_starts_with($key, 'cookie_')) { + return $this->createMockItem($key, null, false); + } + return $this->createMockItem($key, null, false); + }); + $cache->method('saveDeferred')->willReturn(true); + $cache->method('commit')->willReturn(true); + $cache->method('save')->willReturn(true); + + $manager = $this->createManager($cache); + + $clock = $this->createMock(ClockInterface::class); + $totp = TOTP::generate($clock); + $totp->setLabel('Test'); + + $payload = new Payload(); + $payload->id = 'user1'; + $payload->token = $totp->now(); + $payload->nonce = 'abc'; + $payload->json = true; + $payload->scope = Scope::Ip; + + $request = Request::create('https://example.com/'); + $response = $manager->checkToken($payload, $request); + + self::assertNotNull($response); + // When ipTtl is 0, scope falls back to Cookie + self::assertTrue($response->headers->hasCookie('__Host-Http-Preauth')); + } + + public function testCheckTokenWithInvalidNonce(): void { + $cache = $this->createMock(CacheItemPoolInterface::class); + $keyListItem = $this->createMockItem('__key_list', [], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + + $cache->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + + $manager = $this->createManager($cache); + + $nonceCache = $this->createMock(CacheItemPoolInterface::class); + $nonceItem = $this->createMockItem('nonce_bad', false, true); + $nonceCache->method('getItem')->willReturn($nonceItem); + $manager->setNonceCache($nonceCache); + + $clock = $this->createMock(ClockInterface::class); + $totp = TOTP::generate($clock); + $totp->setLabel('Test'); + + $payload = new Payload(); + $payload->id = 'user1'; + $payload->token = $totp->now(); + $payload->nonce = 'bad'; + $payload->json = true; + $payload->scope = Scope::None; + + $request = Request::create('https://example.com/'); + $response = $manager->checkToken($payload, $request); + + self::assertNull($response); + } + + public function testCheckTokenWithMissingNonce(): void { + $cache = $this->createMock(CacheItemPoolInterface::class); + $keyListItem = $this->createMockItem('__key_list', [], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + + $cache->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + + $manager = $this->createManager($cache); + + $nonceCache = $this->createMock(CacheItemPoolInterface::class); + $nonceItem = $this->createMockItem('nonce_missing', null, false); + $nonceCache->method('getItem')->willReturn($nonceItem); + $manager->setNonceCache($nonceCache); + + $clock = $this->createMock(ClockInterface::class); + $totp = TOTP::generate($clock); + $totp->setLabel('Test'); + + $payload = new Payload(); + $payload->id = 'user1'; + $payload->token = $totp->now(); + $payload->nonce = 'missing'; + $payload->json = true; + $payload->scope = Scope::None; + + $request = Request::create('https://example.com/'); + $response = $manager->checkToken($payload, $request); + + self::assertNull($response); + } + + public function testCheckTokenWithUlidCollisionThrows(): void { + $cache = $this->createMock(CacheItemPoolInterface::class); + $keyListItem = $this->createMockItem('__key_list', [], true); + $changeListItem = $this->createMockItem('__chg_list', [], true); + + $cache->method('getItems') + ->with(['__key_list', '__chg_list']) + ->willReturn([$keyListItem, $changeListItem]); + $cache->method('getItem') + ->willReturnCallback(function ($key) { + if (str_starts_with($key, 'cookie_')) { + // Simulate collision + return $this->createMockItem($key, 'existing', true); + } + return $this->createMockItem($key, null, false); + }); + + $manager = $this->createManager($cache); + + $clock = $this->createMock(ClockInterface::class); + $totp = TOTP::generate($clock); + $totp->setLabel('Test'); + + $payload = new Payload(); + $payload->id = 'user1'; + $payload->token = $totp->now(); + $payload->nonce = 'abc'; + $payload->json = true; + $payload->scope = Scope::Cookie; + + $request = Request::create('https://example.com/'); + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Internal Server Error'); + $manager->checkToken($payload, $request); + } +} 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/StringTraitTest.php b/tests/Unit/Trait/StringTraitTest.php new file mode 100644 index 0000000..fa84b2c --- /dev/null +++ b/tests/Unit/Trait/StringTraitTest.php @@ -0,0 +1,40 @@ +makeCacheKey('hello')); + } + + public function testMakeCacheKeyReplacesInvalidChars(): void { + self::assertSame('hello_world', $this->makeCacheKey('hello world')); + self::assertSame('a_b_c_', $this->makeCacheKey('a!b@c#')); + self::assertSame('test_key', $this->makeCacheKey('test-key')); + } + + public function testMakeCacheKeyTruncatesTo128(): void { + $long = str_repeat('a', 200); + self::assertSame(128, mb_strlen($this->makeCacheKey($long))); + self::assertSame(str_repeat('a', 128), $this->makeCacheKey($long)); + } + + public function testMakeCacheKeyWithUnicode(): void { + self::assertSame('_', $this->makeCacheKey('日本語')); + self::assertSame('hello_world', $this->makeCacheKey('hello日本語world')); + } + + public function testMakeCacheKeyWithDotsAndUnderscores(): void { + self::assertSame('key.name_1', $this->makeCacheKey('key.name_1')); + } + + public function testMakeCacheKeyEmptyString(): void { + self::assertSame('', $this->makeCacheKey('')); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..b06dbb5 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,13 @@ +bootEnv(dirname(__DIR__).'/.env'); +} + +if ($_SERVER['APP_DEBUG'] ?? false) { + umask(0000); +}