From ed959888b4f45c5b1f120002516e1660138a10c8 Mon Sep 17 00:00:00 2001 From: Timothy Allen Date: Sat, 26 Sep 2026 03:48:09 +0200 Subject: [PATCH] Add PHP unit tests and page tests. Run "composer install", then "composer test". --- .gitignore | 5 + composer.json | 20 + composer.lock | 1937 +++++++++++++++++++++++++ phpunit.xml.dist | 45 + tests/php/Pages/AddEditorTest.php | 67 + tests/php/Pages/EditTest.php | 92 ++ tests/php/Pages/FeedAndImageTest.php | 96 ++ tests/php/Pages/FrontPagesTest.php | 97 ++ tests/php/Pages/LoginTest.php | 178 +++ tests/php/Pages/SubmitTest.php | 107 ++ tests/php/Support/FakeDb.php | 100 ++ tests/php/Support/FakeRecaptcha.php | 67 + tests/php/Support/FakeResult.php | 27 + tests/php/Support/Mail.php | 41 + tests/php/Support/PageTestCase.php | 204 +++ tests/php/Support/PhpProcess.php | 46 + tests/php/Support/SiteData.php | 184 +++ tests/php/Support/TestCase.php | 90 ++ tests/php/Unit/CaptchaTest.php | 136 ++ tests/php/Unit/ConfigTest.php | 109 ++ tests/php/Unit/DeleteStoryTest.php | 55 + tests/php/Unit/DisplayStoriesTest.php | 144 ++ tests/php/Unit/EmailTest.php | 45 + tests/php/Unit/EntitiesTest.php | 42 + tests/php/Unit/HtmlTest.php | 54 + tests/php/Unit/LoginThrottleTest.php | 116 ++ tests/php/Unit/NavigationTest.php | 85 ++ tests/php/Unit/NumberWordsTest.php | 55 + tests/php/Unit/OpenDbTest.php | 82 ++ tests/php/Unit/PasswordTest.php | 70 + tests/php/Unit/QueryTest.php | 135 ++ tests/php/Unit/RequestArgsTest.php | 256 ++++ tests/php/Unit/SanitiseTest.php | 73 + tests/php/Unit/SaveDataTest.php | 151 ++ tests/php/Unit/SessionTest.php | 187 +++ tests/php/Unit/UploadTest.php | 115 ++ tests/php/Unit/ValidateDataTest.php | 84 ++ tests/php/bootstrap.php | 22 + tests/php/environment.php | 25 + tests/php/fixtures/fake-sendmail.php | 8 + tests/php/fixtures/router.php | 60 + tests/php/fixtures/test.env | 10 + 42 files changed, 5522 insertions(+) create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 phpunit.xml.dist create mode 100644 tests/php/Pages/AddEditorTest.php create mode 100644 tests/php/Pages/EditTest.php create mode 100644 tests/php/Pages/FeedAndImageTest.php create mode 100644 tests/php/Pages/FrontPagesTest.php create mode 100644 tests/php/Pages/LoginTest.php create mode 100644 tests/php/Pages/SubmitTest.php create mode 100644 tests/php/Support/FakeDb.php create mode 100644 tests/php/Support/FakeRecaptcha.php create mode 100644 tests/php/Support/FakeResult.php create mode 100644 tests/php/Support/Mail.php create mode 100644 tests/php/Support/PageTestCase.php create mode 100644 tests/php/Support/PhpProcess.php create mode 100644 tests/php/Support/SiteData.php create mode 100644 tests/php/Support/TestCase.php create mode 100644 tests/php/Unit/CaptchaTest.php create mode 100644 tests/php/Unit/ConfigTest.php create mode 100644 tests/php/Unit/DeleteStoryTest.php create mode 100644 tests/php/Unit/DisplayStoriesTest.php create mode 100644 tests/php/Unit/EmailTest.php create mode 100644 tests/php/Unit/EntitiesTest.php create mode 100644 tests/php/Unit/HtmlTest.php create mode 100644 tests/php/Unit/LoginThrottleTest.php create mode 100644 tests/php/Unit/NavigationTest.php create mode 100644 tests/php/Unit/NumberWordsTest.php create mode 100644 tests/php/Unit/OpenDbTest.php create mode 100644 tests/php/Unit/PasswordTest.php create mode 100644 tests/php/Unit/QueryTest.php create mode 100644 tests/php/Unit/RequestArgsTest.php create mode 100644 tests/php/Unit/SanitiseTest.php create mode 100644 tests/php/Unit/SaveDataTest.php create mode 100644 tests/php/Unit/SessionTest.php create mode 100644 tests/php/Unit/UploadTest.php create mode 100644 tests/php/Unit/ValidateDataTest.php create mode 100644 tests/php/bootstrap.php create mode 100644 tests/php/environment.php create mode 100644 tests/php/fixtures/fake-sendmail.php create mode 100644 tests/php/fixtures/router.php create mode 100644 tests/php/fixtures/test.env diff --git a/.gitignore b/.gitignore index cd93f85..a54c077 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,8 @@ # Old upload directory; uploads now go to UPLOAD_DIR outside the web root graphics/tmp/ + +# Test tools (composer install, npm install) +/vendor/ +/node_modules/ +/.phpunit.cache/ diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..5cb05a1 --- /dev/null +++ b/composer.json @@ -0,0 +1,20 @@ +{ + "name": "xxx/xxx", + "description": "x.x.x, a site of hundred-word stories", + "type": "project", + "license": "proprietary", + "require-dev": { + "phpunit/phpunit": "^13.3" + }, + "autoload-dev": { + "psr-4": { + "Xxx\\Tests\\": "tests/php/" + } + }, + "config": { + "sort-packages": true + }, + "scripts": { + "test": "phpunit" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..2b055bb --- /dev/null +++ b/composer.lock @@ -0,0 +1,1937 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "5a6a22f28f18aca6b2eeff252e8a5b90", + "packages": [], + "packages-dev": [ + { + "name": "myclabs/deep-copy", + "version": "1.14.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2026-08-11T10:17:44+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.9.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0" + }, + "time": "2026-09-13T18:51:52+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.3.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "96af7aaa1e15561a67b2fa5b98906b063ebec9c2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/96af7aaa1e15561a67b2fa5b98906b063ebec9c2", + "reference": "96af7aaa1e15561a67b2fa5b98906b063ebec9c2", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.9.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.2", + "sebastian/version": "^7.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^13.3.4" + }, + "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.3.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.3.5" + }, + "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-09-25T08:28:49+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "9bb4e6c58b62c1e043be995c66abec7c97307aae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/9bb4e6c58b62c1e043be995c66abec7c97307aae", + "reference": "9bb4e6c58b62c1e043be995c66abec7c97307aae", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.3.1" + }, + "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.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/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-08-25T14:47:43+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.3.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "1b482b9a77774705a5c4a47ab7d60819d2589e90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/1b482b9a77774705a5c4a47ab7d60819d2589e90", + "reference": "1b482b9a77774705a5c4a47ab7d60819d2589e90", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.14.0", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.4.1", + "phpunit/php-code-coverage": "^14.3.5", + "phpunit/php-file-iterator": "^7.0.2", + "phpunit/php-invoker": "^7.0.0", + "phpunit/php-text-template": "^6.0.0", + "phpunit/php-timer": "^9.0.0", + "sebastian/cli-parser": "^5.0.1", + "sebastian/comparator": "^8.4", + "sebastian/diff": "^9.0.1", + "sebastian/environment": "^9.3.2", + "sebastian/exporter": "^8.2.1", + "sebastian/file-filter": "^1.0", + "sebastian/git-state": "^1.0", + "sebastian/global-state": "^9.0.1", + "sebastian/object-enumerator": "^8.1.0", + "sebastian/recursion-context": "^8.0.1", + "sebastian/type": "^7.0.2", + "sebastian/version": "^7.0.0", + "staabm/side-effects-detector": "^1.0.5" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "13.3-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.3.5" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-09-25T08:41:20+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "eeb759ad3146b7096fb59c3195d39e071cd409e3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/eeb759ad3146b7096fb59c3195d39e071cd409e3", + "reference": "eeb759ad3146b7096fb59c3195d39e071cd409e3", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.2.6" + }, + "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.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/cli-parser", + "type": "tidelift" + } + ], + "time": "2026-08-01T04:27:14+00:00" + }, + { + "name": "sebastian/comparator", + "version": "8.4.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "3b070e608146cba00fd6fd1f0ffba89e5a8897fb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/3b070e608146cba00fd6fd1f0ffba89e5a8897fb", + "reference": "3b070e608146cba00fd6fd1f0ffba89e5a8897fb", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/diff": "^9.0", + "sebastian/exporter": "^8.2" + }, + "require-dev": { + "phpunit/phpunit": "^13.3" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.4-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.4.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-08-07T07:23:13+00:00" + }, + { + "name": "sebastian/complexity", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/c5651c795c98093480df79350cb050813fc7a2f3", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/complexity", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:41:32+00:00" + }, + { + "name": "sebastian/diff", + "version": "9.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "a2df6626c1baf31d5a88674882a3072f151b5a26" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/a2df6626c1baf31d5a88674882a3072f151b5a26", + "reference": "a2df6626c1baf31d5a88674882a3072f151b5a26", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.3.1", + "symfony/process": "^7.4.17" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/9.0.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/diff", + "type": "tidelift" + } + ], + "time": "2026-08-25T15:38:55+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.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "24a3b69bba4a12ab615fca9d34680c5598d9ab7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/24a3b69bba4a12ab615fca9d34680c5598d9ab7a", + "reference": "24a3b69bba4a12ab615fca9d34680c5598d9ab7a", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/recursion-context": "^8.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^13.3" + }, + "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": "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.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/exporter", + "type": "tidelift" + } + ], + "time": "2026-08-07T07:22:06+00:00" + }, + { + "name": "sebastian/file-filter", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/file-filter.git", + "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/file-filter/zipball/33a26f394330f6faa7684bb9cc73afb7727aae93", + "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for filtering files", + "homepage": "https://github.com/sebastianbergmann/file-filter", + "support": { + "issues": "https://github.com/sebastianbergmann/file-filter/issues", + "security": "https://github.com/sebastianbergmann/file-filter/security/policy", + "source": "https://github.com/sebastianbergmann/file-filter/tree/1.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/file-filter", + "type": "tidelift" + } + ], + "time": "2026-04-22T07:20:04+00:00" + }, + { + "name": "sebastian/git-state", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/git-state.git", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/git-state/zipball/792a952e0eba55b6960a48aeceb9f371aad1f76b", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for describing the state of a Git checkout", + "homepage": "https://github.com/sebastianbergmann/git-state", + "support": { + "issues": "https://github.com/sebastianbergmann/git-state/issues", + "security": "https://github.com/sebastianbergmann/git-state/security/policy", + "source": "https://github.com/sebastianbergmann/git-state/tree/1.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/git-state", + "type": "tidelift" + } + ], + "time": "2026-03-21T12:54:28+00:00" + }, + { + "name": "sebastian/global-state", + "version": "9.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^13.1.13" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/9.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2026-06-01T15:11:33+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d1b6f8fce682505dbd048977f1abedf1b8ad3ff8", + "reference": "d1b6f8fce682505dbd048977f1abedf1b8ad3ff8", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.8.0", + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.2.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" + } + ], + "time": "2026-07-09T08:42:34+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "8.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "511064ecde82bd747e2ba2fab3dda8d977b59576" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/511064ecde82bd747e2ba2fab3dda8d977b59576", + "reference": "511064ecde82bd747e2ba2fab3dda8d977b59576", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "sebastian/recursion-context": "^8.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^13.3.0" + }, + "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" + } + ], + "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.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/object-enumerator", + "type": "tidelift" + } + ], + "time": "2026-08-13T07:05:05+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "6.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "f71bbcdc4f95456b4622810bec64eb06372e25b2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/f71bbcdc4f95456b4622810bec64eb06372e25b2", + "reference": "f71bbcdc4f95456b4622810bec64eb06372e25b2", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.3.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.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/object-reflector", + "type": "tidelift" + } + ], + "time": "2026-08-13T06:34:36+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "8.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0", + "reference": "32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.2.6" + }, + "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.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/recursion-context", + "type": "tidelift" + } + ], + "time": "2026-08-03T05:58:12+00:00" + }, + { + "name": "sebastian/type", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "bd1df467864cb95140414059a535b2d906173fcf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/bd1df467864cb95140414059a535b2d906173fcf", + "reference": "bd1df467864cb95140414059a535b2d906173fcf", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.3.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": "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.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/type", + "type": "tidelift" + } + ], + "time": "2026-08-10T08:00:57+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": "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": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..7e49e24 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,45 @@ + + + + + + + + + + tests/php/Unit + + + tests/php/Pages + + + + + . + + + vendor + node_modules + tests + lib/htmlpurifier-4.19.1 + lib/smartypants.inc.php + + + diff --git a/tests/php/Pages/AddEditorTest.php b/tests/php/Pages/AddEditorTest.php new file mode 100644 index 0000000..4552970 --- /dev/null +++ b/tests/php/Pages/AddEditorTest.php @@ -0,0 +1,67 @@ +setTables(array('editors' => array(array('username' => 'ed', 'password' => 'x', 'email' => '')))); + $this->token = $this->loginAs(); + } + + private function add(array $fields = array()): array { + return $this->post('/publish/addeditor', $fields + array('adduser' => '1', 'username' => 'newbie', 'password' => 'pass word', + 'email' => 'new@example.org', 'csrf_token' => $this->token)); + } + + public function testVisitorsAreSentToTheLogin(): void { + $this->cookies = array(); + $this->assertRedirect('https://xxx.org.za/publish/login', $this->get('/publish/addeditor')); + $this->assertRedirect('https://xxx.org.za/publish/login', $this->add(array('csrf_token' => ''))); + $this->assertCount(1, $this->tables()['editors']); + } + + public function testPlainHttpIsSentToHttps(): void { + $this->assertRedirect('https://xxx.org.za/publish/addeditor', $this->get('/publish/addeditor', array('X-Test-Plain' => '1'))); + } + + public function testTheFormHasTheToken(): void { + $this->assertSame($this->token, $this->csrfToken($this->get('/publish/addeditor')['body'])); + } + + public function testAnEditorIsAddedWithAPasswordHash(): void { + $answer = $this->add(); + $this->assertStringContainsString('

Added editor newbie

', $answer['body']); + $row = $this->tables()['editors'][1]; + $this->assertSame(array('newbie', 'new@example.org'), array($row['username'], $row['email'])); + $this->assertTrue(password_verify('pass word', $row['password'])); + } + + public function testTheTokenIsChecked(): void { + $answer = $this->add(array('csrf_token' => 'wrong')); + $this->assertStringContainsString('Your session has expired. Please try again.', $answer['body']); + $this->assertCount(1, $this->tables()['editors']); + } + + public function testAnExistingUsernameIsRefused(): void { + $answer = $this->add(array('username' => 'ed')); + $this->assertStringContainsString('An editor with that username already exists.', $answer['body']); + $this->assertSame('x', $this->tables()['editors'][0]['password']); + } + + public function testAHashThatDoesNotFitIsNotKept(): void { + $this->setTables(array('editors' => array()), 40); + $answer = $this->add(); + $this->assertStringContainsString('the editors.password column is too short', $answer['body']); + $this->assertSame(array(), $this->tables()['editors']); + } + + public function testTheUsernameIsEscaped(): void { + $body = $this->add(array('username' => 'x'))['body']; + $this->assertStringContainsString('Added editor <b>x</b>', $body); + } +} diff --git a/tests/php/Pages/EditTest.php b/tests/php/Pages/EditTest.php new file mode 100644 index 0000000..878aa71 --- /dev/null +++ b/tests/php/Pages/EditTest.php @@ -0,0 +1,92 @@ +setTables(array( + 'xxx' => array(array('id' => 1, 'title' => 'Live title', 'date' => '2021-01-01 10:00:00'), + array('id' => 2, 'title' => 'Other live', 'date' => '2021-01-02 10:00:00')), + 'queue' => array(array('id' => 5, 'title' => 'Queued title', 'date' => '2021-02-01 10:00:00')), + )); + $this->token = $this->loginAs(); + } + + private function story(array $fields): array { + return $fields + array('title' => 'Changed', 'author' => 'Ann', 'story' => '

New text.

', 'csrf_token' => $this->token); + } + + public function testVisitorsAreSentToTheLogin(): void { + $this->cookies = array(); + $this->assertRedirect('https://xxx.org.za/publish/login', $this->get('/publish/edit')); + } + + public function testPlainHttpIsSentToHttps(): void { + $this->assertRedirect('https://xxx.org.za/publish/edit?id=1', $this->get('/publish/edit?id=1', array('X-Test-Plain' => '1'))); + } + + public function testTheQueueIsShownFirst(): void { + $body = $this->get('/publish/edit')['body']; + $this->assertStringContainsString('Queued title', $body); + $this->assertStringNotContainsString('Live title', $body); + $this->assertStringContainsString('click here', $body); + } + + public function testTheLiveStoriesCanBeChosen(): void { + $body = $this->get('/publish/edit?queue=0')['body']; + $this->assertStringContainsString('Live title', $body); + $this->assertStringContainsString('Other live', $body); + $this->assertStringNotContainsString('Queued title', $body); + } + + public function testAStoryOpensInTheForm(): void { + $body = $this->get('/publish/edit?id=1&queue=0')['body']; + $this->assertStringContainsString('', $body); + $this->assertStringContainsString('', $body); + $this->assertStringContainsString('value="delete"', $body); + $this->assertSame($this->token, $this->csrfToken($body)); + } + + public function testEditorsUpdateALiveStory(): void { + $page = $this->post('/publish/edit', $this->story(array('action' => 'post', 'id' => '1', 'queue' => '0'))); + $this->assertSame(200, $page['status']); + $this->assertSame('Changed', $this->tables()['xxx'][0]['title']); + $this->assertSame(array(), $this->mails()); + } + + public function testPostingFromTheQueueMovesTheStory(): void { + $this->post('/publish/edit', $this->story(array('action' => 'post', 'id' => '5', 'queue' => '1'))); + $tables = $this->tables(); + $this->assertSame(array(), $tables['queue']); + $this->assertSame(array('Live title', 'Other live', 'Changed'), array_column($tables['xxx'], 'title')); + } + + public function testSavingKeepsTheStoryInTheQueue(): void { + $this->post('/publish/edit', $this->story(array('action' => 'save', 'id' => '5', 'queue' => '1', 'comments' => 'Needs work'))); + $row = $this->tables()['queue'][0]; + $this->assertSame(array('5', 'Changed', 'Needs work'), array($row['id'], $row['title'], $row['comments'])); + } + + public function testDeletingNeedsTheToken(): void { + $page = $this->post('/publish/edit', array('action' => 'delete', 'id' => '1', 'queue' => '0', 'csrf_token' => 'wrong')); + $this->assertStringContainsString('Your session has expired.', $page['body']); + $this->assertCount(2, $this->tables()['xxx']); + $this->assertSame(array(), $this->queries('/^DELETE/')); + } + + public function testDeletingAStory(): void { + $page = $this->post('/publish/edit', array('action' => 'delete', 'id' => '1', 'queue' => '0', 'csrf_token' => $this->token)); + $this->assertStringContainsString('Article has been deleted.', $page['body']); + $this->assertSame(array('Other live'), array_column($this->tables()['xxx'], 'title')); + } + + public function testDeletingCannotBeDoneByGet(): void { + $this->get('/publish/edit?action=delete&id=1&queue=0&csrf_token=' . $this->token); + $this->assertCount(2, $this->tables()['xxx']); + } +} diff --git a/tests/php/Pages/FeedAndImageTest.php b/tests/php/Pages/FeedAndImageTest.php new file mode 100644 index 0000000..31cd237 --- /dev/null +++ b/tests/php/Pages/FeedAndImageTest.php @@ -0,0 +1,96 @@ +setTables(array( + 'xxx' => array( + array('id' => 1, 'title' => 'Tom & "Jerry"', 'author' => 'Ann ', 'date' => '2021-01-03 10:00:00', + 'story' => "

Wait… a bold\x01 move & more.

"), + array('id' => 2, 'title' => 'Picture', 'date' => '2021-01-02 10:00:00', 'story' => '', 'img' => TestCase::png(), 'mime' => 'text/html'), + array('id' => 3, 'title' => 'Secret', 'date' => '2021-01-01 10:00:00', 'img' => TestCase::png(), 'mime' => 'image/png', 'hidden' => '1'), + array('id' => 4, 'title' => 'Not an image', 'img' => '', 'mime' => 'image/png'), + ), + 'queue' => array( + array('id' => 9, 'title' => 'Waiting', 'img' => TestCase::png(), 'mime' => 'image/png'), + ), + )); + } + + public function testTheFeedIsValidRss(): void { + $feed = $this->get('/feed'); + $this->assertSame('application/rss+xml', $this->header($feed, 'Content-Type')); + $xml = simplexml_load_string($feed['body']); + $this->assertNotFalse($xml, $feed['body']); + $items = $xml->channel->item; + // The front page, then the visible stories, newest first + $this->assertSame(array('Front Page', 'Tom & "Jerry"', 'Picture', 'Not an image'), + array_map('strval', iterator_to_array($xml->xpath('/rss/channel/item/title'), FALSE))); + $this->assertSame('http://xxx.org.za/article?id=1', (string)$items[1]->link); + $this->assertSame('Wait… a bold move & more.', (string)$items[1]->description); + $this->assertSame('Ann ', (string)$items[1]->children('http://purl.org/dc/elements/1.1/')->creator); + $this->assertSame('Image: Picture', (string)$items[2]->description); + $this->assertSame(date('D, d M Y H:i:s O', strtotime('2021-01-03 10:00:00')), (string)$xml->channel->lastBuildDate); + } + + public function testAnImageIsServedWithItsRealType(): void { + $image = $this->get('/display?id=2'); + $this->assertSame(200, $image['status']); + $this->assertSame(TestCase::png(), $image['body']); + $this->assertSame('image/png', $this->header($image, 'Content-Type')); + $this->assertSame('nosniff', $this->header($image, 'X-Content-Type-Options')); + $this->assertSame((string)strlen(TestCase::png()), $this->header($image, 'Content-Length')); + } + + public function testDataThatIsNotAnImageIsNotServed(): void { + $image = $this->get('/display?id=4'); + $this->assertSame(404, $image['status']); + $this->assertStringNotContainsString('script', $image['body']); + } + + public function testAMissingImageIsNotFound(): void { + $this->assertSame(404, $this->get('/display?id=99')['status']); + $this->assertSame(404, $this->get('/display?id=1')['status']); + } + + public function testHiddenImagesAreForEditorsOnly(): void { + $this->assertSame(404, $this->get('/display?id=3')['status']); + $this->loginAs(); + $this->assertSame(200, $this->get('/display?id=3')['status']); + } + + public function testQueuedImagesNeedPermission(): void { + $this->assertSame(401, $this->get('/display?id=9&queue=1')['status']); + $this->startSession(array('allow_queue_image' => '8')); + $this->assertSame(401, $this->get('/display?id=9&queue=1')['status']); + $this->startSession(array('allow_queue_image' => '9')); + $this->assertSame(200, $this->get('/display?id=9&queue=1')['status']); + $this->loginAs(); + $this->assertSame(200, $this->get('/display?id=9&queue=1')['status']); + } + + public function testThePendingUploadIsShownToItsSession(): void { + $name = bin2hex(random_bytes(16)); + mkdir(self::$state . '/tmp/uploads', 0700); + file_put_contents(self::$state . "/tmp/uploads/$name", TestCase::png()); + $this->assertSame(404, $this->get('/display?pending=x')['status']); + $this->startSession(array('pending_upload' => $name)); + $image = $this->get('/display?pending=' . substr($name, 0, 8)); + $this->assertSame(200, $image['status']); + $this->assertSame(TestCase::png(), $image['body']); + $this->assertSame('private, no-store', $this->header($image, 'Cache-Control')); + } + + public function testAPendingUploadMustBeAnImage(): void { + $name = bin2hex(random_bytes(16)); + mkdir(self::$state . '/tmp/uploads', 0700); + file_put_contents(self::$state . "/tmp/uploads/$name", 'This is not an image at all.'); + $this->startSession(array('pending_upload' => $name)); + $this->assertSame(404, $this->get('/display?pending=x')['status']); + } +} diff --git a/tests/php/Pages/FrontPagesTest.php b/tests/php/Pages/FrontPagesTest.php new file mode 100644 index 0000000..d3fa0e0 --- /dev/null +++ b/tests/php/Pages/FrontPagesTest.php @@ -0,0 +1,97 @@ +setTables(array('xxx' => array( + array('id' => 1, 'title' => 'Newest', 'author' => 'Ann', 'date' => '2021-01-03 10:00:00', 'story' => '

First story.

'), + array('id' => 2, 'title' => 'Middle', 'author' => 'Bo', 'date' => '2021-01-02 10:00:00', 'story' => '

Second.

'), + array('id' => 3, 'title' => 'Secret', 'author' => 'Cy', 'date' => '2021-01-01 10:00:00', 'story' => '

Hidden.

', 'hidden' => '1'), + ))); + } + + public function testFrontPageListsTheVisibleStories(): void { + $page = $this->get('/'); + $this->assertSame(200, $page['status']); + $this->assertStringContainsString('Newest', $page['body']); + $this->assertStringContainsString('Middle', $page['body']); + $this->assertStringNotContainsString('Secret', $page['body']); + $this->assertStringNotContainsString('onclick', $page['body']); + $this->assertStringEndsWith("\n", $page['body']); + } + + public function testPagesAreNotCached(): void { + $page = $this->get('/'); + $this->assertStringContainsString('no-store', implode(', ', $page['headers']['cache-control'])); + } + + public function testTheSessionCookieIsSecure(): void { + $cookie = $this->header($this->get('/'), 'Set-Cookie'); + $this->assertMatchesRegularExpression('/^PHPSESSID=[0-9a-z,-]+;/', $cookie); + foreach (array('path=/', 'domain=.xxx.org.za', 'secure', 'HttpOnly', 'SameSite=Lax') as $flag) + $this->assertStringContainsString("; $flag", $cookie); + } + + public function testPlainHttpStartsNoSession(): void { + $page = $this->get('/', array('X-Test-Plain' => '1')); + $this->assertSame(200, $page['status']); + $this->assertArrayNotHasKey('set-cookie', $page['headers']); + } + + public function testNoAnalyticsOrEditorLinksForVisitors(): void { + $body = $this->get('/')['body']; + $this->assertStringNotContainsString('google-analytics', $body); + $this->assertStringNotContainsString('/publish/logout', $body); + $this->assertStringNotContainsString('/publish/addeditor', $body); + } + + public function testEditorsSeeHiddenStoriesAndTheirLinks(): void { + $token = $this->loginAs(); + $body = $this->get('/')['body']; + $this->assertStringContainsString('Secret', $body); + $this->assertStringContainsString('Logout', $body); + $this->assertStringContainsString('Add editor', $body); + } + + public function testTheArticlePageShowsOneStory(): void { + $body = $this->get('/article?id=1')['body']; + $this->assertStringContainsString('

First story.

', $body); + $this->assertStringNotContainsString('Middle', $body); + } + + public function testAHiddenStoryIsNotShown(): void { + $body = $this->get('/article?id=3')['body']; + $this->assertStringNotContainsString('Hidden.', $body); + } + + public function testSearchFindsStories(): void { + $body = $this->get('/article?search=second')['body']; + $this->assertStringContainsString('Middle', $body); + $this->assertStringNotContainsString('Newest', $body); + $this->assertSame(array('%second%'), array_slice($this->queries('/LIKE/')[0]['params'], 0, 1)); + } + + public function testTheSearchTermIsEscaped(): void { + $body = $this->get('/article?search=' . rawurlencode('">'))['body']; + $this->assertStringNotContainsString('', $body); + } + + public function testThePageRemembersWhereTheVisitorWas(): void { + $this->get('/article?id=2'); + $this->assertSame('/article?id=2', $this->session()['referrer']); + } + + public function testTheAboutPageLoads(): void { + $page = $this->get('/about'); + $this->assertSame(200, $page['status']); + $this->assertStringContainsString('', $page['body']); + } + + public function testTheTestsAreNotServed(): void { + $this->assertSame(404, $this->get('/tests/php/fixtures/test.env')['status']); + } +} diff --git a/tests/php/Pages/LoginTest.php b/tests/php/Pages/LoginTest.php new file mode 100644 index 0000000..866b9c8 --- /dev/null +++ b/tests/php/Pages/LoginTest.php @@ -0,0 +1,178 @@ +setTables(array('editors' => array( + array('username' => 'ed', 'password' => password_hash('secret', PASSWORD_BCRYPT, array('cost' => 4)), 'email' => 'ed@example.org'), + ))); + } + + // Open the login form, then send it; returns the answer + private function login(string $password = 'secret', array $fields = array()): array { + $token = $this->csrfToken($this->get('/publish/login')['body']); + return $this->post('/publish/login', $fields + array('login' => '1', 'username' => 'ed', 'password' => $password, + 'csrf_token' => $token, 'g-recaptcha-response' => 'pass')); + } + + /* + * After a login, the site goes back to the last page that the session + * showed. With the login form that is the login page itself, which + * sends an editor on to the edit page. + */ + private function assertLoggedIn(array $answer): void { + $this->assertRedirect('https://' . $this->host() . '/publish/login', $answer); + $this->assertRedirect('https://xxx.org.za/publish/edit', $this->get('/publish/login')); + } + + private function host(): string { + return parse_url(self::$base, PHP_URL_HOST) . ':' . parse_url(self::$base, PHP_URL_PORT); + } + + public function testPlainHttpIsSentToHttps(): void { + $this->assertRedirect('https://xxx.org.za/publish/login', $this->get('/publish/login', array('X-Test-Plain' => '1'))); + } + + public function testTheFormHasATokenAndTheCaptcha(): void { + $body = $this->get('/publish/login')['body']; + $this->assertSame($this->session()['csrf_token'], $this->csrfToken($body)); + $this->assertStringContainsString('data-sitekey="test-site-key"', $body); + } + + public function testTheRightPasswordLogsIn(): void { + $this->get('/publish/login'); + $before = $this->cookies['PHPSESSID']; + $this->assertLoggedIn($this->login('secret', array('remember' => NULL))); + // A new session id, and a new CSRF token with it + $this->assertNotSame($before, $this->cookies['PHPSESSID']); + $session = $this->session(); + $this->assertSame('ed', $session['editor']); + $this->assertArrayNotHasKey('csrf_token', $session); + $this->assertArrayNotHasKey('PersistentAuth', $this->cookies); + } + + public function testTheLoginGoesBackToTheLastPage(): void { + $token = $this->csrfToken($this->get('/publish/login')['body']); + $this->get('/article?id=1'); + $answer = $this->post('/publish/login', array('login' => '1', 'username' => 'ed', 'password' => 'secret', + 'csrf_token' => $token, 'g-recaptcha-response' => 'pass')); + $this->assertRedirect('https://' . $this->host() . '/article?id=1', $answer); + $this->assertArrayNotHasKey('referrer', $this->session()); + } + + public function testWithoutALastPageTheLoginGoesToTheEditPage(): void { + $token = $this->csrfToken($this->get('/publish/login')['body']); + $this->startSession(array('csrf_token' => $token)); + $answer = $this->post('/publish/login', array('login' => '1', 'username' => 'ed', 'password' => 'secret', + 'csrf_token' => $token, 'g-recaptcha-response' => 'pass')); + $this->assertRedirect('https://xxx.org.za/publish/edit', $answer); + } + + public function testTheWrongPasswordIsDenied(): void { + $answer = $this->login('wrong'); + $this->assertSame(200, $answer['status']); + $this->assertStringContainsString('Access denied. Please try again.', $answer['body']); + $this->assertArrayNotHasKey('editor', $this->session()); + } + + public function testAnUnknownEditorIsDenied(): void { + $answer = $this->login('secret', array('username' => 'nobody')); + $this->assertStringContainsString('Access denied. Please try again.', $answer['body']); + } + + public function testTheTokenIsChecked(): void { + $this->get('/publish/login'); + $answer = $this->post('/publish/login', array('login' => '1', 'username' => 'ed', 'password' => 'secret', + 'csrf_token' => 'wrong', 'g-recaptcha-response' => 'pass')); + $this->assertStringContainsString('Access denied. Please try again.', $answer['body']); + $this->assertSame(array(), $this->queries('/FROM editors/')); + } + + public function testTheCaptchaIsChecked(): void { + $answer = $this->login('secret', array('g-recaptcha-response' => 'fail')); + $this->assertStringContainsString('Please complete the captcha.', $answer['body']); + $this->assertSame(array(), $this->queries('/FROM editors/')); + } + + public function testFiveFailuresBlockTheLogin(): void { + for ($i = 0; $i < 5; $i++) + $this->login('wrong'); + $answer = $this->login('secret'); + $this->assertStringContainsString('Too many failed logins. Please try again in 15 minutes.', $answer['body']); + $this->assertArrayNotHasKey('editor', $this->session()); + } + + public function testTheBlockIsPerAddress(): void { + for ($i = 0; $i < 5; $i++) + $this->login('wrong'); + $token = $this->csrfToken($this->get('/publish/login')['body']); + $answer = $this->post('/publish/login', array('login' => '1', 'username' => 'ed', 'password' => 'secret', 'csrf_token' => $token, + 'g-recaptcha-response' => 'pass'), array('X-Test-Address' => '198.51.100.7')); + $this->assertLoggedIn($answer); + } + + public function testAnOldHashIsReplacedAtLogin(): void { + $this->setTables(array('editors' => array(array('username' => 'ed', 'password' => crypt('secret', '$1$abcdefgh$'), 'email' => '')))); + $this->assertLoggedIn($this->login()); + $hash = $this->tables()['editors'][0]['password']; + $this->assertFalse(password_needs_rehash($hash, defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_BCRYPT)); + $this->assertTrue(password_verify('secret', $hash)); + } + + public function testAnEditorWhoIsLoggedInGoesToTheEditPage(): void { + $this->loginAs(); + $this->assertRedirect('https://xxx.org.za/publish/edit', $this->get('/publish/login')); + } + + public function testRememberMeKeepsTheEditorLoggedIn(): void { + $this->login('secret', array('remember' => 'on')); + $this->assertMatchesRegularExpression('/^ed\|[0-9a-f]{64}$/', $this->cookies['PersistentAuth']); + $key = explode('|', $this->cookies['PersistentAuth'])[1]; + $this->assertSame(array(hash('sha256', $key)), array_column($this->tables()['persistent_sessions'], 'session')); + + // A new browser session with only the cookie + unset($this->cookies['PHPSESSID']); + $body = $this->get('/')['body']; + $this->assertStringContainsString('/publish/logout?token=', $body); + $this->assertSame('ed', $this->session()['editor']); + } + + public function testAWrongPersistentKeyDoesNotLogIn(): void { + $this->setTables(array('persistent_sessions' => array( + array('username' => 'ed', 'session' => hash('sha256', 'right'), 'created' => gmdate('Y-m-d H:i:s'))))); + $this->cookies['PersistentAuth'] = 'ed|wrong'; + $body = $this->get('/')['body']; + $this->assertStringNotContainsString('/publish/logout', $body); + } + + public function testLogoutNeedsTheToken(): void { + $this->loginAs(); + $this->assertRedirect('https://xxx.org.za/', $this->get('/publish/logout?token=wrong')); + $this->assertSame('ed', $this->session()['editor']); + } + + public function testLogoutEndsTheSessionAndThePersistentLogin(): void { + $this->setTables(array('persistent_sessions' => array( + array('username' => 'ed', 'session' => hash('sha256', 'key'), 'created' => gmdate('Y-m-d H:i:s')), + array('username' => 'ed', 'session' => hash('sha256', 'other device'), 'created' => gmdate('Y-m-d H:i:s'))))); + $token = $this->loginAs('ed', array('referrer' => '/article?id=2')); + $this->cookies['PersistentAuth'] = 'ed|key'; + $session = self::$state . '/sessions/sess_' . $this->cookies['PHPSESSID']; + + $answer = $this->get('/publish/logout?token=' . $token); + $this->assertRedirect('https://' . $this->host() . '/article?id=2', $answer); + $this->assertFileDoesNotExist($session); + $this->assertArrayNotHasKey('PHPSESSID', $this->cookies); + $this->assertArrayNotHasKey('PersistentAuth', $this->cookies); + $this->assertSame(array(hash('sha256', 'other device')), array_column($this->tables()['persistent_sessions'], 'session')); + } + + public function testLogoutDoesNotGoBackToTheLogoutPage(): void { + $token = $this->loginAs('ed', array('referrer' => '/publish/logout')); + $this->assertRedirect('/', $this->get('/publish/logout?token=' . $token)); + } +} diff --git a/tests/php/Pages/SubmitTest.php b/tests/php/Pages/SubmitTest.php new file mode 100644 index 0000000..30c752b --- /dev/null +++ b/tests/php/Pages/SubmitTest.php @@ -0,0 +1,107 @@ +setTables(array('xxx' => array(array('id' => 1, 'title' => 'Live')))); + } + + private function story(array $fields = array()): array { + return $fields + array('title' => 'My story', 'author' => 'Vi', 'email' => 'vi@example.org', + 'story' => '

Once -- upon a "time".

'); + } + + public function testPlainHttpIsSentToHttps(): void { + $this->assertRedirect('https://xxx.org.za/publish', $this->get('/publish', array('X-Test-Plain' => '1'))); + } + + public function testTheFormHasATokenAndTheCaptcha(): void { + $page = $this->get('/publish'); + $this->assertSame(200, $page['status']); + $this->assertSame($this->session()['csrf_token'], $this->csrfToken($page['body'])); + $this->assertStringContainsString('
', $page['body']); + $this->assertStringContainsString('https://www.google.com/recaptcha/api.js', $page['body']); + $this->assertStringContainsString('', $page['body']); + } + + public function testEditorsGetNoCaptcha(): void { + $this->loginAs(); + $body = $this->get('/publish')['body']; + $this->assertStringNotContainsString('recaptcha', $body); + } + + public function testPreviewThenSubmitAddsTheStoryToTheQueue(): void { + $token = $this->csrfToken($this->get('/publish')['body']); + $preview = $this->post('/publish', $this->story(array('action' => 'preview', 'csrf_token' => $token, 'g-recaptcha-response' => 'pass'))); + $this->assertStringContainsString('Please review your submission.', $preview['body']); + // The preview shows the purified story; the typography comes when it is saved + $this->assertStringContainsString('

Once -- upon a "time".

', $preview['body']); + $this->assertStringContainsString('