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 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/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..3615708 100644 --- a/composer.lock +++ b/composer.lock @@ -4,16 +4,251 @@ "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": "044e048a4549897c1f7e7cd8c51327b3", "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", + "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": { @@ -69,7 +304,1690 @@ "type": "github" } ], - "time": "2026-09-03T20:38:19+00:00" + "time": "2026-09-25T09:31:51+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/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/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/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; 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/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/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/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/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/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 @@ +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); + } + } +} 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); + } +} 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); + } + } +} diff --git a/tests/Http/FileResponseFactoryTest.php b/tests/Http/FileResponseFactoryTest.php new file mode 100644 index 0000000..bed7e68 --- /dev/null +++ b/tests/Http/FileResponseFactoryTest.php @@ -0,0 +1,112 @@ +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 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..3c11ee3 --- /dev/null +++ b/tests/Http/FileResponseTest.php @@ -0,0 +1,128 @@ +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']); + } + + #[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-'); + + 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..f4d7129 --- /dev/null +++ b/tests/Http/RequestTest.php @@ -0,0 +1,141 @@ + */ + 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')); + } + + #[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); + } +} diff --git a/tests/Http/RouterTest.php b/tests/Http/RouterTest.php new file mode 100644 index 0000000..fe34baa --- /dev/null +++ b/tests/Http/RouterTest.php @@ -0,0 +1,233 @@ +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')); + } + + #[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); + } + + 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..05b3c4d --- /dev/null +++ b/tests/Http/TemplateResponseTest.php @@ -0,0 +1,137 @@ +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); + } + + #[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); + } +} diff --git a/tests/Http/XSendfileResponseTest.php b/tests/Http/XSendfileResponseTest.php new file mode 100644 index 0000000..cc53d0a --- /dev/null +++ b/tests/Http/XSendfileResponseTest.php @@ -0,0 +1,114 @@ +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']); + } + + #[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( + '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); + } +} 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/ConfigManagerTest.php b/tests/Manager/ConfigManagerTest.php new file mode 100644 index 0000000..f04c68c --- /dev/null +++ b/tests/Manager/ConfigManagerTest.php @@ -0,0 +1,495 @@ + */ + 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"); + } + } +} 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()); + } +} 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'); + } +}