From 7f4c8845724cf36beb332d0cb490d57f176918fb Mon Sep 17 00:00:00 2001 From: QuickWrite Date: Fri, 25 Sep 2026 18:53:01 +0200 Subject: [PATCH 01/15] Add unit tests for the Http section --- composer.json | 6 +- composer.lock | 1920 +++++++++++++++++++++++- tests/Http/FileResponseFactoryTest.php | 123 ++ tests/Http/FileResponseTest.php | 120 ++ tests/Http/ProcessRunner.php | 52 + tests/Http/RedirectResponseTest.php | 52 + tests/Http/RequestTest.php | 93 ++ tests/Http/RouterTest.php | 192 +++ tests/Http/SessionTest.php | 73 + tests/Http/TemplateResponseTest.php | 94 ++ tests/Http/XSendfileResponseTest.php | 98 ++ 11 files changed, 2820 insertions(+), 3 deletions(-) create mode 100644 tests/Http/FileResponseFactoryTest.php create mode 100644 tests/Http/FileResponseTest.php create mode 100644 tests/Http/ProcessRunner.php create mode 100644 tests/Http/RedirectResponseTest.php create mode 100644 tests/Http/RequestTest.php create mode 100644 tests/Http/RouterTest.php create mode 100644 tests/Http/SessionTest.php create mode 100644 tests/Http/TemplateResponseTest.php create mode 100644 tests/Http/XSendfileResponseTest.php diff --git a/composer.json b/composer.json index 800838e..53225f0 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "php": "^8.4" }, "require-dev": { + "phpunit/phpunit": "^13", "phpstan/phpstan": "^2.2" }, "scripts": { @@ -22,9 +23,10 @@ "ci": [ "@lint", "@analyse", - "@check:migrations" + "@check:migrations", + "@test" ], - "test": "echo 'No test suite yet.' && exit 1", + "test": "vendor/bin/phpunit --bootstrap vendor/autoload.php tests", "start": "php -S localhost:8000 -t public" } } diff --git a/composer.lock b/composer.lock index 755e908..8600e91 100644 --- a/composer.lock +++ b/composer.lock @@ -4,9 +4,244 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8c3a3f8a96d944a3dddbe9606b8337b7", + "content-hash": "f2112414e65b025bfe834b6a013125ee", "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": "phpstan/phpstan", "version": "2.2.13", @@ -70,6 +305,1689 @@ } ], "time": "2026-09-03T20:38:19+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": [], diff --git a/tests/Http/FileResponseFactoryTest.php b/tests/Http/FileResponseFactoryTest.php new file mode 100644 index 0000000..522756c --- /dev/null +++ b/tests/Http/FileResponseFactoryTest.php @@ -0,0 +1,123 @@ +resetConfig(); + } + + protected function tearDown(): void + { + putenv('KITTYSHARE_FILE_SERVER'); + $this->resetConfig(); + + parent::tearDown(); + } + + #[Test] + public function createsByDefaultFileResponse(): void + { + putenv('KITTYSHARE_FILE_SERVER'); + $this->resetConfig(); + + $response = FileResponseFactory::forFile('/tmp/example.txt', 'text/plain'); + + $this->assertInstanceOf(FileResponse::class, $response); + } + + #[Test] + public function createsFileResponseWhenPhpIsConfigured(): void + { + putenv('KITTYSHARE_FILE_SERVER=php'); + $this->resetConfig(); + + $response = FileResponseFactory::forFile('/tmp/example.txt', 'text/plain'); + + $this->assertInstanceOf(FileResponse::class, $response); + } + + #[Test] + public function createsXSendfileResponseWhenConfigured(): void + { + putenv('KITTYSHARE_FILE_SERVER=x-sendfile'); + $this->resetConfig(); + + $response = FileResponseFactory::forFile('/tmp/example.txt', 'text/plain'); + + $this->assertInstanceOf(XSendfileResponse::class, $response); + } + + #[Test] + public function createsXSendfileResponseForApacheAlias(): void + { + putenv('KITTYSHARE_FILE_SERVER=apache'); + $this->resetConfig(); + + $response = FileResponseFactory::forFile('/tmp/example.txt', 'text/plain'); + + $this->assertInstanceOf(XSendfileResponse::class, $response); + } + + #[Test] + public function fallsBackToFileResponseForUnknownBackend(): void + { + putenv('KITTYSHARE_FILE_SERVER=unknown-backend'); + $this->resetConfig(); + + $response = FileResponseFactory::forFile('/tmp/example.txt', 'text/plain'); + + $this->assertInstanceOf(FileResponse::class, $response); + } + + #[Test] + public function forwardsArgumentsToFileResponse(): void + { + putenv('KITTYSHARE_FILE_SERVER=php'); + $this->resetConfig(); + + $response = FileResponseFactory::forFile('/tmp/example.txt', 'text/plain', 201, 4096); + + $this->assertInstanceOf(FileResponse::class, $response); + $this->assertSame('/tmp/example.txt', $this->readProperty($response, 'file')); + $this->assertSame('text/plain', $this->readProperty($response, 'contentType')); + $this->assertSame(201, $this->readProperty($response, 'statusCode')); + $this->assertSame(4096, $this->readProperty($response, 'chunkSize')); + } + + #[Test] + public function forwardsArgumentsToXSendfileResponse(): void + { + putenv('KITTYSHARE_FILE_SERVER=x-sendfile'); + $this->resetConfig(); + + $response = FileResponseFactory::forFile('/tmp/example.txt', 'text/plain', 201); + + $this->assertInstanceOf(XSendfileResponse::class, $response); + $this->assertSame('/tmp/example.txt', $this->readProperty($response, 'file')); + $this->assertSame('text/plain', $this->readProperty($response, 'contentType')); + $this->assertSame(201, $this->readProperty($response, 'statusCode')); + } + + private function resetConfig(): void + { + $property = new ReflectionProperty(ConfigManager::class, 'config'); + $property->setValue(null, null); + } + + private function readProperty(object $object, string $name): mixed + { + $property = new ReflectionProperty($object, $name); + + return $property->getValue($object); + } +} diff --git a/tests/Http/FileResponseTest.php b/tests/Http/FileResponseTest.php new file mode 100644 index 0000000..164896c --- /dev/null +++ b/tests/Http/FileResponseTest.php @@ -0,0 +1,120 @@ +file !== '' && is_file($this->file)) { + unlink($this->file); + } + + $this->file = ''; + + parent::tearDown(); + } + + #[Test] + public function implementsResponse(): void + { + $this->assertInstanceOf(Response::class, new FileResponse('/tmp/example.txt', 'text/plain')); + } + + #[Test] + public function storesConstructorArguments(): void + { + $response = new FileResponse('/tmp/example.txt', 'text/plain', 201, 4096); + + $this->assertSame('/tmp/example.txt', $this->readProperty($response, 'file')); + $this->assertSame('text/plain', $this->readProperty($response, 'contentType')); + $this->assertSame(201, $this->readProperty($response, 'statusCode')); + $this->assertSame(4096, $this->readProperty($response, 'chunkSize')); + } + + #[Test] + public function defaultsToStatus200And8192ByteChunks(): void + { + $response = new FileResponse('/tmp/example.txt', 'text/plain'); + + $this->assertSame(200, $this->readProperty($response, 'statusCode')); + $this->assertSame(8192, $this->readProperty($response, 'chunkSize')); + } + + #[Test] + public function streamsFileContentsInChunks(): void + { + // Content is larger than the chunk size so the read loop runs + // multiple times, and its length is not a multiple of the chunk + // size so the final partial chunk is covered as well. + $content = str_repeat("0123456789abcdef\n", 500); + $this->file = $this->writeTempFile($content); + + $result = ProcessRunner::run($this->sendSnippet($this->file, 'text/plain', 200, 100)); + + $this->assertSame(0, $result['exit'], 'stderr: ' . $result['stderr']); + $this->assertSame($content, $result['stdout']); + $this->assertStringContainsString('CODE:200', $result['stderr']); + } + + #[Test] + public function streamsEmptyFile(): void + { + $this->file = $this->writeTempFile(''); + + $result = ProcessRunner::run($this->sendSnippet($this->file, 'text/plain', 200, 100)); + + $this->assertSame(0, $result['exit'], 'stderr: ' . $result['stderr']); + $this->assertSame('', $result['stdout']); + $this->assertStringContainsString('CODE:200', $result['stderr']); + } + + #[Test] + public function missingFileReturns404WithoutOutput(): void + { + $missing = sys_get_temp_dir() . '/kittyshare-missing-' . uniqid() . '.txt'; + + $result = ProcessRunner::run($this->sendSnippet($missing, 'text/plain', 200, 100)); + + $this->assertSame(0, $result['exit'], 'stderr: ' . $result['stderr']); + $this->assertSame('', $result['stdout']); + $this->assertStringContainsString('CODE:404', $result['stderr']); + } + + private function writeTempFile(string $content): string + { + $file = tempnam(sys_get_temp_dir(), 'kittyshare-file-'); + + if ($file === false) { + $this->fail('Could not create temporary file.'); + } + + file_put_contents($file, $content); + + return $file; + } + + private function sendSnippet(string $file, string $contentType, int $statusCode, int $chunkSize): string + { + return sprintf( + 'require %s; (new \KittyShare\Http\FileResponse(%s, %s, %d, %d))->send(); fwrite(STDERR, "CODE:".var_export(http_response_code(), true));', + ProcessRunner::autoload(), + var_export($file, true), + var_export($contentType, true), + $statusCode, + $chunkSize, + ); + } + + private function readProperty(object $object, string $name): mixed + { + return (new ReflectionProperty($object, $name))->getValue($object); + } +} diff --git a/tests/Http/ProcessRunner.php b/tests/Http/ProcessRunner.php new file mode 100644 index 0000000..b953eac --- /dev/null +++ b/tests/Http/ProcessRunner.php @@ -0,0 +1,52 @@ + ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); + + if (!is_resource($process)) { + throw new RuntimeException('Could not start PHP subprocess.'); + } + + fclose($pipes[0]); + + $stdout = stream_get_contents($pipes[1]); + fclose($pipes[1]); + + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[2]); + + $exit = proc_close($process); + + return [ + 'stdout' => $stdout === false ? '' : $stdout, + 'stderr' => $stderr === false ? '' : $stderr, + 'exit' => $exit, + ]; + } + + /** + * Returns a PHP expression evaluating to the Composer autoloader path, + * for use inside the subprocess snippet. + */ + public static function autoload(): string + { + return var_export(__DIR__ . '/../../vendor/autoload.php', true); + } +} diff --git a/tests/Http/RedirectResponseTest.php b/tests/Http/RedirectResponseTest.php new file mode 100644 index 0000000..4763e32 --- /dev/null +++ b/tests/Http/RedirectResponseTest.php @@ -0,0 +1,52 @@ +assertInstanceOf(Response::class, new RedirectResponse('/login')); + } + + #[Test] + public function defaultsTo302(): void + { + $response = new RedirectResponse('/login'); + + $this->assertSame('/login', $this->readProperty($response, 'url')); + $this->assertSame(302, $this->readProperty($response, 'statusCode')); + } + + #[Test] + public function acceptsCustomStatusCode(): void + { + $response = new RedirectResponse('/login', 301); + + $this->assertSame(301, $this->readProperty($response, 'statusCode')); + } + + #[Test] + public function sendSetsResponseCode(): void + { + (new RedirectResponse('/login', 303))->send(); + + $this->assertSame(303, http_response_code()); + } + + private function readProperty(object $object, string $name): mixed + { + return (new ReflectionProperty($object, $name))->getValue($object); + } +} diff --git a/tests/Http/RequestTest.php b/tests/Http/RequestTest.php new file mode 100644 index 0000000..41c74a6 --- /dev/null +++ b/tests/Http/RequestTest.php @@ -0,0 +1,93 @@ + */ + private array $backupGet = []; + + /** @var array */ + private array $backupPost = []; + + protected function setUp(): void + { + parent::setUp(); + + $this->backupGet = $_GET; + $this->backupPost = $_POST; + } + + protected function tearDown(): void + { + $_GET = $this->backupGet; + $_POST = $this->backupPost; + + parent::tearDown(); + } + + #[Test] + public function exposesMethodUriAndUrlParams(): void + { + $request = new Request(Method::Get, '/share/abc', ['id' => 'abc']); + + $this->assertSame(Method::Get, $request->getMethod()); + $this->assertSame('/share/abc', $request->getUri()); + $this->assertSame(['id' => 'abc'], $request->getUrlParams()); + } + + #[Test] + public function readsUrlParamWithDefault(): void + { + $request = new Request(Method::Get, '/share/abc', ['id' => 'abc']); + + $this->assertSame('abc', $request->urlParam('id')); + $this->assertNull($request->urlParam('missing')); + $this->assertSame('fallback', $request->urlParam('missing', 'fallback')); + } + + #[Test] + public function readsQueryString(): void + { + $_GET = ['name' => 'ada']; + + $request = new Request(Method::Get, '/', []); + + $this->assertSame('ada', $request->get('name')); + } + + #[Test] + public function fallsBackToDefaultForNonStringQueryValue(): void + { + $_GET = ['filter' => ['not', 'a', 'string']]; + + $request = new Request(Method::Get, '/', []); + + $this->assertNull($request->get('filter')); + $this->assertSame('default', $request->get('filter', 'default')); + } + + #[Test] + public function readsPostBody(): void + { + $_POST = ['username' => 'ada']; + + $request = new Request(Method::Post, '/login', []); + + $this->assertSame('ada', $request->post('username')); + } + + #[Test] + public function fallsBackToDefaultForNonStringPostValue(): void + { + $_POST = ['username' => ['not', 'a', 'string']]; + + $request = new Request(Method::Post, '/login', []); + + $this->assertNull($request->post('username')); + $this->assertSame('default', $request->post('username', 'default')); + } +} diff --git a/tests/Http/RouterTest.php b/tests/Http/RouterTest.php new file mode 100644 index 0000000..258baeb --- /dev/null +++ b/tests/Http/RouterTest.php @@ -0,0 +1,192 @@ +resetConfig(); + + $userRepository = $this->createStub(UserRepository::class); + $sessionRepository = $this->createStub(SessionRepository::class); + + $this->router = new Router( + new Dependencies( + userRepository: $userRepository, + setupRepository: $this->createStub(SetupRepository::class), + sessionRepository: $sessionRepository, + shareRepository: $this->createStub(ShareRepository::class), + authenticationManager: new AuthenticationManager($userRepository, $sessionRepository), + ), + ); + } + + protected function tearDown(): void + { + RouterTestStubController::$lastRequest = null; + $this->resetConfig(); + + parent::tearDown(); + } + + #[Test] + public function dispatchesExactRoute(): void + { + $this->router->get('/login', RouterTestStubController::class); + + $response = $this->router->dispatch(Method::Get, '/login'); + + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertNotNull(RouterTestStubController::$lastRequest); + $this->assertSame([], RouterTestStubController::$lastRequest->getUrlParams()); + } + + #[Test] + public function extractsSingleParameter(): void + { + $this->router->get('/share/{id}', RouterTestStubController::class); + + $this->router->dispatch(Method::Get, '/share/abc123'); + + $this->assertSame(['id' => 'abc123'], RouterTestStubController::$lastRequest?->getUrlParams()); + } + + #[Test] + public function extractsCatchAllPathAcrossSegments(): void + { + $this->router->get('/share/{id}/{...path}', RouterTestStubController::class); + + $this->router->dispatch(Method::Get, '/share/abc/folder/sub/file.txt'); + + $this->assertSame( + ['id' => 'abc', 'path' => 'folder/sub/file.txt'], + RouterTestStubController::$lastRequest?->getUrlParams(), + ); + } + + #[Test] + public function normalizesTrailingSlashes(): void + { + $this->router->get('/admin', RouterTestStubController::class); + + $response = $this->router->dispatch(Method::Get, '/admin/'); + + $this->assertInstanceOf(RedirectResponse::class, $response); + } + + #[Test] + public function preservesRootPath(): void + { + $this->router->get('/', RouterTestStubController::class); + + $response = $this->router->dispatch(Method::Get, '/'); + + $this->assertInstanceOf(RedirectResponse::class, $response); + } + + #[Test] + public function firstMatchingRouteWins(): void + { + $this->router->get('/users/{id}', RouterTestStubController::class); + $this->router->get('/users/new', RouterTestStubController::class); + + $this->router->dispatch(Method::Get, '/users/new'); + + $this->assertSame(['id' => 'new'], RouterTestStubController::$lastRequest?->getUrlParams()); + } + + #[Test] + public function distinguishesHttpMethods(): void + { + $this->router->get('/login', RouterTestStubController::class); + $this->router->post('/submit', RouterTestStubController::class); + $this->router->put('/item', RouterTestStubController::class); + $this->router->delete('/item', RouterTestStubController::class); + + $this->assertInstanceOf(RedirectResponse::class, $this->router->dispatch(Method::Post, '/submit')); + $this->assertInstanceOf(RedirectResponse::class, $this->router->dispatch(Method::Put, '/item')); + $this->assertInstanceOf(RedirectResponse::class, $this->router->dispatch(Method::Delete, '/item')); + + // Registered for GET only, so POST falls through to the 404 page. + $this->assertInstanceOf(TemplateResponse::class, $this->router->dispatch(Method::Post, '/login')); + } + + #[Test] + public function unknownPathReturns404Template(): void + { + $response = $this->router->dispatch(Method::Get, '/does-not-exist'); + + $this->assertInstanceOf(TemplateResponse::class, $response); + $this->assertSame('error/404', $this->readProperty($response, 'template')); + $this->assertSame(404, $this->readProperty($response, 'statusCode')); + $this->assertSame('/does-not-exist', $this->readProperty($response, 'parameters')['path']); + } + + #[Test] + public function decodesUrlEncodedParameters(): void + { + $this->router->get('/share/{id}/{...path}', RouterTestStubController::class); + + $this->router->dispatch(Method::Get, '/share/abc/my%20file.txt'); + + $this->assertSame('my file.txt', RouterTestStubController::$lastRequest?->urlParam('path')); + } + + #[Test] + public function preservesLiteralPlusInParameters(): void + { + $this->router->get('/share/{id}/{...path}', RouterTestStubController::class); + + // rawurldecode (not urldecode) keeps a literal "+" intact. + $this->router->dispatch(Method::Get, '/share/abc/a+b'); + + $this->assertSame('a+b', RouterTestStubController::$lastRequest?->urlParam('path')); + + $this->router->dispatch(Method::Get, '/share/abc/a%2Bb'); + + $this->assertSame('a+b', RouterTestStubController::$lastRequest?->urlParam('path')); + } + + private function resetConfig(): void + { + (new ReflectionProperty(ConfigManager::class, 'config'))->setValue(null, null); + } + + private function readProperty(object $object, string $name): mixed + { + return (new ReflectionProperty($object, $name))->getValue($object); + } +} diff --git a/tests/Http/SessionTest.php b/tests/Http/SessionTest.php new file mode 100644 index 0000000..0239ae4 --- /dev/null +++ b/tests/Http/SessionTest.php @@ -0,0 +1,73 @@ +assertSame('value', Session::get('session_test_key')); + } + + #[Test] + public function returnsDefaultForMissingKeys(): void + { + $this->assertNull(Session::get('session_test_key')); + $this->assertSame('fallback', Session::get('session_test_key', 'fallback')); + } + + #[Test] + public function removesKeys(): void + { + Session::set('session_test_key', 'value'); + Session::set('session_test_other', 'other'); + + Session::remove('session_test_key', 'session_test_other'); + + $this->assertNull(Session::get('session_test_key')); + $this->assertNull(Session::get('session_test_other')); + } + + #[Test] + public function pullsValueAndRemovesIt(): void + { + Session::set('session_test_key', 'value'); + + $this->assertSame('value', Session::pull('session_test_key')); + $this->assertNull(Session::get('session_test_key')); + } + + #[Test] + public function pullReturnsDefaultForMissingKeys(): void + { + $this->assertNull(Session::pull('session_test_key')); + $this->assertSame('fallback', Session::pull('session_test_key', 'fallback')); + } + + #[Test] + public function regenerateKeepsSessionData(): void + { + Session::set('session_test_key', 'value'); + + Session::regenerate(); + + $this->assertSame('value', Session::get('session_test_key')); + $this->assertNotSame('', session_id()); + } +} diff --git a/tests/Http/TemplateResponseTest.php b/tests/Http/TemplateResponseTest.php new file mode 100644 index 0000000..8809e2e --- /dev/null +++ b/tests/Http/TemplateResponseTest.php @@ -0,0 +1,94 @@ +assertInstanceOf(Response::class, new TemplateResponse('error/404')); + } + + #[Test] + public function storesConstructorArguments(): void + { + $response = new TemplateResponse( + 'error/404', + ['path' => '/missing', 'baseUrl' => ''], + 404, + ['X-Custom' => 'value'], + ); + + $this->assertSame('error/404', $this->readProperty($response, 'template')); + $this->assertSame(['path' => '/missing', 'baseUrl' => ''], $this->readProperty($response, 'parameters')); + $this->assertSame(404, $this->readProperty($response, 'statusCode')); + $this->assertSame(['X-Custom' => 'value'], $this->readProperty($response, 'headers')); + } + + #[Test] + public function defaultsTo200WithEmptyParametersAndHeaders(): void + { + $response = new TemplateResponse('error/404'); + + $this->assertSame(200, $this->readProperty($response, 'statusCode')); + $this->assertSame([], $this->readProperty($response, 'parameters')); + $this->assertSame([], $this->readProperty($response, 'headers')); + } + + #[Test] + public function rendersTemplateWithStatusCode(): void + { + $response = new TemplateResponse( + 'error/404', + ['path' => '/missing', 'baseUrl' => ''], + 404, + ); + + ob_start(); + try { + $response->send(); + } finally { + $output = (string) ob_get_clean(); + } + + $this->assertSame(404, http_response_code()); + $this->assertStringContainsString('404 Page not found', $output); + $this->assertStringContainsString('/missing', $output); + } + + #[Test] + public function escapesTemplateParameters(): void + { + $response = new TemplateResponse( + 'error/404', + ['path' => '/a&"c"', 'baseUrl' => ''], + 404, + ); + + ob_start(); + try { + $response->send(); + } finally { + $output = (string) ob_get_clean(); + } + + $this->assertStringContainsString('/a<b>&"c"', $output); + $this->assertStringNotContainsString('/a', $output); + } + + private function readProperty(object $object, string $name): mixed + { + return (new ReflectionProperty($object, $name))->getValue($object); + } +} diff --git a/tests/Http/XSendfileResponseTest.php b/tests/Http/XSendfileResponseTest.php new file mode 100644 index 0000000..25eb04d --- /dev/null +++ b/tests/Http/XSendfileResponseTest.php @@ -0,0 +1,98 @@ +file !== '' && is_file($this->file)) { + unlink($this->file); + } + + $this->file = ''; + + parent::tearDown(); + } + + #[Test] + public function implementsResponse(): void + { + $this->assertInstanceOf(Response::class, new XSendfileResponse('/tmp/example.txt', 'text/plain')); + } + + #[Test] + public function storesConstructorArguments(): void + { + $response = new XSendfileResponse('/tmp/example.txt', 'text/plain', 201); + + $this->assertSame('/tmp/example.txt', $this->readProperty($response, 'file')); + $this->assertSame('text/plain', $this->readProperty($response, 'contentType')); + $this->assertSame(201, $this->readProperty($response, 'statusCode')); + } + + #[Test] + public function defaultsToStatus200(): void + { + $response = new XSendfileResponse('/tmp/example.txt', 'text/plain'); + + $this->assertSame(200, $this->readProperty($response, 'statusCode')); + } + + #[Test] + public function existingFileReturnsStatusCodeWithoutBody(): void + { + // Apache (not PHP) sends the body via the X-Sendfile header, so a + // successful response has no body of its own. Headers cannot be + // observed in a CLI subprocess, but the status code proves the + // file-exists branch was taken. + $this->file = tempnam(sys_get_temp_dir(), 'kittyshare-xsend-'); + + if ($this->file === false) { + $this->fail('Could not create temporary file.'); + } + + file_put_contents($this->file, 'hello'); + + $result = ProcessRunner::run($this->sendSnippet($this->file, 'text/plain', 200)); + + $this->assertSame(0, $result['exit'], 'stderr: ' . $result['stderr']); + $this->assertSame('', $result['stdout']); + $this->assertStringContainsString('CODE:200', $result['stderr']); + } + + #[Test] + public function missingFileReturns404WithoutOutput(): void + { + $missing = sys_get_temp_dir() . '/kittyshare-missing-' . uniqid() . '.txt'; + + $result = ProcessRunner::run($this->sendSnippet($missing, 'text/plain', 200)); + + $this->assertSame(0, $result['exit'], 'stderr: ' . $result['stderr']); + $this->assertSame('', $result['stdout']); + $this->assertStringContainsString('CODE:404', $result['stderr']); + } + + private function sendSnippet(string $file, string $contentType, int $statusCode): string + { + return sprintf( + 'require %s; (new \KittyShare\Http\XSendfileResponse(%s, %s, %d))->send(); fwrite(STDERR, "CODE:".var_export(http_response_code(), true));', + ProcessRunner::autoload(), + var_export($file, true), + var_export($contentType, true), + $statusCode, + ); + } + + private function readProperty(object $object, string $name): mixed + { + return (new ReflectionProperty($object, $name))->getValue($object); + } +} From f87e81b6923ec8f8bb8e4623d105d1ffbed0ee1c Mon Sep 17 00:00:00 2001 From: QuickWrite Date: Sun, 27 Sep 2026 17:55:12 +0200 Subject: [PATCH 02/15] Fix: Undefined key prints error message When the key of the GET oder POST does not exist, an error gets printed as this undefined value gets used by the method when using get or post. However this is something that should explicitly NOT happen as the wrapper is designed to prevent that. As such a new test is being added and the bug is fixed. --- src/Http/Request.php | 8 +++---- tests/Http/RequestTest.php | 48 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/Http/Request.php b/src/Http/Request.php index 4577bc1..dec2721 100644 --- a/src/Http/Request.php +++ b/src/Http/Request.php @@ -45,26 +45,26 @@ public function urlParam(string $key, ?string $default = null): ?string public function get(string $key, ?string $default = null): ?string { // I am just assuming the $_GET is an array of strings to strings. Which is not the case. - if (!is_string($_GET[$key])) { + if (!isset($_GET[$key]) || !is_string($_GET[$key])) { return $default; } /** * @var array $_POST */ - return $_GET[$key] ?? $default; + return $_GET[$key]; } public function post(string $key, ?string $default = null): ?string { // I am just assuming the $_POST is an array of strings to strings. Which is not the case. - if (!is_string($_POST[$key])) { + if (!isset($_POST[$key]) || !is_string($_POST[$key])) { return $default; } /** * @var array $_POST */ - return $_POST[$key] ?? $default; + return $_POST[$key]; } } diff --git a/tests/Http/RequestTest.php b/tests/Http/RequestTest.php index 41c74a6..f4d7129 100644 --- a/tests/Http/RequestTest.php +++ b/tests/Http/RequestTest.php @@ -90,4 +90,52 @@ public function fallsBackToDefaultForNonStringPostValue(): void $this->assertNull($request->post('username')); $this->assertSame('default', $request->post('username', 'default')); } + + #[Test] + public function returnsDefaultForMissingQueryKeyWithoutWarning(): void + { + $_GET = []; + $warnings = []; + + set_error_handler(static function (int $errno, string $errstr) use (&$warnings): bool { + $warnings[] = $errstr; + + return true; + }, E_WARNING); + + try { + $request = new Request(Method::Get, '/', []); + + $this->assertNull($request->get('missing')); + $this->assertSame('fallback', $request->get('missing', 'fallback')); + } finally { + restore_error_handler(); + } + + $this->assertSame([], $warnings); + } + + #[Test] + public function returnsDefaultForMissingPostKeyWithoutWarning(): void + { + $_POST = []; + $warnings = []; + + set_error_handler(static function (int $errno, string $errstr) use (&$warnings): bool { + $warnings[] = $errstr; + + return true; + }, E_WARNING); + + try { + $request = new Request(Method::Post, '/login', []); + + $this->assertNull($request->post('missing')); + $this->assertSame('fallback', $request->post('missing', 'fallback')); + } finally { + restore_error_handler(); + } + + $this->assertSame([], $warnings); + } } From e512e2cb1c00498740a0216c96bb0ea85753422b Mon Sep 17 00:00:00 2001 From: QuickWrite Date: Sun, 27 Sep 2026 17:59:52 +0200 Subject: [PATCH 03/15] Update composer.lock --- composer.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/composer.lock b/composer.lock index 8600e91..3615708 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f2112414e65b025bfe834b6a013125ee", + "content-hash": "044e048a4549897c1f7e7cd8c51327b3", "packages": [], "packages-dev": [ { @@ -244,11 +244,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.13", + "version": "2.2.16", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9ba9ac76ee9c5cf5b56d58eb5deec6315b7a0260", - "reference": "9ba9ac76ee9c5cf5b56d58eb5deec6315b7a0260", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92", + "reference": "46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92", "shasum": "" }, "require": { @@ -304,7 +304,7 @@ "type": "github" } ], - "time": "2026-09-03T20:38:19+00:00" + "time": "2026-09-25T09:31:51+00:00" }, { "name": "phpunit/php-code-coverage", From c2384f586286c0dfcd17db84af6f4ba63cd2954e Mon Sep 17 00:00:00 2001 From: QuickWrite Date: Tue, 29 Sep 2026 19:32:19 +0200 Subject: [PATCH 04/15] Add unit tests for the controllers Fix: Setup no longer trims passwords so credentials match exactly Fix: ShareController blocks direct dotfile access when hidden and refuses shares left outside the current browse root Fix: Logout regenerates the PHP session ID after logout --- src/Controller/LogoutController.php | 7 + src/Controller/SetupController.php | 2 +- src/Controller/ShareController.php | 37 ++++ tests/Controller/AdminControllerTest.php | 227 ++++++++++++++++++++++ tests/Controller/BaseControllerTest.php | 69 +++++++ tests/Controller/LoginControllerTest.php | 177 +++++++++++++++++ tests/Controller/LogoutControllerTest.php | 102 ++++++++++ tests/Controller/SetupControllerTest.php | 166 ++++++++++++++++ tests/Controller/ShareControllerTest.php | 157 +++++++++++++++ 9 files changed, 943 insertions(+), 1 deletion(-) create mode 100644 tests/Controller/AdminControllerTest.php create mode 100644 tests/Controller/BaseControllerTest.php create mode 100644 tests/Controller/LoginControllerTest.php create mode 100644 tests/Controller/LogoutControllerTest.php create mode 100644 tests/Controller/SetupControllerTest.php create mode 100644 tests/Controller/ShareControllerTest.php diff --git a/src/Controller/LogoutController.php b/src/Controller/LogoutController.php index 451d453..f5f0f86 100644 --- a/src/Controller/LogoutController.php +++ b/src/Controller/LogoutController.php @@ -3,6 +3,7 @@ namespace KittyShare\Controller; use KittyShare\Http\{Request, Response}; +use KittyShare\Http\Session; use Override; final class LogoutController extends BaseController @@ -12,6 +13,12 @@ public function handle(Request $request): Response { $this->dependencies->authenticationManager->logout(); + // The old PHP session ID was tied to an authenticated session and + // must not stay valid after logout (fixation/replay). Rotating it + // keeps anonymous data (e.g. flash messages) while invalidating + // the previous identifier. + Session::regenerate(); + return $this->redirect('/login'); } } diff --git a/src/Controller/SetupController.php b/src/Controller/SetupController.php index 218a9cf..e7ae31b 100644 --- a/src/Controller/SetupController.php +++ b/src/Controller/SetupController.php @@ -48,7 +48,7 @@ private function handlePost(Request $request): Response $errors = []; $username = trim($request->post('username') ?? ''); - $password = trim($request->post('password') ?? ''); + $password = $request->post('password') ?? ''; if ($username === '') { $errors['username'] = 'empty'; diff --git a/src/Controller/ShareController.php b/src/Controller/ShareController.php index 2f80d3b..1e6664d 100644 --- a/src/Controller/ShareController.php +++ b/src/Controller/ShareController.php @@ -7,11 +7,13 @@ use KittyShare\Filesystem\DirectoryBrowser; use KittyShare\Model\Share; use Override; +use function explode; use function is_dir; use function realpath; use function basename; use function is_file; use function mime_content_type; +use function str_starts_with; final class ShareController extends BaseController { @@ -40,6 +42,12 @@ public function handle(Request $request): Response return $this->notFound(); } + // Shares are created inside the browse root. If the root was narrowed + // afterwards, shares left outside of it must no longer be served. + if (!DirectoryBrowser::isWithinRoot(DirectoryBrowser::browseRoot(), $shareRoot)) { + return $this->notFound(); + } + if (is_file($shareRoot)) { return $this->handleFileRoot($share, $shareRoot, $path); } @@ -62,6 +70,10 @@ public function handle(Request $request): Response return $this->notFound(); } + if (!$this->isVisiblePath($shareRoot, $resolvedPath)) { + return $this->notFound(); + } + if (is_file($resolvedPath)) { return $this->serveFile($resolvedPath); } @@ -158,6 +170,31 @@ private function renderDirectory( ); } + /** + * Checks that a resolved path may be served under the dotfile setting. + * + * @param string $shareRoot The canonical absolute path to the share root. + * @param string $resolvedPath The canonical absolute path to check. + * + * @return bool True when the path contains no hidden segment. + */ + private function isVisiblePath( + string $shareRoot, + string $resolvedPath, + ): bool { + if (ConfigManager::get()->showDotfiles) { + return true; + } + + foreach (explode('/', DirectoryBrowser::relativePath($shareRoot, $resolvedPath)) as $segment) { + if ($segment !== '' && str_starts_with($segment, '.')) { + return false; + } + } + + return true; + } + /** * Serves a file using its detected MIME type. * diff --git a/tests/Controller/AdminControllerTest.php b/tests/Controller/AdminControllerTest.php new file mode 100644 index 0000000..14ce3ea --- /dev/null +++ b/tests/Controller/AdminControllerTest.php @@ -0,0 +1,227 @@ +current; + } + + public function delete(Session $session): void + { + } +} + +final class AdminControllerTest extends TestCase +{ + private string $browseRoot = ''; + /** @var array */ + private array $backupPost = []; + /** @var array */ + private array $backupGet = []; + + protected function setUp(): void + { + parent::setUp(); + $this->backupPost = $_POST; + $this->backupGet = $_GET; + $this->browseRoot = sys_get_temp_dir() . '/kittyshare-admin-' . bin2hex(random_bytes(4)); + mkdir($this->browseRoot . '/sub', 0777, true); + file_put_contents($this->browseRoot . '/a.txt', 'a'); + putenv('KITTYSHARE_ROOT=' . $this->browseRoot); + putenv('KITTYSHARE_BASE_URL'); + (new ReflectionProperty(ConfigManager::class, 'config'))->setValue(null, null); + HttpSession::remove('auth.session_id'); + } + + protected function tearDown(): void + { + $_POST = $this->backupPost; + $_GET = $this->backupGet; + HttpSession::remove('auth.session_id'); + putenv('KITTYSHARE_ROOT'); + putenv('KITTYSHARE_BASE_URL'); + (new ReflectionProperty(ConfigManager::class, 'config'))->setValue(null, null); + $this->removeDir($this->browseRoot); + parent::tearDown(); + } + + private function removeDir(string $dir): void + { + $tmp = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR); + if ($dir === '' || !str_starts_with($dir, $tmp . DIRECTORY_SEPARATOR . 'kittyshare-')) { + return; + } + if (is_link($dir)) { + unlink($dir); + return; + } + if (!is_dir($dir)) { + if (is_file($dir)) { + unlink($dir); + } + return; + } + foreach (scandir($dir) ?: [] as $e) { + if ($e === '.' || $e === '..') { + continue; + } + $this->removeDir($dir . DIRECTORY_SEPARATOR . $e); + } + rmdir($dir); + } + + private function session(): Session + { + return new Session('sid', new UserIdentity(1, 'admin'), new DateTimeImmutable('+1 hour')); + } + + private function dependencies(?Session $current = null, ?Share $share = null, array $shares = []): Dependencies + { + $users = $this->createStub(KittyShare\Repository\UserRepository::class); + $sessions = new FakeAdminSessionRepository($current); + if ($current !== null) { + HttpSession::set('auth.session_id', $current->id); + } else { + HttpSession::remove('auth.session_id'); + } + $shareRepo = $this->createStub(KittyShare\Repository\ShareRepository::class); + $shareRepo->method('find')->willReturn($share); + $shareRepo->method('findByUser')->willReturn($shares); + return new Dependencies( + userRepository: $users, + setupRepository: $this->createStub(KittyShare\Repository\SetupRepository::class), + sessionRepository: $sessions, + shareRepository: $shareRepo, + authenticationManager: new AuthenticationManager($users, $sessions), + ); + } + + #[Test] + public function unauthenticatedRedirectsToLogin(): void + { + $response = (new AdminController($this->dependencies(null)))->handle(new Request(Method::Get, '/admin', [])); + + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertSame('/login', (new ReflectionProperty($response, 'url'))->getValue($response)); + } + + #[Test] + public function listRendersShares(): void + { + $deps = $this->dependencies($this->session(), null, []); + + $response = (new AdminController($deps))->handle(new Request(Method::Get, '/admin', [])); + + + $this->assertInstanceOf(TemplateResponse::class, $response); + $this->assertSame('admin/list', (new ReflectionProperty($response, 'template'))->getValue($response)); + } + + #[Test] + public function browseRendersDirectory(): void + { + $deps = $this->dependencies($this->session()); + + $response = (new AdminController($deps))->handle(new Request(Method::Get, '/admin/browse', [])); + + + $this->assertInstanceOf(TemplateResponse::class, $response); + $this->assertSame('admin/browse', (new ReflectionProperty($response, 'template'))->getValue($response)); + } + + #[Test] + public function browseUnknownPathIs404(): void + { + $deps = $this->dependencies($this->session()); + + $response = (new AdminController($deps))->handle(new Request(Method::Get, '/admin/browse/x', ['path' => 'nope'])); + + + $this->assertSame(404, (new ReflectionProperty($response, 'statusCode'))->getValue($response)); + } + + #[Test] + public function detailUnknownShareIs404(): void + { + $deps = $this->dependencies($this->session(), null); + + $response = (new AdminController($deps))->handle(new Request(Method::Get, '/admin/shares/x', ['id' => 'x'])); + + + $this->assertSame(404, (new ReflectionProperty($response, 'statusCode'))->getValue($response)); + } + + #[Test] + public function detailRendersShare(): void + { + $share = new Share('abc', new UserIdentity(1, 'admin'), $this->browseRoot, new DateTimeImmutable()); + $deps = $this->dependencies($this->session(), $share); + + $response = (new AdminController($deps))->handle(new Request(Method::Get, '/admin/shares/abc', ['id' => 'abc'])); + + + $this->assertSame('admin/share-detail', (new ReflectionProperty($response, 'template'))->getValue($response)); + } + + #[Test] + public function createValidPathRedirectsToDetail(): void + { + $share = new Share('new-id', new UserIdentity(1, 'admin'), $this->browseRoot, new DateTimeImmutable()); + $users = $this->createStub(KittyShare\Repository\UserRepository::class); + $sessions = new FakeAdminSessionRepository($this->session()); + HttpSession::set('auth.session_id', 'sid'); + $shareRepo = $this->createStub(KittyShare\Repository\ShareRepository::class); + $shareRepo->method('create')->willReturn($share); + $deps = new Dependencies( + userRepository: $users, + setupRepository: $this->createStub(KittyShare\Repository\SetupRepository::class), + sessionRepository: $sessions, + shareRepository: $shareRepo, + authenticationManager: new AuthenticationManager($users, $sessions), + ); + $_POST = ['filepath' => $this->browseRoot]; + + $response = (new AdminController($deps))->handle(new Request(Method::Post, '/admin/shares', [])); + + + $this->assertSame('/admin/shares/new-id', (new ReflectionProperty($response, 'url'))->getValue($response)); + } + + #[Test] + public function createOutsideRootIs404(): void + { + $deps = $this->dependencies($this->session()); + $_POST = ['filepath' => '/etc/passwd']; + + $response = (new AdminController($deps))->handle(new Request(Method::Post, '/admin/shares', [])); + + + $this->assertSame(404, (new ReflectionProperty($response, 'statusCode'))->getValue($response)); + } +} diff --git a/tests/Controller/BaseControllerTest.php b/tests/Controller/BaseControllerTest.php new file mode 100644 index 0000000..5ef23ee --- /dev/null +++ b/tests/Controller/BaseControllerTest.php @@ -0,0 +1,69 @@ +redirect('/target'); + } +} + +final class BaseControllerTest extends TestCase +{ + protected function setUp(): void + { + parent::setUp(); + putenv('KITTYSHARE_BASE_URL'); + (new ReflectionProperty(ConfigManager::class, 'config'))->setValue(null, null); + } + + protected function tearDown(): void + { + putenv('KITTYSHARE_BASE_URL'); + (new ReflectionProperty(ConfigManager::class, 'config'))->setValue(null, null); + parent::tearDown(); + } + + private function dependencies(): Dependencies + { + $users = $this->createStub(KittyShare\Repository\UserRepository::class); + $sessions = $this->createStub(KittyShare\Repository\SessionRepository::class); + return new Dependencies( + userRepository: $users, + setupRepository: $this->createStub(KittyShare\Repository\SetupRepository::class), + sessionRepository: $sessions, + shareRepository: $this->createStub(KittyShare\Repository\ShareRepository::class), + authenticationManager: new AuthenticationManager($users, $sessions), + ); + } + + #[Test] + public function redirectPrefixesBaseUrl(): void + { + $response = (new BaseControllerTestStub($this->dependencies()))->handle(new Request(Method::Get, '/', [])); + + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertSame('/target', (new ReflectionProperty($response, 'url'))->getValue($response)); + } + + #[Test] + public function redirectIncludesBasePathWhenConfigured(): void + { + putenv('KITTYSHARE_BASE_URL=/app'); + (new ReflectionProperty(ConfigManager::class, 'config'))->setValue(null, null); + $response = (new BaseControllerTestStub($this->dependencies()))->handle(new Request(Method::Get, '/', [])); + + $this->assertSame('/app/target', (new ReflectionProperty($response, 'url'))->getValue($response)); + } +} diff --git a/tests/Controller/LoginControllerTest.php b/tests/Controller/LoginControllerTest.php new file mode 100644 index 0000000..6445f6e --- /dev/null +++ b/tests/Controller/LoginControllerTest.php @@ -0,0 +1,177 @@ +user !== null && $username === $this->user->username ? $this->user : null; + } + + public function createUser(string $username, string $password): User + { + throw new RuntimeException('not used'); + } +} + +final class FakeLoginSessionRepository implements SessionRepository +{ + public function __construct(private ?Session $current = null) + { + } + + public function create(UserIdentity $user): Session + { + return new Session('new-id', $user, new DateTimeImmutable('+1 hour')); + } + + public function find(string $id): ?Session + { + return $this->current; + } + + public function delete(Session $session): void + { + } +} + +final class FakeLoginSetupRepository implements SetupRepository +{ + public function __construct(private bool $required) + { + } + + public function setupRequired(): bool + { + return $this->required; + } +} + +final class LoginControllerTest extends TestCase +{ + /** @var array */ + private array $backupPost = []; + + protected function setUp(): void + { + parent::setUp(); + $this->backupPost = $_POST; + putenv('KITTYSHARE_BASE_URL'); + (new ReflectionProperty(ConfigManager::class, 'config'))->setValue(null, null); + HttpSession::remove('auth.session_id', 'login.form.errors', 'login.form.values'); + } + + protected function tearDown(): void + { + $_POST = $this->backupPost; + putenv('KITTYSHARE_BASE_URL'); + (new ReflectionProperty(ConfigManager::class, 'config'))->setValue(null, null); + HttpSession::remove('auth.session_id', 'login.form.errors', 'login.form.values'); + parent::tearDown(); + } + + private function dependencies(bool $setupRequired, ?User $user = null, ?Session $current = null): Dependencies + { + $users = new FakeLoginUserRepository($user); + $sessions = new FakeLoginSessionRepository($current); + return new Dependencies( + userRepository: $users, + setupRepository: new FakeLoginSetupRepository($setupRequired), + sessionRepository: $sessions, + shareRepository: $this->createStub(ShareRepository::class), + authenticationManager: new AuthenticationManager($users, $sessions), + ); + } + + private function user(): User + { + return new User(1, 'admin', password_hash('pw', PASSWORD_BCRYPT, ['cost' => 4])); + } + + #[Test] + public function redirectsToSetupWhenRequired(): void + { + $response = (new LoginController($this->dependencies(true)))->handle(new Request(Method::Get, '/login', [])); + + $this->assertSame('/setup', (new ReflectionProperty($response, 'url'))->getValue($response)); + } + + #[Test] + public function redirectsToAdminWhenAlreadyAuthenticated(): void + { + $session = new Session('id', new UserIdentity(1, 'admin'), new DateTimeImmutable('+1 hour')); + HttpSession::set('auth.session_id', 'id'); + $response = (new LoginController($this->dependencies(false, $this->user(), $session)))->handle(new Request(Method::Get, '/login', [])); + + $this->assertSame('/admin', (new ReflectionProperty($response, 'url'))->getValue($response)); + } + + #[Test] + public function getRendersLoginForm(): void + { + $response = (new LoginController($this->dependencies(false)))->handle(new Request(Method::Get, '/login', [])); + + $this->assertInstanceOf(TemplateResponse::class, $response); + $this->assertSame('auth/login', (new ReflectionProperty($response, 'template'))->getValue($response)); + } + + #[Test] + public function postWithEmptyFieldsRedirectsBack(): void + { + $_POST = ['username' => '', 'password' => '']; + $response = (new LoginController($this->dependencies(false)))->handle(new Request(Method::Post, '/login', [])); + + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertSame('/login', (new ReflectionProperty($response, 'url'))->getValue($response)); + } + + #[Test] + public function postWithInvalidCredentialsRedirectsBack(): void + { + $_POST = ['username' => 'admin', 'password' => 'wrong']; + $response = (new LoginController($this->dependencies(false, $this->user())))->handle(new Request(Method::Post, '/login', [])); + + $this->assertSame('/login', (new ReflectionProperty($response, 'url'))->getValue($response)); + $this->assertSame(['credentials' => 'invalid'], HttpSession::get('login.form.errors')); + } + + #[Test] + public function postWithValidCredentialsRedirectsToAdmin(): void + { + $_POST = ['username' => 'admin', 'password' => 'pw']; + $response = (new LoginController($this->dependencies(false, $this->user())))->handle(new Request(Method::Post, '/login', [])); + + $this->assertSame('/admin', (new ReflectionProperty($response, 'url'))->getValue($response)); + } + + #[Test] + public function loginRequiresExactPasswordMatch(): void + { + $_POST = ['username' => 'admin', 'password' => ' pw ']; + $response = (new LoginController($this->dependencies(false, $this->user())))->handle(new Request(Method::Post, '/login', [])); + + $this->assertSame('/login', (new ReflectionProperty($response, 'url'))->getValue($response)); + } +} diff --git a/tests/Controller/LogoutControllerTest.php b/tests/Controller/LogoutControllerTest.php new file mode 100644 index 0000000..13e0cdb --- /dev/null +++ b/tests/Controller/LogoutControllerTest.php @@ -0,0 +1,102 @@ +current; + } + + public function delete(Session $session): void + { + $this->deleted = $session; + } +} + +final class LogoutControllerTest extends TestCase +{ + protected function setUp(): void + { + parent::setUp(); + putenv('KITTYSHARE_BASE_URL'); + (new ReflectionProperty(ConfigManager::class, 'config'))->setValue(null, null); + HttpSession::remove('auth.session_id'); + } + + protected function tearDown(): void + { + putenv('KITTYSHARE_BASE_URL'); + (new ReflectionProperty(ConfigManager::class, 'config'))->setValue(null, null); + HttpSession::remove('auth.session_id'); + parent::tearDown(); + } + + #[Test] + public function logoutCallsManagerAndRedirectsToLogin(): void + { + $session = new Session('sid', new UserIdentity(1, 'admin'), new DateTimeImmutable('+1 hour')); + $sessions = new FakeLogoutSessionRepository($session); + $users = $this->createStub(KittyShare\Repository\UserRepository::class); + $deps = new Dependencies( + userRepository: $users, + setupRepository: $this->createStub(KittyShare\Repository\SetupRepository::class), + sessionRepository: $sessions, + shareRepository: $this->createStub(KittyShare\Repository\ShareRepository::class), + authenticationManager: new AuthenticationManager($users, $sessions), + ); + + HttpSession::set('auth.session_id', 'sid'); + + $response = (new LogoutController($deps))->handle(new Request(Method::Post, '/logout', [])); + + $this->assertSame('/login', (new ReflectionProperty($response, 'url'))->getValue($response)); + $this->assertSame($session, $sessions->deleted); + $this->assertNull(HttpSession::get('auth.session_id')); + } + + #[Test] + public function logoutRegeneratesSessionId(): void + { + $session = new Session('sid', new UserIdentity(1, 'admin'), new DateTimeImmutable('+1 hour')); + $sessions = new FakeLogoutSessionRepository($session); + $users = $this->createStub(KittyShare\Repository\UserRepository::class); + $deps = new Dependencies( + userRepository: $users, + setupRepository: $this->createStub(KittyShare\Repository\SetupRepository::class), + sessionRepository: $sessions, + shareRepository: $this->createStub(KittyShare\Repository\ShareRepository::class), + authenticationManager: new AuthenticationManager($users, $sessions), + ); + + HttpSession::set('auth.session_id', 'sid'); + $before = session_id(); + + (new LogoutController($deps))->handle(new Request(Method::Post, '/logout', [])); + + $this->assertNotSame($before, session_id()); + } +} diff --git a/tests/Controller/SetupControllerTest.php b/tests/Controller/SetupControllerTest.php new file mode 100644 index 0000000..645b271 --- /dev/null +++ b/tests/Controller/SetupControllerTest.php @@ -0,0 +1,166 @@ +createCalls++; + $this->createdPassword = $password; + $this->created = new User(1, $username, 'hash'); + return $this->created; + } +} + +final class FakeSetupSetupRepository implements SetupRepository +{ + public function __construct(private bool $required) + { + } + + public function setupRequired(): bool + { + return $this->required; + } +} + +final class SetupControllerTest extends TestCase +{ + /** @var array */ + private array $backupPost = []; + + protected function setUp(): void + { + parent::setUp(); + $this->backupPost = $_POST; + putenv('KITTYSHARE_BASE_URL'); + (new ReflectionProperty(ConfigManager::class, 'config'))->setValue(null, null); + HttpSession::remove('setup.form.errors', 'setup.form.values'); + } + + protected function tearDown(): void + { + $_POST = $this->backupPost; + putenv('KITTYSHARE_BASE_URL'); + (new ReflectionProperty(ConfigManager::class, 'config'))->setValue(null, null); + HttpSession::remove('setup.form.errors', 'setup.form.values'); + parent::tearDown(); + } + + private function dependencies(bool $setupRequired, ?FakeSetupUserRepository $users = null): array + { + $users ??= new FakeSetupUserRepository(); + $sessions = $this->createStub(SessionRepository::class); + return [$users, new Dependencies( + userRepository: $users, + setupRepository: new FakeSetupSetupRepository($setupRequired), + sessionRepository: $sessions, + shareRepository: $this->createStub(ShareRepository::class), + authenticationManager: new AuthenticationManager($users, $sessions), + )]; + } + + #[Test] + public function redirectsToLoginWhenSetupNotRequired(): void + { + [, $deps] = $this->dependencies(false); + + $response = (new SetupController($deps))->handle(new Request(Method::Get, '/setup', [])); + + + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertSame('/login', (new ReflectionProperty($response, 'url'))->getValue($response)); + } + + #[Test] + public function getRendersForm(): void + { + [, $deps] = $this->dependencies(true); + + $response = (new SetupController($deps))->handle(new Request(Method::Get, '/setup', [])); + + + $this->assertInstanceOf(TemplateResponse::class, $response); + $this->assertSame('setup/form', (new ReflectionProperty($response, 'template'))->getValue($response)); + } + + #[Test] + public function postWithEmptyFieldsRedirectsBack(): void + { + [$users, $deps] = $this->dependencies(true); + $_POST = ['username' => '', 'password' => '']; + + $response = (new SetupController($deps))->handle(new Request(Method::Post, '/setup', [])); + + + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertSame('/setup', (new ReflectionProperty($response, 'url'))->getValue($response)); + $this->assertSame(0, $users->createCalls); + } + + #[Test] + public function postCreatesUserAndRedirectsToLogin(): void + { + [$users, $deps] = $this->dependencies(true); + $_POST = ['username' => 'admin', 'password' => 's3cret']; + + $response = (new SetupController($deps))->handle(new Request(Method::Post, '/setup', [])); + + + $this->assertSame('/login', (new ReflectionProperty($response, 'url'))->getValue($response)); + $this->assertSame(1, $users->createCalls); + $this->assertSame('admin', $users->created?->username); + } + + #[Test] + public function secondSetupDoesNotError(): void + { + [$users, $deps] = $this->dependencies(false); + $_POST = ['username' => 'second', 'password' => 'pw2']; + + $response = (new SetupController($deps))->handle(new Request(Method::Post, '/setup', [])); + + + $this->assertSame('/login', (new ReflectionProperty($response, 'url'))->getValue($response)); + $this->assertSame(0, $users->createCalls); + } + + #[Test] + public function loginRequiresExactPasswordMatch(): void + { + // Passwords must be stored exactly as entered; trimming them changes + // the credential and breaks exact-match login. + [$users, $deps] = $this->dependencies(true); + $_POST = ['username' => 'admin', 'password' => ' s3cret ']; + + (new SetupController($deps))->handle(new Request(Method::Post, '/setup', [])); + + + $this->assertSame(' s3cret ', $users->createdPassword); + } +} diff --git a/tests/Controller/ShareControllerTest.php b/tests/Controller/ShareControllerTest.php new file mode 100644 index 0000000..e47940c --- /dev/null +++ b/tests/Controller/ShareControllerTest.php @@ -0,0 +1,157 @@ +setValue(null, null); + $this->base = sys_get_temp_dir() . '/kittyshare-share-' . bin2hex(random_bytes(4)); + mkdir($this->base . '/share/sub', 0777, true); + file_put_contents($this->base . '/share/hello.txt', 'hello'); + file_put_contents($this->base . '/share/sub/nested.txt', 'nested'); + $this->shareDir = (string) realpath($this->base . '/share'); + } + + protected function tearDown(): void + { + putenv('KITTYSHARE_BASE_URL'); + putenv('KITTYSHARE_SHOW_DOTFILES'); + (new ReflectionProperty(ConfigManager::class, 'config'))->setValue(null, null); + $this->removeDir($this->base); + parent::tearDown(); + } + + private function removeDir(string $dir): void + { + $tmp = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR); + if ($dir === '' || !str_starts_with($dir, $tmp . DIRECTORY_SEPARATOR . 'kittyshare-')) { + return; + } + if (is_link($dir)) { + unlink($dir); + return; + } + if (!is_dir($dir)) { + if (is_file($dir)) { + unlink($dir); + } + return; + } + foreach (scandir($dir) ?: [] as $e) { + if ($e === '.' || $e === '..') { + continue; + } + $this->removeDir($dir . DIRECTORY_SEPARATOR . $e); + } + rmdir($dir); + } + + private function dependencies(?Share $share): Dependencies + { + $shares = $this->createStub(KittyShare\Repository\ShareRepository::class); + $shares->method('find')->willReturn($share); + $users = $this->createStub(KittyShare\Repository\UserRepository::class); + $sessions = $this->createStub(KittyShare\Repository\SessionRepository::class); + return new Dependencies( + userRepository: $users, + setupRepository: $this->createStub(KittyShare\Repository\SetupRepository::class), + sessionRepository: $sessions, + shareRepository: $shares, + authenticationManager: new KittyShare\Manager\AuthenticationManager($users, $sessions), + ); + } + + private function share(string $filepath): Share + { + return new Share('abc', new UserIdentity(1, 'admin'), $filepath, new DateTimeImmutable()); + } + + #[Test] + public function unknownShareIsNotFound(): void + { + $response = (new ShareController($this->dependencies(null)))->handle(new Request(Method::Get, '/share/x', ['id' => 'x'])); + + $this->assertInstanceOf(TemplateResponse::class, $response); + $this->assertSame('share/not-found', (new ReflectionProperty($response, 'template'))->getValue($response)); + } + + #[Test] + public function revokedShareIsNotFound(): void + { + $share = $this->share($this->shareDir)->revoke(); + $response = (new ShareController($this->dependencies($share)))->handle(new Request(Method::Get, '/share/x', ['id' => 'abc'])); + + $this->assertSame('share/not-found', (new ReflectionProperty($response, 'template'))->getValue($response)); + } + + #[Test] + public function directoryRootRendersListing(): void + { + $response = (new ShareController($this->dependencies($this->share($this->shareDir))))->handle(new Request(Method::Get, '/share/x', ['id' => 'abc'])); + + $this->assertSame('share/directory', (new ReflectionProperty($response, 'template'))->getValue($response)); + } + + #[Test] + public function fileInShareIsServed(): void + { + $response = (new ShareController($this->dependencies($this->share($this->shareDir))))->handle(new Request(Method::Get, '/share/x', ['id' => 'abc', 'path' => 'hello.txt'])); + + $this->assertInstanceOf(FileResponse::class, $response); + } + + #[Test] + public function missingSubpathIsNotFound(): void + { + $response = (new ShareController($this->dependencies($this->share($this->shareDir))))->handle(new Request(Method::Get, '/share/x', ['id' => 'abc', 'path' => 'nope.txt'])); + + $this->assertSame('share/not-found', (new ReflectionProperty($response, 'template'))->getValue($response)); + } + + #[Test] + public function hiddenDotfileDirectAccessIsNotFound(): void + { + file_put_contents($this->shareDir . '/.secret', 'hidden'); + $response = (new ShareController($this->dependencies($this->share($this->shareDir))))->handle(new Request(Method::Get, '/share/x', ['id' => 'abc', 'path' => '.secret'])); + + $this->assertInstanceOf(TemplateResponse::class, $response); + $this->assertSame('share/not-found', (new ReflectionProperty($response, 'template'))->getValue($response)); + } + + #[Test] + public function shareOutsideCurrentRootIsNotFound(): void + { + $outside = sys_get_temp_dir() . '/kittyshare-share-outside-' . bin2hex(random_bytes(4)); + mkdir($outside, 0777, true); + file_put_contents($outside . '/file.txt', 'x'); + putenv('KITTYSHARE_ROOT=' . $this->base); + (new ReflectionProperty(ConfigManager::class, 'config'))->setValue(null, null); + try { + $response = (new ShareController($this->dependencies($this->share($outside))))->handle(new Request(Method::Get, '/share/x', ['id' => 'abc'])); + $this->assertInstanceOf(TemplateResponse::class, $response); + $this->assertSame('share/not-found', (new ReflectionProperty($response, 'template'))->getValue($response)); + } finally { + putenv('KITTYSHARE_ROOT'); + (new ReflectionProperty(ConfigManager::class, 'config'))->setValue(null, null); + $this->removeDir($outside); + } + } +} From 1cbdebdf087fc74b011b5fc3055d3809367e0722 Mon Sep 17 00:00:00 2001 From: QuickWrite Date: Tue, 29 Sep 2026 19:59:28 +0200 Subject: [PATCH 05/15] Add a directory browser test Fix: Some outside files could still be seen by the user even though they cannot be accessed. --- src/Filesystem/DirectoryBrowser.php | 12 ++ tests/Filesystem/DirectoryBrowserTest.php | 141 ++++++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 tests/Filesystem/DirectoryBrowserTest.php diff --git a/src/Filesystem/DirectoryBrowser.php b/src/Filesystem/DirectoryBrowser.php index 2a54c89..a8001ae 100644 --- a/src/Filesystem/DirectoryBrowser.php +++ b/src/Filesystem/DirectoryBrowser.php @@ -141,6 +141,12 @@ public static function listDirectory( return []; } + $canonicalDir = realpath($directoryPath); + + if ($canonicalDir === false) { + return []; + } + $result = []; foreach ($entries as $entry) { @@ -153,6 +159,12 @@ public static function listDirectory( } $entryPath = $directoryPath . DIRECTORY_SEPARATOR . $entry; + + $canonicalEntry = realpath($entryPath); + if ($canonicalEntry === false || !self::isWithinRoot($canonicalDir, $canonicalEntry)) { + continue; + } + $entryRelativePath = $relativePath === '' ? $entry : $relativePath . '/' . $entry; diff --git a/tests/Filesystem/DirectoryBrowserTest.php b/tests/Filesystem/DirectoryBrowserTest.php new file mode 100644 index 0000000..1ba7c4d --- /dev/null +++ b/tests/Filesystem/DirectoryBrowserTest.php @@ -0,0 +1,141 @@ +base = sys_get_temp_dir() . '/kittyshare-dir-' . bin2hex(random_bytes(4)); + + mkdir($this->base . '/root/sub', 0777, true); + file_put_contents($this->base . '/root/top.txt', 'top'); + file_put_contents($this->base . '/root/sub/inner.txt', 'inner'); + file_put_contents($this->base . '/root/.hidden', 'h'); + + $this->root = (string) realpath($this->base . '/root'); + } + + protected function tearDown(): void + { + $this->removeDir($this->base); + + parent::tearDown(); + } + + private function removeDir(string $dir): void + { + $tmp = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR); + + if ($dir === '' || !str_starts_with($dir, $tmp . DIRECTORY_SEPARATOR . 'kittyshare-')) { + return; + } + + if (is_link($dir)) { + unlink($dir); + + return; + } + + if (!is_dir($dir)) { + if (is_file($dir)) { + unlink($dir); + } + + return; + } + + foreach (scandir($dir) ?: [] as $e) { + if ($e === '.' || $e === '..') { + continue; + } + + $this->removeDir($dir . DIRECTORY_SEPARATOR . $e); + } + + rmdir($dir); + } + + #[Test] + public function resolveValidPath(): void + { + $this->assertSame($this->root . DIRECTORY_SEPARATOR . 'top.txt', DirectoryBrowser::resolvePath($this->root, 'top.txt')); + $this->assertSame($this->root, DirectoryBrowser::resolvePath($this->root, 'sub/..')); + } + + #[Test] + public function resolveTraversalReturnsNull(): void + { + $this->assertNull(DirectoryBrowser::resolvePath($this->root, '../top.txt')); + $this->assertNull(DirectoryBrowser::resolvePath($this->root, 'missing.txt')); + } + + #[Test] + public function isWithinRootChecksPrefix(): void + { + $this->assertTrue(DirectoryBrowser::isWithinRoot($this->root, $this->root)); + $this->assertTrue(DirectoryBrowser::isWithinRoot($this->root, $this->root . '/sub')); + $this->assertFalse(DirectoryBrowser::isWithinRoot($this->root, $this->root . '-other')); + } + + #[Test] + public function relativePathStripsRoot(): void + { + $this->assertSame('', DirectoryBrowser::relativePath($this->root, $this->root)); + $this->assertSame('sub', DirectoryBrowser::relativePath($this->root, $this->root . '/sub')); + } + + #[Test] + public function listDirectoryHidesDotfilesByDefault(): void + { + $names = array_column(DirectoryBrowser::listDirectory($this->root, '', false), 'name'); + + $this->assertContains('top.txt', $names); + $this->assertNotContains('.hidden', $names); + $this->assertContains('.hidden', array_column(DirectoryBrowser::listDirectory($this->root, '', true), 'name')); + } + + #[Test] + public function listingFilesystemRootIsNotEmpty(): void + { + if (scandir('/') === false) { + $this->markTestSkipped('filesystem root is not readable'); + } + + $entries = DirectoryBrowser::listDirectory('/', '', true); + + $this->assertNotEmpty($entries, 'listing / must not filter out every entry'); + } + + #[Test] + public function symlinkedDirOutsideIsNotListedAsDir(): void + { + $outside = sys_get_temp_dir() . '/kittyshare-dir-outside-' . bin2hex(random_bytes(4)); + mkdir($outside, 0777, true); + $link = $this->root . '/evil-dir'; + + if (!symlink($outside, $link)) { + + $this->markTestSkipped('symlink not permitted'); + } + + try { + $byName = []; + foreach (DirectoryBrowser::listDirectory($this->root, '', false) as $entry) { + $byName[$entry['name']] = $entry['isDir']; + } + + $this->assertArrayNotHasKey('evil-dir', $byName, 'symlink escaping the root must not be listed'); + } finally { + unlink($link); + rmdir($outside); + } + } +} From 18cfa703fc0ba71c34cf849456946b98508ded8a Mon Sep 17 00:00:00 2001 From: QuickWrite Date: Tue, 29 Sep 2026 21:53:21 +0200 Subject: [PATCH 06/15] Add explicit Config error handline The main issue with the configuration system was that errors were not really that visible. Now the configuration errors are resulting in an exception. Also tests were added to test if these things work correctly. --- bin/migrate | 9 +- public/index.php | 4 +- src/Manager/ConfigManager.php | 130 ++++- src/Manager/InvalidConfigurationException.php | 26 + tests/Manager/ConfigManagerTest.php | 495 ++++++++++++++++++ 5 files changed, 638 insertions(+), 26 deletions(-) create mode 100644 src/Manager/InvalidConfigurationException.php create mode 100644 tests/Manager/ConfigManagerTest.php diff --git a/bin/migrate b/bin/migrate index 3d6bc6d..3697d67 100755 --- a/bin/migrate +++ b/bin/migrate @@ -15,6 +15,7 @@ require_once __DIR__ . '/../vendor/autoload.php'; use KittyShare\Manager\ConfigManager; +use KittyShare\Manager\InvalidConfigurationException; use KittyShare\Database\SQLiteDatabase; use KittyShare\Database\SQLiteMigrator; use KittyShare\Repository\SQLiteDBVersionRepository; @@ -25,7 +26,13 @@ if (php_sapi_name() !== 'cli') { exit(1); } -$databasePath = ConfigManager::get()->databasePath; +try { + $databasePath = ConfigManager::get()->databasePath; +} catch (InvalidConfigurationException $e) { + fwrite(STDERR, 'Invalid configuration: ' . $e->getMessage() . "\n"); + + exit(1); +} $parentDir = dirname($databasePath); diff --git a/public/index.php b/public/index.php index 379da39..8160c48 100644 --- a/public/index.php +++ b/public/index.php @@ -3,13 +3,13 @@ require_once __DIR__ . '/../vendor/autoload.php'; use KittyShare\Controller\{AdminController, LoginController, LogoutController, SetupController, ShareController}; -use KittyShare\Manager\{DependencyManager, ConfigManager}; +use KittyShare\Manager\{DependencyManager, ConfigManager, InvalidConfigurationException}; use KittyShare\Database\DatabaseVersionException; use KittyShare\Http\{Router, Method}; try { $dependencies = DependencyManager::get(); -} catch (DatabaseVersionException $e) { +} catch (DatabaseVersionException | InvalidConfigurationException $e) { http_response_code(500); header('Content-Type: text/plain; charset=utf-8'); diff --git a/src/Manager/ConfigManager.php b/src/Manager/ConfigManager.php index 6ead34d..b7e1070 100644 --- a/src/Manager/ConfigManager.php +++ b/src/Manager/ConfigManager.php @@ -49,9 +49,15 @@ private static function resolveBrowseRoot(?string $configured): string if ($configured !== null && $configured !== '') { $root = realpath($configured); - if ($root !== false && is_dir($root)) { - return $root; + if ($root === false || !is_dir($root)) { + throw new InvalidConfigurationException( + 'KITTYSHARE_ROOT', + $configured, + 'an existing readable directory', + ); } + + return $root; } $root = realpath('/'); @@ -74,19 +80,23 @@ private static function env(string $name): ?string * * @param string $name The name of the value * @param positive-int $default The default value - * @return positive-int The configured value, or $default when unset or invalid. + * @return positive-int The configured value, or $default when unset. + * + * @throws InvalidConfigurationException If the parsed integer is negative. */ private static function envPosInt(string $name, int $default): int { $value = self::env($name); - if ($value === null || !ctype_digit($value)) { + if ($value === null) { return $default; } - $parsed = (int) $value; + if (!ctype_digit($value) || (int) $value <= 0) { + throw new InvalidConfigurationException($name, $value, 'a positive integer'); + } - return $parsed > 0 ? $parsed : $default; + return (int) $value; } /** @@ -94,7 +104,10 @@ private static function envPosInt(string $name, int $default): int * * Accepts 1/true/yes/on and 0/false/no/off (case-insensitive). * - * @return bool The configured value, or $default when unset or invalid. + * @return bool The configured value, or $default when unset. + * + * @throws InvalidConfigurationException If the parsed boolean does not have + * a valid value. */ private static function envBool(string $name, bool $default): bool { @@ -106,7 +119,10 @@ private static function envBool(string $name, bool $default): bool /** * Reads a boolean setting where null means "not configured". * - * @return bool|null The configured value, or null when unset or invalid. + * @return bool|null The configured value, or null when unset. + * + * @throws InvalidConfigurationException If the parsed boolean does not have + * a valid value. */ private static function envBoolOrNull(string $name): ?bool { @@ -116,15 +132,24 @@ private static function envBoolOrNull(string $name): ?bool return null; } - return match (strtolower($value)) { + $parsed = match (strtolower($value)) { '1', 'true', 'yes', 'on' => true, '0', 'false', 'no', 'off' => false, - default => null, + default => throw new InvalidConfigurationException( + $name, + $value, + 'a boolean (1/true/yes/on or 0/false/no/off)' + ), }; + + return $parsed; } /** - * @return 'Lax'|'Strict'|'None' One of Lax, Strict or None; Lax when unset or invalid. + * @return 'Lax'|'Strict'|'None' One of Lax, Strict or None; Lax when unset. + * + * @throws InvalidConfigurationException If the parsed value is not Lax, + * Strict or None */ private static function cookieSameSite(): string { @@ -134,12 +159,29 @@ private static function cookieSameSite(): string return 'Lax'; } - return match (strtolower($value)) { + $parsed = match (strtolower($value)) { 'lax' => 'Lax', 'strict' => 'Strict', 'none' => 'None', - default => 'Lax', + default => throw new InvalidConfigurationException( + 'KITTYSHARE_COOKIE_SAMESITE', + $value, + 'one of Lax, Strict or None', + ), }; + + // SameSite=None without the Secure flag is rejected by browsers + // (and leaks the cookie cross-site otherwise). An explicitly + // disabled Secure flag combined with None refuses to start + if ($parsed === 'None' && self::envBoolOrNull('KITTYSHARE_COOKIE_SECURE') === false) { + throw new InvalidConfigurationException( + 'KITTYSHARE_COOKIE_SAMESITE', + $value, + 'Secure cookies (set KITTYSHARE_COOKIE_SECURE=true when using SameSite=None)', + ); + } + + return $parsed; } /** @@ -151,7 +193,9 @@ private static function cookieSameSite(): string * - Bare paths: "/public" * * @return string|null The base URL without trailing slash, or null when - * unset or invalid. + * unset. + * + * @throws InvalidConfigurationException If the parsed based URL is invalid */ private static function baseUrl(): ?string { @@ -167,6 +211,21 @@ private static function baseUrl(): ?string return null; } + // A protocol-relative URL ('//evil.com/foo') would turn every + // generated link into an external URL, and quotes, angle brackets + // or whitespace would break out of HTML attributes the value is + // interpolated into. Refuse such values instead of emitting them. + if ( + str_starts_with($value, '//') + || preg_match('/["\'<>\s\x00-\x1F\x7F\\\\]/', $value) === 1 + ) { + throw new InvalidConfigurationException( + 'KITTYSHARE_BASE_URL', + $value, + 'a full URL, host with path, or absolute path without quotes, brackets or whitespace', + ); + } + if (str_starts_with(strtolower($value), 'http://') || str_starts_with(strtolower($value), 'https://')) { return $value; } @@ -179,7 +238,11 @@ private static function baseUrl(): ?string return 'http://' . $value; } - return null; + throw new InvalidConfigurationException( + 'KITTYSHARE_BASE_URL', + $value, + 'a full URL, host with path, or absolute path', + ); } /** @@ -203,7 +266,11 @@ private static function metaDescription(): ?string /** * Reads the Open Graph extensiveness mode. * - * @return 'none'|'minimal'|'per-share' The configured mode; 'none' when unset or invalid. + * @return 'none'|'minimal'|'per-share' The configured mode; 'none' when + * unset. + * + * @throws InvalidConfigurationException If the parsed value does is not a + * valid mode */ private static function metaOgMode(): string { @@ -213,20 +280,31 @@ private static function metaOgMode(): string return 'none'; } - return match (strtolower(trim($value))) { + $parsed = match (strtolower(trim($value))) { 'none' => 'none', 'minimal' => 'minimal', 'per-share', 'per_share', 'full', 'per-share-full' => 'per-share', - default => 'none', + default => throw new InvalidConfigurationException( + 'KITTYSHARE_META_OG_MODE', + $value, + 'one of none, minimal or per-share', + ), }; + + return $parsed; } /** * Reads the file-serving backend. * - * Accepts `php` (default, streams through PHP) and `x-sendfile` - * (delegates to Apache via mod_xsendfile). `apache` is accepted as - * an alias of `x-sendfile`. Unknown or unset values fall back to `php`. + * Accepts `php` (default, streams through PHP) and `x-sendfile` (delegates + * to Apache via mod_xsendfile). `apache` is accepted as an alias of + * `x-sendfile`. Unset values fall back to `php`. + * + * @return FileServerType The type of file server that should be used + * + * @throws InvalidConfigurationException If the parsed value is not a valid + * file server type */ private static function fileServer(): FileServerType { @@ -236,11 +314,17 @@ private static function fileServer(): FileServerType return FileServerType::Php; } - return match (strtolower(trim($value))) { + $parsed = match (strtolower(trim($value))) { 'php', 'php-stream' => FileServerType::Php, 'apache', 'x-sendfile' => FileServerType::XSendfile, - default => FileServerType::Php, + default => throw new InvalidConfigurationException( + 'KITTYSHARE_FILE_SERVER', + $value, + 'one of php or x-sendfile (apache is accepted as an alias)', + ), }; + + return $parsed; } /** diff --git a/src/Manager/InvalidConfigurationException.php b/src/Manager/InvalidConfigurationException.php new file mode 100644 index 0000000..82a8e1d --- /dev/null +++ b/src/Manager/InvalidConfigurationException.php @@ -0,0 +1,26 @@ + */ + private array $originalEnv = []; + + protected function setUp(): void + { + parent::setUp(); + + foreach (self::ENV_VARS as $name) { + $this->originalEnv[$name] = getenv($name); + putenv($name); + } + + $this->resetConfig(); + } + + protected function tearDown(): void + { + foreach (self::ENV_VARS as $name) { + $original = $this->originalEnv[$name] ?? false; + + if ($original === false) { + putenv($name); + } else { + putenv($name . '=' . $original); + } + } + + $this->resetConfig(); + + parent::tearDown(); + } + + private function resetConfig(): void + { + (new ReflectionProperty(ConfigManager::class, 'config'))->setValue(null, null); + (new ReflectionProperty(DependencyManager::class, 'dependencies'))->setValue(null, null); + } + + private function configure(array $env): void + { + foreach ($env as $name => $value) { + putenv($name . '=' . $value); + } + + $this->resetConfig(); + } + + #[Test] + public function returnsSameInstanceWhileCached(): void + { + $this->assertSame(ConfigManager::get(), ConfigManager::get()); + } + + #[Test] + public function rereadsEnvAfterReset(): void + { + $this->configure(['KITTYSHARE_SESSION_LIFETIME' => '100']); + + $this->assertSame(100, ConfigManager::get()->sessionLifetime); + + $this->configure(['KITTYSHARE_SESSION_LIFETIME' => '200']); + + $this->assertSame(200, ConfigManager::get()->sessionLifetime); + } + + #[Test] + public function defaultsWhenEnvUnset(): void + { + $config = ConfigManager::get(); + + $managerDir = dirname((new ReflectionClass(ConfigManager::class))->getFileName()); + + $this->assertSame($managerDir . '/../../database.sqlite', $config->databasePath); + $this->assertSame(realpath('/') ?: '/', $config->browseRoot); + $this->assertSame(60 * 60 * 24 * 30, $config->sessionLifetime); + $this->assertSame('Lax', $config->cookieSameSite); + $this->assertNull($config->cookieSecure); + $this->assertFalse($config->showDotfiles); + $this->assertNull($config->baseUrl); + $this->assertSame(8192, $config->downloadChunkSize); + $this->assertNull($config->metaDescription); + $this->assertSame('none', $config->metaOgMode); + $this->assertSame(FileServerType::Php, $config->fileServer); + } + + #[Test] + public function readsDatabasePathFromEnv(): void + { + $this->configure(['KITTYSHARE_DATABASE_PATH' => '/tmp/custom.sqlite']); + + $this->assertSame('/tmp/custom.sqlite', ConfigManager::get()->databasePath); + } + + #[Test] + public function emptyDatabasePathFallsBackToDefault(): void + { + putenv('KITTYSHARE_DATABASE_PATH='); + $this->resetConfig(); + + $this->assertStringEndsWith('database.sqlite', ConfigManager::get()->databasePath); + } + + #[Test] + public function resolvesBrowseRootToRealpath(): void + { + $dir = sys_get_temp_dir(); + $this->configure(['KITTYSHARE_ROOT' => $dir]); + + $this->assertSame(realpath($dir), ConfigManager::get()->browseRoot); + } + + #[Test] + public function invalidBrowseRootThrows(): void + { + $this->configure(['KITTYSHARE_ROOT' => '/definitely-not-a-kittyshare-dir-12345']); + + $this->expectException(RuntimeException::class); + ConfigManager::get(); + } + + #[Test] + public function fileBrowseRootThrows(): void + { + $this->configure(['KITTYSHARE_ROOT' => __FILE__]); + + $this->expectException(RuntimeException::class); + ConfigManager::get(); + } + + #[Test] + public function readsSessionLifetimeFromEnv(): void + { + $this->configure(['KITTYSHARE_SESSION_LIFETIME' => '3600']); + + $this->assertSame(3600, ConfigManager::get()->sessionLifetime); + } + + #[Test] + public function invalidSessionLifetimeThrows(): void + { + foreach (['0', '-5', 'abc', '3.5', ' 10', '10 '] as $value) { + $this->configure(['KITTYSHARE_SESSION_LIFETIME' => $value]); + + try { + ConfigManager::get(); + $thrown = false; + } catch (RuntimeException) { + $thrown = true; + } + + $this->assertTrue($thrown, "session lifetime '$value' must throw"); + } + } + + #[Test] + public function readsDownloadChunkSizeFromEnv(): void + { + $this->configure(['KITTYSHARE_DOWNLOAD_CHUNK_SIZE' => '4096']); + + $this->assertSame(4096, ConfigManager::get()->downloadChunkSize); + } + + #[Test] + public function invalidDownloadChunkSizeThrows(): void + { + foreach (['0', '-1', 'huge', '8.5'] as $value) { + $this->configure(['KITTYSHARE_DOWNLOAD_CHUNK_SIZE' => $value]); + + try { + ConfigManager::get(); + $thrown = false; + } catch (RuntimeException) { + $thrown = true; + } + + $this->assertTrue($thrown, "chunk size '$value' must throw"); + } + } + + #[Test] + public function normalizesCookieSameSite(): void + { + foreach (['lax' => 'Lax', 'LAX' => 'Lax', 'strict' => 'Strict', 'STRICT' => 'Strict'] as $raw => $expected) { + $this->configure(['KITTYSHARE_COOKIE_SAMESITE' => $raw]); + + $this->assertSame($expected, ConfigManager::get()->cookieSameSite, "SameSite '$raw'"); + } + + foreach (['none' => 'None', 'NONE' => 'None'] as $raw => $expected) { + $this->configure(['KITTYSHARE_COOKIE_SAMESITE' => $raw, 'KITTYSHARE_COOKIE_SECURE' => 'true']); + + $this->assertSame($expected, ConfigManager::get()->cookieSameSite, "SameSite '$raw'"); + } + } + + #[Test] + public function sameSiteNoneWithExplicitInsecureThrows(): void + { + foreach (['false', '0', 'no', 'off'] as $secure) { + $this->configure(['KITTYSHARE_COOKIE_SAMESITE' => 'none', 'KITTYSHARE_COOKIE_SECURE' => $secure]); + + try { + ConfigManager::get(); + $thrown = false; + } catch (RuntimeException) { + $thrown = true; + } + + $this->assertTrue($thrown, "SameSite=None with Secure=$secure must throw"); + } + } + + #[Test] + public function sameSiteNoneWithAutoSecureIsAllowed(): void + { + $this->configure(['KITTYSHARE_COOKIE_SAMESITE' => 'none']); + + $this->assertSame('None', ConfigManager::get()->cookieSameSite); + } + + #[Test] + public function invalidCookieSameSiteThrows(): void + { + foreach (['sometimes', 'null'] as $value) { + $this->configure(['KITTYSHARE_COOKIE_SAMESITE' => $value]); + + try { + ConfigManager::get(); + $thrown = false; + } catch (RuntimeException) { + $thrown = true; + } + + $this->assertTrue($thrown, "SameSite '$value' must throw"); + } + } + + #[Test] + public function parsesCookieSecureTruthyValues(): void + { + foreach (['1', 'true', 'TRUE', 'yes', 'Yes', 'on', 'ON'] as $value) { + $this->configure(['KITTYSHARE_COOKIE_SECURE' => $value]); + + $this->assertTrue(ConfigManager::get()->cookieSecure, "cookie secure '$value' must be true"); + } + } + + #[Test] + public function parsesCookieSecureFalsyValues(): void + { + foreach (['0', 'false', 'FALSE', 'no', 'No', 'off', 'OFF'] as $value) { + $this->configure(['KITTYSHARE_COOKIE_SECURE' => $value]); + + $this->assertFalse(ConfigManager::get()->cookieSecure, "cookie secure '$value' must be false"); + } + } + + #[Test] + public function invalidCookieSecureThrows(): void + { + foreach (['2', 'maybe'] as $value) { + $this->configure(['KITTYSHARE_COOKIE_SECURE' => $value]); + + try { + ConfigManager::get(); + $thrown = false; + } catch (RuntimeException) { + $thrown = true; + } + + $this->assertTrue($thrown, "cookie secure '$value' must throw"); + } + } + + #[Test] + public function parsesShowDotfiles(): void + { + $this->configure(['KITTYSHARE_SHOW_DOTFILES' => 'true']); + + $this->assertTrue(ConfigManager::get()->showDotfiles); + + $this->configure(['KITTYSHARE_SHOW_DOTFILES' => '1']); + + $this->assertTrue(ConfigManager::get()->showDotfiles); + + $this->configure(['KITTYSHARE_SHOW_DOTFILES' => '0']); + + $this->assertFalse(ConfigManager::get()->showDotfiles); + } + + #[Test] + public function invalidShowDotfilesThrows(): void + { + $this->configure(['KITTYSHARE_SHOW_DOTFILES' => 'maybe']); + + $this->expectException(RuntimeException::class); + ConfigManager::get(); + } + + #[Test] + public function baseUrlDefaultsToNull(): void + { + $this->assertNull(ConfigManager::get()->baseUrl); + $this->assertSame('', ConfigManager::basePath()); + } + + #[Test] + public function baseUrlKeepsFullUrlsAndStripsTrailingSlash(): void + { + $this->configure(['KITTYSHARE_BASE_URL' => 'https://files.example.com/test/']); + + $this->assertSame('https://files.example.com/test', ConfigManager::get()->baseUrl); + } + + #[Test] + public function baseUrlKeepsBarePaths(): void + { + $this->configure(['KITTYSHARE_BASE_URL' => '/public/']); + + $this->assertSame('/public', ConfigManager::get()->baseUrl); + } + + #[Test] + public function baseUrlPrefixesHostWithPath(): void + { + $this->configure(['KITTYSHARE_BASE_URL' => '127.0.0.1:3000/public']); + + $this->assertSame('http://127.0.0.1:3000/public', ConfigManager::get()->baseUrl); + } + + #[Test] + public function baseUrlRejectsBareHostWithoutPath(): void + { + $this->configure(['KITTYSHARE_BASE_URL' => 'example.com']); + + $this->expectException(RuntimeException::class); + ConfigManager::get(); + } + + #[Test] + public function baseUrlRejectsEmptyValues(): void + { + foreach (['', ' ', '/'] as $value) { + $this->configure(['KITTYSHARE_BASE_URL' => $value]); + + $this->assertNull(ConfigManager::get()->baseUrl, "base URL '$value' must be null"); + } + } + + #[Test] + public function baseUrlTrimsSurroundingWhitespace(): void + { + $this->configure(['KITTYSHARE_BASE_URL' => ' /public/ ']); + + $this->assertSame('/public', ConfigManager::get()->baseUrl); + } + + #[Test] + public function basePathExtractsPathFromFullUrl(): void + { + $this->configure(['KITTYSHARE_BASE_URL' => 'https://files.example.com/test/abc']); + + $this->assertSame('/test/abc', ConfigManager::basePath()); + } + + #[Test] + public function basePathIsEmptyWithoutPath(): void + { + foreach (['https://files.example.com', 'https://files.example.com/'] as $value) { + $this->configure(['KITTYSHARE_BASE_URL' => $value]); + + $this->assertSame('', ConfigManager::basePath(), "base path of '$value' must be empty"); + } + } + + #[Test] + public function basePathReturnsBarePath(): void + { + $this->configure(['KITTYSHARE_BASE_URL' => '/public']); + + $this->assertSame('/public', ConfigManager::basePath()); + } + + #[Test] + public function metaDescriptionDefaultsToNull(): void + { + $this->assertNull(ConfigManager::get()->metaDescription); + } + + #[Test] + public function metaDescriptionKeepsConfiguredText(): void + { + $this->configure(['KITTYSHARE_META_DESCRIPTION' => 'Share files simply']); + + $this->assertSame('Share files simply', ConfigManager::get()->metaDescription); + } + + #[Test] + public function blankMetaDescriptionMeansOmitTag(): void + { + foreach (['', ' '] as $value) { + $this->configure(['KITTYSHARE_META_DESCRIPTION' => $value]); + + $this->assertNull(ConfigManager::get()->metaDescription, 'blank description must be null'); + } + } + + #[Test] + public function parsesMetaOgModeWithAliases(): void + { + foreach (['minimal' => 'minimal', 'MINIMAL' => 'minimal', 'per-share' => 'per-share', 'per_share' => 'per-share', 'full' => 'per-share', 'per-share-full' => 'per-share', ' none ' => 'none'] as $raw => $expected) { + $this->configure(['KITTYSHARE_META_OG_MODE' => (string) $raw]); + + $this->assertSame($expected, ConfigManager::get()->metaOgMode, "og mode '$raw'"); + } + } + + #[Test] + public function invalidMetaOgModeThrows(): void + { + $this->configure(['KITTYSHARE_META_OG_MODE' => 'everything']); + + $this->expectException(RuntimeException::class); + ConfigManager::get(); + } + + #[Test] + public function parsesFileServerBackends(): void + { + foreach (['php' => FileServerType::Php, 'php-stream' => FileServerType::Php, 'x-sendfile' => FileServerType::XSendfile, 'apache' => FileServerType::XSendfile, ' X-SENDFILE ' => FileServerType::XSendfile] as $raw => $expected) { + $this->configure(['KITTYSHARE_FILE_SERVER' => $raw]); + + $this->assertSame($expected, ConfigManager::get()->fileServer, "file server '$raw'"); + } + } + + #[Test] + public function unknownFileServerThrows(): void + { + $this->configure(['KITTYSHARE_FILE_SERVER' => 'nginx']); + + $this->expectException(RuntimeException::class); + ConfigManager::get(); + } + + #[Test] + public function protocolRelativeBaseUrlThrows(): void + { + $this->configure(['KITTYSHARE_BASE_URL' => '//evil.com/foo']); + + $this->expectException(RuntimeException::class); + ConfigManager::get(); + } + + #[Test] + public function baseUrlWithQuotesThrows(): void + { + foreach (['/app">configure(['KITTYSHARE_BASE_URL' => $value]); + + try { + ConfigManager::get(); + $thrown = false; + } catch (RuntimeException) { + $thrown = true; + } + + $this->assertTrue($thrown, "base URL '$value' must throw"); + } + } +} From c167b8ba90c5f1e2598c5cfc84c167474921a92a Mon Sep 17 00:00:00 2001 From: QuickWrite Date: Tue, 29 Sep 2026 21:57:18 +0200 Subject: [PATCH 07/15] Add unit tests for the managers --- tests/Manager/AuthenticationManagerTest.php | 413 ++++++++++++++++++++ tests/Manager/DependencyManagerTest.php | 241 ++++++++++++ 2 files changed, 654 insertions(+) create mode 100644 tests/Manager/AuthenticationManagerTest.php create mode 100644 tests/Manager/DependencyManagerTest.php diff --git a/tests/Manager/AuthenticationManagerTest.php b/tests/Manager/AuthenticationManagerTest.php new file mode 100644 index 0000000..0f0e673 --- /dev/null +++ b/tests/Manager/AuthenticationManagerTest.php @@ -0,0 +1,413 @@ + 4]) + ); + } + + private function makeSession(string $id = 'session-id', ?UserIdentity $user = null, ?DateTimeImmutable $expiresAt = null): Session + { + return new Session( + $id, + $user ?? new UserIdentity(1, 'test'), + $expiresAt ?? new DateTimeImmutable('+1 hour') + ); + } + + private function stubUserRepository(?User $user): UserRepository + { + $repo = $this->createStub(UserRepository::class); + $repo->method('getUser')->willReturn($user); + + return $repo; + } + + private function stubSessionRepository(?Session $sessionToCreate = null): SessionRepository + { + $repo = $this->createStub(SessionRepository::class); + + if ($sessionToCreate !== null) { + $repo->method('create')->willReturn($sessionToCreate); + } + + return $repo; + } + + #[Test] + public function loginSuccessful(): void + { + $user = $this->makeUser(); + $session = $this->makeSession('new-session-id', new UserIdentity(1, 'test')); + + $manager = new AuthenticationManager( + $this->stubUserRepository($user), + $this->stubSessionRepository($session) + ); + + $result = $manager->login('test', 'correct-password'); + + $this->assertSame($session, $result); + $this->assertSame('new-session-id', HttpSession::get(self::SESSION_KEY)); + } + + #[Test] + public function loginReturnsNullForUnknownUser(): void + { + $sessionRepository = $this->createMock(SessionRepository::class); + $sessionRepository->expects($this->never())->method('create'); + + $manager = new AuthenticationManager( + $this->stubUserRepository(null), + $sessionRepository + ); + + $this->assertNull($manager->login('nobody', 'whatever')); + $this->assertNull(HttpSession::get(self::SESSION_KEY)); + } + + #[Test] + public function loginReturnsNullOnWrongPassword(): void + { + $sessionRepository = $this->createMock(SessionRepository::class); + $sessionRepository->expects($this->never())->method('create'); + + $manager = new AuthenticationManager( + $this->stubUserRepository($this->makeUser()), + $sessionRepository + ); + + $this->assertNull($manager->login('test', 'wrong-password')); + $this->assertNull(HttpSession::get(self::SESSION_KEY)); + } + + #[Test] + public function loginReturnsNullOnEmptyPassword(): void + { + $sessionRepository = $this->createMock(SessionRepository::class); + $sessionRepository->expects($this->never())->method('create'); + + $manager = new AuthenticationManager( + $this->stubUserRepository($this->makeUser()), + $sessionRepository + ); + + $this->assertNull($manager->login('test', '')); + $this->assertNull(HttpSession::get(self::SESSION_KEY)); + } + + #[Test] + public function loginDoesNotRegenerateSessionIdOnFailure(): void + { + $manager = new AuthenticationManager( + $this->stubUserRepository($this->makeUser()), + $this->stubSessionRepository() + ); + + $before = session_id(); + $manager->login('test', 'wrong-password'); + + $this->assertSame($before, session_id()); + $this->assertNull(HttpSession::get(self::SESSION_KEY)); + } + + #[Test] + public function loginRegeneratesSessionIdToPreventFixation(): void + { + $manager = new AuthenticationManager( + $this->stubUserRepository($this->makeUser()), + $this->stubSessionRepository($this->makeSession('new-session-id')) + ); + + $before = session_id(); + + $manager->login('test', 'correct-password'); + + $this->assertNotSame($before, session_id()); + } + + #[Test] + public function loginPassesAuthenticatedUserToSessionRepository(): void + { + $user = $this->makeUser(); + $session = $this->makeSession('new-session-id'); + + $userRepository = $this->createStub(UserRepository::class); + $userRepository->method('getUser')->willReturn($user); + + $sessionRepository = $this->createMock(SessionRepository::class); + $sessionRepository->expects($this->once()) + ->method('create') + ->with($this->identicalTo($user)) + ->willReturn($session); + + $manager = new AuthenticationManager($userRepository, $sessionRepository); + + $manager->login('test', 'correct-password'); + } + + #[Test] + public function loginFailurePreservesPreExistingSession(): void + { + $manager = new AuthenticationManager( + $this->stubUserRepository($this->makeUser()), + $this->stubSessionRepository() + ); + + HttpSession::set(self::SESSION_KEY, 'old-session-id'); + + $this->assertNull($manager->login('test', 'wrong-password')); + + $this->assertSame('old-session-id', HttpSession::get(self::SESSION_KEY)); + } + + #[Test] + public function loginOverwritesPreExistingSessionId(): void + { + $session = $this->makeSession('brand-new-id'); + + $manager = new AuthenticationManager( + $this->stubUserRepository($this->makeUser()), + $this->stubSessionRepository($session) + ); + + HttpSession::set(self::SESSION_KEY, 'stale-id'); + + $result = $manager->login('test', 'correct-password'); + + $this->assertSame($session, $result); + $this->assertSame('brand-new-id', HttpSession::get(self::SESSION_KEY)); + } + + #[Test] + public function currentSessionReturnsNullWhenNothingStored(): void + { + $sessionRepository = $this->createMock(SessionRepository::class); + $sessionRepository->expects($this->never())->method('find'); + + $manager = new AuthenticationManager( + $this->createStub(UserRepository::class), + $sessionRepository + ); + + $this->assertNull($manager->currentSession()); + } + + #[Test] + public function currentSessionReturnsNullForNonStringIdWithoutQueryingRepository(): void + { + HttpSession::set(self::SESSION_KEY, 12345); + + $sessionRepository = $this->createMock(SessionRepository::class); + $sessionRepository->expects($this->never())->method('find'); + + $manager = new AuthenticationManager( + $this->createStub(UserRepository::class), + $sessionRepository + ); + + $this->assertNull($manager->currentSession()); + } + + #[Test] + public function currentSessionReturnsValidSession(): void + { + $session = $this->makeSession('valid-id'); + + $sessionRepository = $this->createMock(SessionRepository::class); + $sessionRepository->expects($this->once()) + ->method('find') + ->with('valid-id') + ->willReturn($session); + + $manager = new AuthenticationManager( + $this->createStub(UserRepository::class), + $sessionRepository + ); + + HttpSession::set(self::SESSION_KEY, 'valid-id'); + + $this->assertSame($session, $manager->currentSession()); + $this->assertSame('valid-id', HttpSession::get(self::SESSION_KEY)); + } + + #[Test] + public function currentSessionClearsKeyWhenSessionNotFound(): void + { + $sessionRepository = $this->createMock(SessionRepository::class); + $sessionRepository->expects($this->once()) + ->method('find') + ->with('ghost-id') + ->willReturn(null); + + $manager = new AuthenticationManager( + $this->createStub(UserRepository::class), + $sessionRepository + ); + + HttpSession::set(self::SESSION_KEY, 'ghost-id'); + + $this->assertNull($manager->currentSession()); + $this->assertNull(HttpSession::get(self::SESSION_KEY)); + } + + #[Test] + public function currentSessionClearsKeyWhenSessionExpired(): void + { + $expired = $this->makeSession('expired-id', null, new DateTimeImmutable('-1 hour')); + + $sessionRepository = $this->createMock(SessionRepository::class); + $sessionRepository->expects($this->once()) + ->method('find') + ->with('expired-id') + ->willReturn($expired); + + $manager = new AuthenticationManager( + $this->createStub(UserRepository::class), + $sessionRepository + ); + + HttpSession::set(self::SESSION_KEY, 'expired-id'); + + $this->assertNull($manager->currentSession()); + $this->assertNull(HttpSession::get(self::SESSION_KEY)); + } + + #[Test] + public function logoutDeletesSessionAndClearsKey(): void + { + $session = $this->makeSession('logout-id'); + + $sessionRepository = $this->createMock(SessionRepository::class); + $sessionRepository->expects($this->once()) + ->method('find') + ->with('logout-id') + ->willReturn($session); + $sessionRepository->expects($this->once())->method('delete')->with($this->identicalTo($session)); + + $manager = new AuthenticationManager( + $this->createStub(UserRepository::class), + $sessionRepository + ); + + HttpSession::set(self::SESSION_KEY, 'logout-id'); + + $manager->logout(); + + $this->assertNull(HttpSession::get(self::SESSION_KEY)); + } + + #[Test] + public function logoutWithoutStoredIdDoesNotTouchRepository(): void + { + $sessionRepository = $this->createMock(SessionRepository::class); + $sessionRepository->expects($this->never())->method('find'); + $sessionRepository->expects($this->never())->method('delete'); + + $manager = new AuthenticationManager( + $this->createStub(UserRepository::class), + $sessionRepository + ); + + $manager->logout(); + + $this->assertNull(HttpSession::get(self::SESSION_KEY)); + } + + #[Test] + public function logoutWithUnknownIdClearsKeyWithoutDelete(): void + { + $sessionRepository = $this->createMock(SessionRepository::class); + $sessionRepository->expects($this->once()) + ->method('find') + ->with('ghost-id') + ->willReturn(null); + $sessionRepository->expects($this->never())->method('delete'); + + $manager = new AuthenticationManager( + $this->createStub(UserRepository::class), + $sessionRepository + ); + + HttpSession::set(self::SESSION_KEY, 'ghost-id'); + + $manager->logout(); + + $this->assertNull(HttpSession::get(self::SESSION_KEY)); + } + + #[Test] + public function logoutWithNonStringIdClearsKeyWithoutRepoInteraction(): void + { + HttpSession::set(self::SESSION_KEY, ['not', 'a', 'string']); + + $sessionRepository = $this->createMock(SessionRepository::class); + $sessionRepository->expects($this->never())->method('find'); + $sessionRepository->expects($this->never())->method('delete'); + + $manager = new AuthenticationManager( + $this->createStub(UserRepository::class), + $sessionRepository + ); + + $manager->logout(); + + $this->assertNull(HttpSession::get(self::SESSION_KEY)); + } + + #[Test] + public function logoutIsIdempotent(): void + { + $session = $this->makeSession('logout-id'); + + $sessionRepository = $this->createMock(SessionRepository::class); + $sessionRepository->expects($this->once()) + ->method('find') + ->with('logout-id') + ->willReturn($session); + $sessionRepository->expects($this->once())->method('delete')->with($session); + + $manager = new AuthenticationManager( + $this->createStub(UserRepository::class), + $sessionRepository + ); + + HttpSession::set(self::SESSION_KEY, 'logout-id'); + + $manager->logout(); + $manager->logout(); + + $this->assertNull(HttpSession::get(self::SESSION_KEY)); + } +} diff --git a/tests/Manager/DependencyManagerTest.php b/tests/Manager/DependencyManagerTest.php new file mode 100644 index 0000000..34c7a7f --- /dev/null +++ b/tests/Manager/DependencyManagerTest.php @@ -0,0 +1,241 @@ + */ + private array $tempFiles = []; + + /** @var array */ + private array $originalEnv = []; + + protected function setUp(): void + { + parent::setUp(); + + foreach (['KITTYSHARE_DATABASE_PATH', 'KITTYSHARE_SESSION_LIFETIME'] as $name) { + $this->originalEnv[$name] = getenv($name); + putenv($name); + } + + $this->resetManagers(); + HttpSession::remove(self::SESSION_KEY); + } + + protected function tearDown(): void + { + HttpSession::remove(self::SESSION_KEY); + + foreach ($this->originalEnv as $name => $value) { + if ($value === false) { + putenv($name); + } else { + putenv($name . '=' . $value); + } + } + + $this->resetManagers(); + + foreach ($this->tempFiles as $path) { + foreach ([$path, $path . '-wal', $path . '-shm', $path . '-journal'] as $file) { + if (is_file($file)) { + unlink($file); + } + } + } + + parent::tearDown(); + } + + private function resetManagers(): void + { + (new ReflectionProperty(ConfigManager::class, 'config'))->setValue(null, null); + (new ReflectionProperty(DependencyManager::class, 'dependencies'))->setValue(null, null); + } + + private function configure(array $env): void + { + foreach ($env as $name => $value) { + putenv($name . '=' . $value); + } + + $this->resetManagers(); + } + + private function createMigratedDatabase(): string + { + $base = tempnam(sys_get_temp_dir(), 'kittyshare-dep-'); + assert(is_string($base)); + unlink($base); + + $path = $base . '.sqlite'; + $this->tempFiles[] = $path; + + $pdo = SQLiteDatabase::connect($path); + SQLiteMigrator::migrate($pdo); + $pdo = null; + + return $path; + } + + + private function createUnmigratedDatabase(): string + { + $base = tempnam(sys_get_temp_dir(), 'kittyshare-dep-'); + assert(is_string($base)); + unlink($base); + + $path = $base . '.sqlite'; + $this->tempFiles[] = $path; + + $pdo = SQLiteDatabase::connect($path); + $pdo = null; + + return $path; + } + + #[Test] + public function returnsSameInstanceWhileCached(): void + { + $path = $this->createMigratedDatabase(); + $this->configure(['KITTYSHARE_DATABASE_PATH' => $path]); + + $this->assertSame(DependencyManager::get(), DependencyManager::get()); + } + + #[Test] + public function exposesAllRepositoriesWithCorrectTypes(): void + { + $path = $this->createMigratedDatabase(); + $this->configure(['KITTYSHARE_DATABASE_PATH' => $path]); + + $dependencies = DependencyManager::get(); + + $this->assertInstanceOf(SQLiteUserRepository::class, $dependencies->userRepository); + $this->assertInstanceOf(SQLiteSetupRepository::class, $dependencies->setupRepository); + $this->assertInstanceOf(SQLiteSessionRepository::class, $dependencies->sessionRepository); + $this->assertInstanceOf(SQLiteShareRepository::class, $dependencies->shareRepository); + $this->assertInstanceOf(AuthenticationManager::class, $dependencies->authenticationManager); + } + + #[Test] + public function authenticationManagerSharesManagedRepositories(): void + { + $path = $this->createMigratedDatabase(); + $this->configure(['KITTYSHARE_DATABASE_PATH' => $path]); + + $dependencies = DependencyManager::get(); + + $userProperty = new ReflectionProperty(AuthenticationManager::class, 'userRepository'); + $sessionProperty = new ReflectionProperty(AuthenticationManager::class, 'sessionRepository'); + + $this->assertSame($dependencies->userRepository, $userProperty->getValue($dependencies->authenticationManager)); + $this->assertSame($dependencies->sessionRepository, $sessionProperty->getValue($dependencies->authenticationManager)); + } + + #[Test] + public function honoursSessionLifetimeFromConfig(): void + { + $path = $this->createMigratedDatabase(); + $this->configure(['KITTYSHARE_DATABASE_PATH' => $path, 'KITTYSHARE_SESSION_LIFETIME' => '1234']); + + $lifetime = new ReflectionProperty(SQLiteSessionRepository::class, 'sessionLifetime'); + + $this->assertSame(1234, $lifetime->getValue(DependencyManager::get()->sessionRepository)); + } + + #[Test] + public function usesDefaultSessionLifetimeWhenUnset(): void + { + $path = $this->createMigratedDatabase(); + $this->configure(['KITTYSHARE_DATABASE_PATH' => $path]); + + $lifetime = new ReflectionProperty(SQLiteSessionRepository::class, 'sessionLifetime'); + + $this->assertSame(60 * 60 * 24 * 30, $lifetime->getValue(DependencyManager::get()->sessionRepository)); + } + + #[Test] + public function writesToConfiguredDatabaseFile(): void + { + $path = $this->createMigratedDatabase(); + $this->configure(['KITTYSHARE_DATABASE_PATH' => $path]); + + DependencyManager::get()->userRepository->createUser('alice', 's3cret'); + + $check = SQLiteDatabase::connect($path); + $count = $check->query("SELECT COUNT(*) FROM users WHERE username = 'alice'")->fetchColumn(); + $check = null; + + $this->assertEquals(1, $count); + } + + #[Test] + public function rejectsDatabaseWithInvalidVersion(): void + { + $path = $this->createUnmigratedDatabase(); + $this->configure(['KITTYSHARE_DATABASE_PATH' => $path]); + + $this->expectException(DatabaseVersionException::class); + + DependencyManager::get(); + } + + #[Test] + public function freshDatabaseRefusesWithMigrationHint(): void + { + $path = $this->createUnmigratedDatabase(); + $this->configure(['KITTYSHARE_DATABASE_PATH' => $path]); + + try { + DependencyManager::get(); + + $this->fail('fresh database must refuse to start'); + } catch (DatabaseVersionException $e) { + + $this->assertStringContainsString('bin/migrate', $e->getMessage()); + } + } + + #[Test] + public function managedRepositoriesWorkEndToEnd(): void + { + $path = $this->createMigratedDatabase(); + $this->configure(['KITTYSHARE_DATABASE_PATH' => $path]); + + $dependencies = DependencyManager::get(); + $dependencies->userRepository->createUser('bob', 's3cret'); + + $session = $dependencies->authenticationManager->login('bob', 's3cret'); + + $this->assertNotNull($session); + $this->assertSame('bob', $session->user->username); + $this->assertSame($session->id, HttpSession::get(self::SESSION_KEY)); + + $current = $dependencies->authenticationManager->currentSession(); + + $this->assertNotNull($current); + $this->assertSame($session->id, $current->id); + $this->assertSame('bob', $current->user->username); + + $dependencies->authenticationManager->logout(); + + $this->assertNull(HttpSession::get(self::SESSION_KEY)); + $this->assertNull($dependencies->authenticationManager->currentSession()); + } +} From 348d099e7aa24287453fb4e139c632ca6921c351 Mon Sep 17 00:00:00 2001 From: QuickWrite Date: Tue, 29 Sep 2026 22:02:30 +0200 Subject: [PATCH 08/15] Remove stale test As the config system is now different the test does not work anymore. --- tests/Http/FileResponseFactoryTest.php | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/tests/Http/FileResponseFactoryTest.php b/tests/Http/FileResponseFactoryTest.php index 522756c..bed7e68 100644 --- a/tests/Http/FileResponseFactoryTest.php +++ b/tests/Http/FileResponseFactoryTest.php @@ -68,17 +68,6 @@ public function createsXSendfileResponseForApacheAlias(): void $this->assertInstanceOf(XSendfileResponse::class, $response); } - #[Test] - public function fallsBackToFileResponseForUnknownBackend(): void - { - putenv('KITTYSHARE_FILE_SERVER=unknown-backend'); - $this->resetConfig(); - - $response = FileResponseFactory::forFile('/tmp/example.txt', 'text/plain'); - - $this->assertInstanceOf(FileResponse::class, $response); - } - #[Test] public function forwardsArgumentsToFileResponse(): void { From ce021f342eef37571277dbcff7c469a9fe4a1ee2 Mon Sep 17 00:00:00 2001 From: QuickWrite Date: Wed, 30 Sep 2026 00:13:05 +0200 Subject: [PATCH 09/15] Add SQLite repository tests --- .../SQLiteDBVersionRepositoryTest.php | 49 ++++++++++ .../SQLiteSessionRepositoryTest.php | 65 ++++++++++++++ .../Repository/SQLiteSetupRepositoryTest.php | 48 ++++++++++ .../Repository/SQLiteShareRepositoryTest.php | 89 +++++++++++++++++++ tests/Repository/SQLiteUserRepositoryTest.php | 70 +++++++++++++++ 5 files changed, 321 insertions(+) create mode 100644 tests/Repository/SQLiteDBVersionRepositoryTest.php create mode 100644 tests/Repository/SQLiteSessionRepositoryTest.php create mode 100644 tests/Repository/SQLiteSetupRepositoryTest.php create mode 100644 tests/Repository/SQLiteShareRepositoryTest.php create mode 100644 tests/Repository/SQLiteUserRepositoryTest.php diff --git a/tests/Repository/SQLiteDBVersionRepositoryTest.php b/tests/Repository/SQLiteDBVersionRepositoryTest.php new file mode 100644 index 0000000..18f4340 --- /dev/null +++ b/tests/Repository/SQLiteDBVersionRepositoryTest.php @@ -0,0 +1,49 @@ +dbPath = $base . '.sqlite'; + $this->pdo = SQLiteDatabase::connect($this->dbPath); + + SQLiteMigrator::migrate($this->pdo); + } + + protected function tearDown(): void + { + $this->pdo = new PDO('sqlite::memory:'); + + foreach ([$this->dbPath, $this->dbPath . '-wal', $this->dbPath . '-shm', $this->dbPath . '-journal'] as $f) { + if (is_file($f)) { + unlink($f); + } + } + + parent::tearDown(); + } + + #[Test] + public function currentVersionMatchesExpected(): void + { + $repo = new SQLiteDBVersionRepository($this->pdo); + + $this->assertSame(SQLiteDBVersionRepository::EXPECTED_VERSION, $repo->currentVersion()); + $this->assertTrue($repo->isValid()); + } +} diff --git a/tests/Repository/SQLiteSessionRepositoryTest.php b/tests/Repository/SQLiteSessionRepositoryTest.php new file mode 100644 index 0000000..01755cb --- /dev/null +++ b/tests/Repository/SQLiteSessionRepositoryTest.php @@ -0,0 +1,65 @@ +dbPath = $base . '.sqlite'; + $this->pdo = SQLiteDatabase::connect($this->dbPath); + + SQLiteMigrator::migrate($this->pdo); + } + + protected function tearDown(): void + { + $this->pdo = new PDO('sqlite::memory:'); + + foreach ([$this->dbPath, $this->dbPath . '-wal', $this->dbPath . '-shm', $this->dbPath . '-journal'] as $f) { + if (is_file($f)) { + unlink($f); + } + } + + parent::tearDown(); + } + + #[Test] + public function createFindAndDelete(): void + { + $users = new SQLiteUserRepository($this->pdo); + $user = $users->createUser('alice', 'pw'); + $identity = new UserIdentity($user->userId, $user->username); + + $repos = new SQLiteSessionRepository($this->pdo, 3600); + $session = $repos->create($identity); + + $this->assertFalse($session->isExpired()); + $this->assertNotNull($repos->find($session->id)); + $repos->delete($session); + + $this->assertNull($repos->find($session->id)); + } + + #[Test] + public function unknownSessionReturnsNull(): void + { + $this->assertNull((new SQLiteSessionRepository($this->pdo))->find('ghost')); + } +} diff --git a/tests/Repository/SQLiteSetupRepositoryTest.php b/tests/Repository/SQLiteSetupRepositoryTest.php new file mode 100644 index 0000000..17b0372 --- /dev/null +++ b/tests/Repository/SQLiteSetupRepositoryTest.php @@ -0,0 +1,48 @@ +dbPath = $base . '.sqlite'; + $this->pdo = SQLiteDatabase::connect($this->dbPath); + + SQLiteMigrator::migrate($this->pdo); + } + + protected function tearDown(): void + { + $this->pdo = new PDO('sqlite::memory:'); + foreach ([$this->dbPath, $this->dbPath . '-wal', $this->dbPath . '-shm', $this->dbPath . '-journal'] as $f) { + if (is_file($f)) { + unlink($f); + } + } + parent::tearDown(); + } + + #[Test] + public function setupRequiredUntilFirstUser(): void + { + $this->assertTrue((new SQLiteSetupRepository($this->pdo))->setupRequired()); + (new SQLiteUserRepository($this->pdo))->createUser('admin', 'pw'); + + $this->assertFalse((new SQLiteSetupRepository($this->pdo))->setupRequired()); + } +} diff --git a/tests/Repository/SQLiteShareRepositoryTest.php b/tests/Repository/SQLiteShareRepositoryTest.php new file mode 100644 index 0000000..3af5c14 --- /dev/null +++ b/tests/Repository/SQLiteShareRepositoryTest.php @@ -0,0 +1,89 @@ +dbPath = $base . '.sqlite'; + $this->pdo = SQLiteDatabase::connect($this->dbPath); + + SQLiteMigrator::migrate($this->pdo); + } + + protected function tearDown(): void + { + $this->pdo = new PDO('sqlite::memory:'); + + foreach ([$this->dbPath, $this->dbPath . '-wal', $this->dbPath . '-shm', $this->dbPath . '-journal'] as $f) { + if (is_file($f)) { + unlink($f); + } + } + + parent::tearDown(); + } + + #[Test] + public function createFindAndDelete(): void + { + $users = new SQLiteUserRepository($this->pdo); + $user = $users->createUser('alice', 'pw'); + $identity = new UserIdentity($user->userId, $user->username); + + $repos = new SQLiteShareRepository($this->pdo); + $share = $repos->create($identity, '/tmp'); + + $this->assertNotNull($repos->find($share->id)); + $repos->delete($share); + + $this->assertNull($repos->find($share->id)); + } + + #[Test] + public function findByUserOnlyReturnsOwnShares(): void + { + $users = new SQLiteUserRepository($this->pdo); + $alice = $users->createUser('alice', 'pw'); + $bob = $users->createUser('bob', 'pw'); + + $repos = new SQLiteShareRepository($this->pdo); + $repos->create(new UserIdentity($alice->userId, $alice->username), '/tmp/a'); + $repos->create(new UserIdentity($bob->userId, $bob->username), '/tmp/b'); + $list = $repos->findByUser(new UserIdentity($alice->userId, $alice->username)); + + $this->assertCount(1, $list); + $this->assertSame('/tmp/a', $list[0]->filepath); + } + + #[Test] + public function saveRevocation(): void + { + $users = new SQLiteUserRepository($this->pdo); + $user = $users->createUser('alice', 'pw'); + + $repos = new SQLiteShareRepository($this->pdo); + $share = $repos->create(new UserIdentity($user->userId, $user->username), '/tmp'); + $repos->save($share->revoke()); + $fresh = $repos->find($share->id); + + $this->assertNotNull($fresh); + $this->assertTrue($fresh->isRevoked()); + } +} diff --git a/tests/Repository/SQLiteUserRepositoryTest.php b/tests/Repository/SQLiteUserRepositoryTest.php new file mode 100644 index 0000000..adcc40e --- /dev/null +++ b/tests/Repository/SQLiteUserRepositoryTest.php @@ -0,0 +1,70 @@ +dbPath = $base . '.sqlite'; + $this->pdo = SQLiteDatabase::connect($this->dbPath); + + SQLiteMigrator::migrate($this->pdo); + } + + protected function tearDown(): void + { + $this->pdo = new PDO('sqlite::memory:'); + + foreach ([$this->dbPath, $this->dbPath . '-wal', $this->dbPath . '-shm', $this->dbPath . '-journal'] as $f) { + if (is_file($f)) { + unlink($f); + } + } + + parent::tearDown(); + } + + #[Test] + public function createAndFindUser(): void + { + $repo = new SQLiteUserRepository($this->pdo); + $created = $repo->createUser('alice', 'pw'); + + $this->assertSame('alice', $created->username); + $found = $repo->getUser('alice'); + + $this->assertNotNull($found); + $this->assertTrue(password_verify('pw', $found->passwordHash)); + } + + #[Test] + public function unknownUserReturnsNull(): void + { + $this->assertNull((new SQLiteUserRepository($this->pdo))->getUser('ghost')); + } + + #[Test] + public function duplicateUsernameThrows(): void + { + $repo = new SQLiteUserRepository($this->pdo); + $repo->createUser('alice', 'pw'); + + $this->expectException(PDOException::class); + $repo->createUser('alice', 'other'); + } +} From b136be0a15506050028b6fb9d814ac7b18b9fa4e Mon Sep 17 00:00:00 2001 From: QuickWrite Date: Wed, 30 Sep 2026 00:20:23 +0200 Subject: [PATCH 10/15] Add SQLite database tests --- tests/Database/SQLiteDatabaseTest.php | 84 +++++++++++++++++++++++++++ tests/Database/SQLiteMigratorTest.php | 66 +++++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 tests/Database/SQLiteDatabaseTest.php create mode 100644 tests/Database/SQLiteMigratorTest.php diff --git a/tests/Database/SQLiteDatabaseTest.php b/tests/Database/SQLiteDatabaseTest.php new file mode 100644 index 0000000..f23b59f --- /dev/null +++ b/tests/Database/SQLiteDatabaseTest.php @@ -0,0 +1,84 @@ +dbPath = $base . '.sqlite'; + } + + #[Override] + protected function tearDown(): void + { + foreach ([$this->dbPath, $this->dbPath . '-wal', $this->dbPath . '-shm', $this->dbPath . '-journal'] as $f) { + if ($f !== '' && is_file($f)) { + unlink($f); + } + } + + parent::tearDown(); + } + + #[Test] + public function connectCreatesUsableConnection(): void + { + $pdo = SQLiteDatabase::connect($this->dbPath); + + $this->assertSame('sqlite', $pdo->getAttribute(PDO::ATTR_DRIVER_NAME)); + $this->assertSame(1, (int) $pdo->query('PRAGMA foreign_keys')->fetchColumn()); + $this->assertFileExists($this->dbPath); + + $pdo->exec('CREATE TABLE test (id INTEGER PRIMARY KEY, value TEXT NOT NULL)'); + $pdo->exec("INSERT INTO test (value) VALUES ('hello')"); + + $this->assertSame( + 'hello', + $pdo->query('SELECT value FROM test WHERE id = 1')->fetchColumn() + ); + } + + #[Test] + public function foreignKeysAreEnabledAndEnforced(): void + { + $pdo = SQLiteDatabase::connect($this->dbPath); + + $this->assertSame( + 1, + (int) $pdo->query('PRAGMA foreign_keys')->fetchColumn() + ); + + $pdo->exec(' + CREATE TABLE parent ( + id INTEGER PRIMARY KEY + ) + '); + + $pdo->exec(' + CREATE TABLE child ( + id INTEGER PRIMARY KEY, + parent_id INTEGER NOT NULL, + FOREIGN KEY (parent_id) REFERENCES parent(id) + ) + '); + + $this->expectException(PDOException::class); + + $pdo->exec(' + INSERT INTO child (parent_id) + VALUES (999) + '); + } +} diff --git a/tests/Database/SQLiteMigratorTest.php b/tests/Database/SQLiteMigratorTest.php new file mode 100644 index 0000000..d71c024 --- /dev/null +++ b/tests/Database/SQLiteMigratorTest.php @@ -0,0 +1,66 @@ +dbPath = $base . '.sqlite'; + $this->pdo = SQLiteDatabase::connect($this->dbPath); + } + + #[Override] + protected function tearDown(): void + { + $this->pdo = new PDO('sqlite::memory:'); + + foreach ([$this->dbPath, $this->dbPath . '-wal', $this->dbPath . '-shm', $this->dbPath . '-journal'] as $f) { + if (is_file($f)) { + unlink($f); + } + } + + parent::tearDown(); + } + + #[Test] + public function migrateAppliesAllVersions(): void + { + $applied = SQLiteMigrator::migrate($this->pdo); + + $this->assertSame([1, 2, 3], $applied); + $this->assertSame(3, (new SQLiteDBVersionRepository($this->pdo))->currentVersion()); + } + + #[Test] + public function migrateIsIdempotent(): void + { + SQLiteMigrator::migrate($this->pdo); + + $this->assertSame([], SQLiteMigrator::migrate($this->pdo)); + } + + #[Test] + public function migrateRefusesDatabaseNewerThanExpected(): void + { + $this->pdo->exec('PRAGMA user_version = 99'); + + $this->expectException(KittyShare\Database\MigrationMismatchException::class); + SQLiteMigrator::migrate($this->pdo); + } +} From 9f823fc44547b631431439f366fae888f2e11ab1 Mon Sep 17 00:00:00 2001 From: QuickWrite Date: Wed, 30 Sep 2026 00:30:00 +0200 Subject: [PATCH 11/15] Fix: Migration does not throw expected exception --- src/Database/MigrationMismatchException.php | 38 ++++++++++++++++++--- src/Database/SQLiteMigrator.php | 11 +++++- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/Database/MigrationMismatchException.php b/src/Database/MigrationMismatchException.php index dbce4fb..6898293 100644 --- a/src/Database/MigrationMismatchException.php +++ b/src/Database/MigrationMismatchException.php @@ -5,17 +5,45 @@ use RuntimeException; /** - * Thrown when a migration file is newer than the expected schema version. + * Thrown when the migration state does not line up with the expected + * schema version: either a migration file is newer than the running + * application understands, or the database itself is. */ final class MigrationMismatchException extends RuntimeException { - public function __construct( - public readonly int $fileVersion, - public readonly int $expectedVersion, + private function __construct( + string $message, ) { - parent::__construct( + parent::__construct($message); + } + + /** + * Creates an exception for a migration file that is newer than the + * running application understands. + */ + public static function forMigrationFile( + int $fileVersion, + int $expectedVersion, + ): self { + return new self( "Migration file version V{$fileVersion} exceeds expected schema version {$expectedVersion}. " . 'Bump the EXPECTED_VERSION in the same commit as the new migration file.', ); } + + /** + * Creates an exception for a database that is newer than the running + * application understands. Migrations only move forward from older + * versions, so there is nothing to apply. + */ + public static function forNewerDatabase( + int $currentVersion, + int $expectedVersion, + ): self { + return new self( + "Database schema version {$currentVersion} is newer than " . + "expected schema version {$expectedVersion}. " . + 'The database was created by a newer application version.', + ); + } } diff --git a/src/Database/SQLiteMigrator.php b/src/Database/SQLiteMigrator.php index d89f272..426ca9a 100644 --- a/src/Database/SQLiteMigrator.php +++ b/src/Database/SQLiteMigrator.php @@ -33,6 +33,15 @@ public static function migrate(PDO $pdo): array $version = $versions->currentVersion(); $applied = []; + // A database from a newer application version cannot be migrated + // by this one; Refusing loudly + if ($version > SQLiteDBVersionRepository::EXPECTED_VERSION) { + throw MigrationMismatchException::forNewerDatabase( + $version, + SQLiteDBVersionRepository::EXPECTED_VERSION, + ); + } + foreach (self::migrationFiles($migrationsDir) as $file) { preg_match('/V(\d+)_/', basename($file), $m); assert(count($m) === 2, 'The matched values should not be empty'); @@ -44,7 +53,7 @@ public static function migrate(PDO $pdo): array } if ($fileVersion > SQLiteDBVersionRepository::EXPECTED_VERSION) { - throw new MigrationMismatchException($fileVersion, SQLiteDBVersionRepository::EXPECTED_VERSION); + throw MigrationMismatchException::forMigrationFile($fileVersion, SQLiteDBVersionRepository::EXPECTED_VERSION); } require_once $file; From 4ca7f766121372afc2ea795bb23b1137825bf62b Mon Sep 17 00:00:00 2001 From: QuickWrite Date: Wed, 30 Sep 2026 00:42:05 +0200 Subject: [PATCH 12/15] Add sanity checks If the user manages to get a file that has a \r\n inside of it (or something like that) the header would leak and a malicious header could be inserted. As such this is now checked (normally this should NEVER happen) as a sanity check inside of the Response object. --- src/Http/FileResponse.php | 6 ++++++ src/Http/XSendfileResponse.php | 10 ++++++++++ tests/Http/FileResponseTest.php | 8 ++++++++ tests/Http/XSendfileResponseTest.php | 16 ++++++++++++++++ 4 files changed, 40 insertions(+) diff --git a/src/Http/FileResponse.php b/src/Http/FileResponse.php index 3714515..26ff8dc 100644 --- a/src/Http/FileResponse.php +++ b/src/Http/FileResponse.php @@ -2,6 +2,8 @@ namespace KittyShare\Http; +use InvalidArgumentException; + /** * A response that sends a file to the client. */ @@ -19,6 +21,10 @@ public function __construct( private int $statusCode = 200, private int $chunkSize = 8192, ) { + // Sanity checks to prevent evil response headers + if (str_contains($contentType, "\r\n")) { + throw new InvalidArgumentException('The $contentType cannot contain CRLF.'); + } } /** diff --git a/src/Http/XSendfileResponse.php b/src/Http/XSendfileResponse.php index 79381cc..0cc9f0b 100644 --- a/src/Http/XSendfileResponse.php +++ b/src/Http/XSendfileResponse.php @@ -2,6 +2,8 @@ namespace KittyShare\Http; +use InvalidArgumentException; + /** * A response that delegates the file transfer to Apache via mod_xsendfile. */ @@ -17,6 +19,14 @@ public function __construct( private string $contentType, private int $statusCode = 200, ) { + // Sanity checks to prevent evil response headers + if (str_contains($file, "\r\n")) { + throw new InvalidArgumentException('The $file cannot contain CRLF.'); + } + + if (str_contains($contentType, "\r\n")) { + throw new InvalidArgumentException('The $contentType cannot contain CRLF.'); + } } public function send(): void diff --git a/tests/Http/FileResponseTest.php b/tests/Http/FileResponseTest.php index 164896c..3c11ee3 100644 --- a/tests/Http/FileResponseTest.php +++ b/tests/Http/FileResponseTest.php @@ -88,6 +88,14 @@ public function missingFileReturns404WithoutOutput(): void $this->assertStringContainsString('CODE:404', $result['stderr']); } + #[Test] + public function contentTypeWithCrlfIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + new FileResponse('/tmp/example.txt', "text/plain\r\nX-Evil: 1"); + } + private function writeTempFile(string $content): string { $file = tempnam(sys_get_temp_dir(), 'kittyshare-file-'); diff --git a/tests/Http/XSendfileResponseTest.php b/tests/Http/XSendfileResponseTest.php index 25eb04d..cc53d0a 100644 --- a/tests/Http/XSendfileResponseTest.php +++ b/tests/Http/XSendfileResponseTest.php @@ -80,6 +80,22 @@ public function missingFileReturns404WithoutOutput(): void $this->assertStringContainsString('CODE:404', $result['stderr']); } + #[Test] + public function filePathWithCrlfIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + new XSendfileResponse("/tmp/x\r\nX-Evil: 1", 'text/plain'); + } + + #[Test] + public function contentTypeWithCrlfIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + new XSendfileResponse('/tmp/example.txt', "text/plain\r\nX-Evil: 1"); + } + private function sendSnippet(string $file, string $contentType, int $statusCode): string { return sprintf( From 908aaeddde2d2f60bc61245bc213bcc9b359528c Mon Sep 17 00:00:00 2001 From: QuickWrite Date: Wed, 30 Sep 2026 00:51:40 +0200 Subject: [PATCH 13/15] Add more tests to the Router --- tests/Http/RouterTest.php | 41 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/Http/RouterTest.php b/tests/Http/RouterTest.php index 258baeb..fe34baa 100644 --- a/tests/Http/RouterTest.php +++ b/tests/Http/RouterTest.php @@ -180,6 +180,47 @@ public function preservesLiteralPlusInParameters(): void $this->assertSame('a+b', RouterTestStubController::$lastRequest?->urlParam('path')); } + #[Test] + public function encodedSlashDecodesInsideParam(): void + { + $this->router->get('/share/{id}', RouterTestStubController::class); + + $this->router->dispatch(Method::Get, '/share/a%2Fb'); + + $this->assertSame('a/b', RouterTestStubController::$lastRequest?->urlParam('id')); + } + + #[Test] + public function doubleEncodingIsOnlyDecodedOnce(): void + { + $this->router->get('/share/{id}/{...path}', RouterTestStubController::class); + + $this->router->dispatch(Method::Get, '/share/x/%252e%252e%252fetc'); + + $this->assertSame('%2e%2e%2fetc', RouterTestStubController::$lastRequest?->urlParam('path')); + } + + #[Test] + public function unsupportedMethodReturns404(): void + { + $this->router->get('/login', RouterTestStubController::class); + + $response = $this->router->dispatch(Method::Post, '/login'); + + $this->assertInstanceOf(TemplateResponse::class, $response); + $this->assertSame(404, $this->readProperty($response, 'statusCode')); + } + + #[Test] + public function pathWithoutLeadingSlashDoesNotMatch(): void + { + $this->router->get('/admin', RouterTestStubController::class); + + $response = $this->router->dispatch(Method::Get, 'ing'); + + $this->assertInstanceOf(TemplateResponse::class, $response); + } + private function resetConfig(): void { (new ReflectionProperty(ConfigManager::class, 'config'))->setValue(null, null); From 8cf1d905981bb9ae1d5a20fb2e924b5d91b50e9b Mon Sep 17 00:00:00 2001 From: QuickWrite Date: Wed, 30 Sep 2026 00:59:45 +0200 Subject: [PATCH 14/15] Ensure that the template names are valid --- src/Http/TemplateResponse.php | 12 ++++++++ tests/Http/TemplateResponseTest.php | 43 +++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/src/Http/TemplateResponse.php b/src/Http/TemplateResponse.php index 7485493..7f53db1 100644 --- a/src/Http/TemplateResponse.php +++ b/src/Http/TemplateResponse.php @@ -2,6 +2,7 @@ namespace KittyShare\Http; +use InvalidArgumentException; use Override; /** @@ -23,6 +24,17 @@ public function __construct( private int $statusCode = 200, private array $headers = [], ) { + if ($template === '') { + throw new InvalidArgumentException('Template name must not be empty.'); + } + + foreach (explode('/', $template) as $segment) { + if (preg_match('/^[A-Za-z0-9_-]+$/', $segment) !== 1) { + throw new InvalidArgumentException( + "Invalid template name '{$template}'.", + ); + } + } } /** diff --git a/tests/Http/TemplateResponseTest.php b/tests/Http/TemplateResponseTest.php index 8809e2e..05b3c4d 100644 --- a/tests/Http/TemplateResponseTest.php +++ b/tests/Http/TemplateResponseTest.php @@ -87,6 +87,49 @@ public function escapesTemplateParameters(): void $this->assertStringNotContainsString('/a', $output); } + #[Test] + public function templateNameWithTraversalIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + new TemplateResponse('../composer', []); + } + + #[Test] + public function acceptedValidTemplateNames(): void + { + foreach (['error/404', 'share/not-found', 'share/directory', 'a1/_-b9'] as $name) { + new TemplateResponse($name, []); + + $this->assertTrue(true, "template name '$name' must be accepted"); + } + } + + #[Test] + public function rejectedInvalidTemplateNames(): void + { + $rejected = 0; + + foreach ( + [ + '', '/', '/abs/path', 'a//b', 'a/b/', '/a', 'a/./b', + '../x', 'x/../y', '..\\win', 'back\\slash', 'with space', + 'with"quote', "with'apos", 'withfail("template name '$name' must be rejected"); + } + + $this->assertSame(21, $rejected); + } + private function readProperty(object $object, string $name): mixed { return (new ReflectionProperty($object, $name))->getValue($object); From a5f8cdfbce1a2cba20bf737635357f198dbc0437 Mon Sep 17 00:00:00 2001 From: QuickWrite Date: Wed, 30 Sep 2026 01:02:57 +0200 Subject: [PATCH 15/15] Fix naming in CI pipeline --- .github/workflows/composer.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/composer.yml b/.github/workflows/composer.yml index be9ec45..2a1779e 100644 --- a/.github/workflows/composer.yml +++ b/.github/workflows/composer.yml @@ -45,8 +45,5 @@ jobs: - name: Install dependencies run: composer install --prefer-dist --no-progress - - name: Lint and analyse + - name: Lint, analyse and test run: composer ci - - # - name: Run test suite - # run: composer test