From b2f1196128e6709d16966cfa44e2ea42ee55ca97 Mon Sep 17 00:00:00 2001 From: Alexandre Delaunay Date: Wed, 25 Mar 2026 12:42:26 +0100 Subject: [PATCH 1/4] add claude.md --- CLAUDE.md | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3a427f9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,106 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is the **GLPI Plugin Directory** — a web application for browsing and managing plugins for the GLPI IT management system. It consists of: + +- `api/` — PHP REST API built on Slim 2 + Eloquent ORM +- `frontend/` — AngularJS 1 SPA built with Grunt +- `specs/` — Modernization specs and test plans (unit, functional, e2e) +- `misc/` — Background task runner and DB initialization scripts + +The project is in **active modernization** (Step 1: adding test coverage is in progress). The stack is intentionally legacy (Slim 2, AngularJS 1) and will be upgraded in later steps. + +## Commands + +### API (PHP) + +```bash +cd api +composer install # Install dependencies +vendor/bin/phpunit # Run all tests +vendor/bin/phpunit --testsuite Unit # Unit tests only (no DB) +vendor/bin/phpunit --testsuite Functional # Functional tests (requires MySQL) +vendor/bin/phpunit tests/Unit/SomeTest.php # Single test file +``` + +Functional tests require a MySQL database. Default credentials (overridable via env vars): +- DB: `glpi_plugins_functional_test`, user: `glpi`, pass: `glpi`, host: `localhost` +- Env vars: `TEST_DB_HOST`, `TEST_DB_NAME`, `TEST_DB_USER`, `TEST_DB_PASS` + +### Frontend (JavaScript) + +```bash +cd frontend +npm install && bower install # Install dependencies +grunt build # Build production output to dist/ +grunt serve # Dev server at http://localhost:9000 +grunt test # Run Karma/Jasmine tests +``` + +### Docker + +```bash +docker-compose up -d # Start dev environment +docker exec -it plugins.glpi-project.org bash # Enter container +# Inside container: Apache on :8080, Node dev server on :9000 +``` + +### Background Tasks + +```bash +php misc/run_tasks.php # Run all tasks (plugin updates, token cleanup) +php misc/run_tasks.php -k genericobject -t update # Update specific plugin by key +php misc/run_tasks.php -i 44 -t update # Update plugin by DB id +``` + +## Architecture + +### API (Slim 2) + +Entry point: `api/index.php` — initializes Illuminate DB capsule, Slim app, OAuth2 resource server, then `require`s all files from `src/endpoints/`. + +``` +api/src/ +├── core/ # Tool.php (request/response helpers), DB.php, Mailer.php, PaginatedCollection.php, ValidableXMLPluginDescription.php +├── endpoints/ # One file per resource (Plugin.php, User.php, Author.php, OAuth.php, Tags.php, …) +├── models/ # Eloquent ORM models (Plugin, User, Author, PluginVersion, OAuth tokens, …) +├── exceptions/ # ErrorResponse base + subclasses (InvalidField, ResourceNotFound, …) +└── oauthserver/ # OAuthHelper.php — league/oauth2-server v4.1 storage & factory +``` + +**Key patterns:** +- Endpoint files are procedural PHP modules, not classes — they register Slim routes directly. +- Pagination uses HTTP headers (`x-range`, `x-lang`) rather than query params. +- OAuth2 supports `password`, `refresh_token`, and `client_credentials` grants. +- Config is loaded from `api/config.php` (copy from `api/config.example.php`). + +### Tests + +**Unit tests** (`api/tests/Unit/`) — no database, use Mockery. Cover `core/`, `models/`, `exceptions/`, `oauthserver/`. + +**Functional tests** (`api/tests/Functional/`) — spin up PHP built-in server as subprocess, send real HTTP requests via Guzzle. Each test class reloads seeds (`tests/Functional/seeds.sql`) and wraps tests in a rolled-back transaction. Schema is initialized once per suite from `tests/Functional/schema.sql`. + +**Frontend tests** (`frontend/test/spec/`) — Karma + Jasmine, cover controllers, services, directives, filters. + +### Frontend (AngularJS 1) + +SPA routed via `ng-route`. API endpoint configured in `frontend/app/scripts/conf.js` (copy from `conf.example.js`). Build output goes to `frontend/dist/`. + +## CI/CD + +GitHub Actions (`.github/workflows/tests_phpunit.yml`): +- **Unit**: PHP 8.2, 8.3, 8.4 matrix — no database +- **Functional**: PHP 8.4 + MySQL 8.0 — depends on unit passing +- Triggers on push/PR to `master` + +## Spec Files + +Detailed test specifications live in `specs/testing/`: +- `unit.md` — what unit tests should cover +- `functional.md` — what functional tests should cover +- `e2e.md` — E2E test plan + +`specs/api/endpoints.md` is the full REST API reference including auth scopes, request/response shapes, and pagination behavior. From 48ab997630a51b0d26259d12af658b25fb54d410 Mon Sep 17 00:00:00 2001 From: Alexandre Delaunay Date: Wed, 25 Mar 2026 12:42:38 +0100 Subject: [PATCH 2/4] add api unit tests --- .github/workflows/tests_phpunit.yml | 34 + .gitignore | 1 + api/composer.json | 25 +- api/composer.lock | 2944 ++++++++++++++--- api/phpunit.xml | 22 + .../Unit/Core/PaginatedCollectionTest.php | 222 ++ api/tests/Unit/Core/ToolTest.php | 247 ++ .../ValidableXMLPluginDescriptionTest.php | 212 ++ api/tests/Unit/Exception/ExceptionsTest.php | 336 ++ api/tests/Unit/Model/AppTest.php | 115 + api/tests/Unit/Model/AuthorTest.php | 81 + api/tests/Unit/Model/UserTest.php | 147 + .../Unit/OAuthServer/OAuthHelperTest.php | 153 + api/tests/Unit/TestCase.php | 56 + api/tests/bootstrap.php | 152 + specs/api/endpoints.md | 1033 ++++++ specs/testing/unit.md | 262 ++ 17 files changed, 5629 insertions(+), 413 deletions(-) create mode 100644 .github/workflows/tests_phpunit.yml create mode 100644 api/phpunit.xml create mode 100644 api/tests/Unit/Core/PaginatedCollectionTest.php create mode 100644 api/tests/Unit/Core/ToolTest.php create mode 100644 api/tests/Unit/Core/ValidableXMLPluginDescriptionTest.php create mode 100644 api/tests/Unit/Exception/ExceptionsTest.php create mode 100644 api/tests/Unit/Model/AppTest.php create mode 100644 api/tests/Unit/Model/AuthorTest.php create mode 100644 api/tests/Unit/Model/UserTest.php create mode 100644 api/tests/Unit/OAuthServer/OAuthHelperTest.php create mode 100644 api/tests/Unit/TestCase.php create mode 100644 api/tests/bootstrap.php create mode 100644 specs/api/endpoints.md create mode 100644 specs/testing/unit.md diff --git a/.github/workflows/tests_phpunit.yml b/.github/workflows/tests_phpunit.yml new file mode 100644 index 0000000..6a49e21 --- /dev/null +++ b/.github/workflows/tests_phpunit.yml @@ -0,0 +1,34 @@ +name: Tests + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + unit: + name: Unit tests (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + + strategy: + matrix: + php: [ "7.4" ] + + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP ${{ matrix.php }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: pdo_mysql, pdo_sqlite, sqlite3, dom, mbstring, json, simplexml, libxml, curl + coverage: none + + - name: Install dependencies + working-directory: api + run: composer install --no-interaction --prefer-dist --ignore-platform-reqs + + - name: Run unit tests + working-directory: api + run: vendor/bin/phpunit --configuration phpunit.xml diff --git a/.gitignore b/.gitignore index de8d2fa..21ccf2b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ api/config.php /misc/illuminate_queries.log .vagrant /docker-compose.override.yaml +api/.phpunit.result.cache diff --git a/api/composer.json b/api/composer.json index dddd90c..2a158b1 100644 --- a/api/composer.json +++ b/api/composer.json @@ -38,8 +38,31 @@ "API\\Exception\\": "src/exceptions" } }, + "require-dev": { + "guzzlehttp/guzzle": "^6.0", + "mockery/mockery": "^1.6", + "phpunit/phpunit": "^9.5", + "symfony/process": "^5.0" + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, + "extra": { + "upgrade-notes": [ + "slim/slim is pinned to ~2.0 — index.php has a get_magic_quotes_gpc() polyfill that must be removed when upgrading to Slim 3+.", + "illuminate/database is pinned to 5.1.* — vendor/nesbot/carbon Carbon.php has a manual PHP 8.2 compat patch (getLastErrors() returning false) that must be removed when upgrading Eloquent/Carbon." + ] + }, "config": { "optimize-autoloader": true, - "sort-packages": true + "sort-packages": true, + "platform": { + "php": "7.4.0" + }, + "allow-plugins": { + "kylekatarnls/update-helper": true + } } } diff --git a/api/composer.lock b/api/composer.lock index 771d269..b0f5b4d 100644 --- a/api/composer.lock +++ b/api/composer.lock @@ -4,44 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b7b16dfdd013b55252da786830a1392c", + "content-hash": "de61b22472bfcd20afe82706911be541", "packages": [ - { - "name": "container-interop/container-interop", - "version": "1.2.0", - "source": { - "type": "git", - "url": "https://github.com/container-interop/container-interop.git", - "reference": "79cbf1341c22ec75643d841642dd5d6acd83bdb8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/container-interop/container-interop/zipball/79cbf1341c22ec75643d841642dd5d6acd83bdb8", - "reference": "79cbf1341c22ec75643d841642dd5d6acd83bdb8", - "shasum": "" - }, - "require": { - "psr/container": "^1.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Interop\\Container\\": "src/Interop/Container/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Promoting the interoperability of container objects (DIC, SL, etc.)", - "homepage": "https://github.com/container-interop/container-interop", - "support": { - "issues": "https://github.com/container-interop/container-interop/issues", - "source": "https://github.com/container-interop/container-interop/tree/master" - }, - "abandoned": "psr/container", - "time": "2017-02-14T19:40:03+00:00" - }, { "name": "danielstjules/stringy", "version": "1.10.0", @@ -65,12 +29,12 @@ }, "type": "library", "autoload": { - "psr-4": { - "Stringy\\": "src/" - }, "files": [ "src/Create.php" - ] + ], + "psr-4": { + "Stringy\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -104,26 +68,26 @@ }, { "name": "doctrine/inflector", - "version": "1.4.3", + "version": "1.4.4", "source": { "type": "git", "url": "https://github.com/doctrine/inflector.git", - "reference": "4650c8b30c753a76bf44fb2ed00117d6f367490c" + "reference": "4bd5c1cdfcd00e9e2d8c484f79150f67e5d355d9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/inflector/zipball/4650c8b30c753a76bf44fb2ed00117d6f367490c", - "reference": "4650c8b30c753a76bf44fb2ed00117d6f367490c", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/4bd5c1cdfcd00e9e2d8c484f79150f67e5d355d9", + "reference": "4bd5c1cdfcd00e9e2d8c484f79150f67e5d355d9", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0" + "php": "^7.1 || ^8.0" }, "require-dev": { - "doctrine/coding-standard": "^7.0", - "phpstan/phpstan": "^0.11", - "phpstan/phpstan-phpunit": "^0.11", - "phpstan/phpstan-strict-rules": "^0.11", + "doctrine/coding-standard": "^8.0", + "phpstan/phpstan": "^0.12", + "phpstan/phpstan-phpunit": "^0.12", + "phpstan/phpstan-strict-rules": "^0.12", "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0" }, "type": "library", @@ -134,8 +98,8 @@ }, "autoload": { "psr-4": { - "Doctrine\\Common\\Inflector\\": "lib/Doctrine/Common/Inflector", - "Doctrine\\Inflector\\": "lib/Doctrine/Inflector" + "Doctrine\\Inflector\\": "lib/Doctrine/Inflector", + "Doctrine\\Common\\Inflector\\": "lib/Doctrine/Common/Inflector" } }, "notification-url": "https://packagist.org/downloads/", @@ -180,7 +144,7 @@ ], "support": { "issues": "https://github.com/doctrine/inflector/issues", - "source": "https://github.com/doctrine/inflector/tree/1.4.x" + "source": "https://github.com/doctrine/inflector/tree/1.4.4" }, "funding": [ { @@ -196,7 +160,7 @@ "type": "tidelift" } ], - "time": "2020-05-29T07:19:59+00:00" + "time": "2021-04-16T17:34:40+00:00" }, { "name": "google/recaptcha", @@ -252,24 +216,24 @@ }, { "name": "guzzlehttp/guzzle", - "version": "6.5.5", + "version": "6.5.8", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "9d4290de1cfd701f38099ef7e183b64b4b7b0c5e" + "reference": "a52f0440530b54fa079ce76e8c5d196a42cad981" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/9d4290de1cfd701f38099ef7e183b64b4b7b0c5e", - "reference": "9d4290de1cfd701f38099ef7e183b64b4b7b0c5e", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/a52f0440530b54fa079ce76e8c5d196a42cad981", + "reference": "a52f0440530b54fa079ce76e8c5d196a42cad981", "shasum": "" }, "require": { "ext-json": "*", "guzzlehttp/promises": "^1.0", - "guzzlehttp/psr7": "^1.6.1", + "guzzlehttp/psr7": "^1.9", "php": ">=5.5", - "symfony/polyfill-intl-idn": "^1.17.0" + "symfony/polyfill-intl-idn": "^1.17" }, "require-dev": { "ext-curl": "*", @@ -286,22 +250,52 @@ } }, "autoload": { - "psr-4": { - "GuzzleHttp\\": "src/" - }, "files": [ "src/functions_include.php" - ] + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, { "name": "Michael Dowling", "email": "mtdowling@gmail.com", "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" } ], "description": "Guzzle is a PHP HTTP client library", @@ -317,22 +311,36 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/6.5" + "source": "https://github.com/guzzle/guzzle/tree/6.5.8" }, - "time": "2020-06-16T21:01:06+00:00" + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2022-06-20T22:16:07+00:00" }, { "name": "guzzlehttp/promises", - "version": "1.4.1", + "version": "1.5.3", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "8e7d04f1f6450fef59366c399cfad4b9383aa30d" + "reference": "67ab6e18aaa14d753cc148911d273f6e6cb6721e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/8e7d04f1f6450fef59366c399cfad4b9383aa30d", - "reference": "8e7d04f1f6450fef59366c399cfad4b9383aa30d", + "url": "https://api.github.com/repos/guzzle/promises/zipball/67ab6e18aaa14d753cc148911d273f6e6cb6721e", + "reference": "67ab6e18aaa14d753cc148911d273f6e6cb6721e", "shasum": "" }, "require": { @@ -342,28 +350,38 @@ "symfony/phpunit-bridge": "^4.4 || ^5.1" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.4-dev" - } - }, "autoload": { - "psr-4": { - "GuzzleHttp\\Promise\\": "src/" - }, "files": [ "src/functions_include.php" - ] + ], + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, { "name": "Michael Dowling", "email": "mtdowling@gmail.com", "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" } ], "description": "Guzzle promises library", @@ -372,22 +390,36 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/1.4.1" + "source": "https://github.com/guzzle/promises/tree/1.5.3" }, - "time": "2021-03-07T09:25:29+00:00" + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2023-05-21T12:31:43+00:00" }, { "name": "guzzlehttp/psr7", - "version": "1.8.1", + "version": "1.9.1", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "35ea11d335fd638b5882ff1725228b3d35496ab1" + "reference": "e4490cabc77465aaee90b20cfc9a770f8c04be6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/35ea11d335fd638b5882ff1725228b3d35496ab1", - "reference": "35ea11d335fd638b5882ff1725228b3d35496ab1", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/e4490cabc77465aaee90b20cfc9a770f8c04be6b", + "reference": "e4490cabc77465aaee90b20cfc9a770f8c04be6b", "shasum": "" }, "require": { @@ -406,31 +438,47 @@ "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.7-dev" - } - }, "autoload": { - "psr-4": { - "GuzzleHttp\\Psr7\\": "src/" - }, "files": [ "src/functions_include.php" - ] + ], + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, { "name": "Michael Dowling", "email": "mtdowling@gmail.com", "homepage": "https://github.com/mtdowling" }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, { "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", "homepage": "https://github.com/Tobion" } ], @@ -447,9 +495,23 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/1.8.1" + "source": "https://github.com/guzzle/psr7/tree/1.9.1" }, - "time": "2021-03-21T16:25:00+00:00" + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2023-04-17T16:00:37+00:00" }, { "name": "illuminate/container", @@ -690,12 +752,12 @@ } }, "autoload": { - "psr-4": { - "Illuminate\\Support\\": "" - }, "files": [ "helpers.php" - ] + ], + "psr-4": { + "Illuminate\\Support\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -889,34 +951,33 @@ }, { "name": "laminas/laminas-escaper", - "version": "2.7.0", + "version": "2.12.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-escaper.git", - "reference": "5e04bc5ae5990b17159d79d331055e2c645e5cc5" + "reference": "ee7a4c37bf3d0e8c03635d5bddb5bb3184ead490" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-escaper/zipball/5e04bc5ae5990b17159d79d331055e2c645e5cc5", - "reference": "5e04bc5ae5990b17159d79d331055e2c645e5cc5", + "url": "https://api.github.com/repos/laminas/laminas-escaper/zipball/ee7a4c37bf3d0e8c03635d5bddb5bb3184ead490", + "reference": "ee7a4c37bf3d0e8c03635d5bddb5bb3184ead490", "shasum": "" }, "require": { - "laminas/laminas-zendframework-bridge": "^1.0", - "php": "^7.3 || ~8.0.0" + "ext-ctype": "*", + "ext-mbstring": "*", + "php": "^7.4 || ~8.0.0 || ~8.1.0 || ~8.2.0" }, - "replace": { - "zendframework/zend-escaper": "^2.6.1" + "conflict": { + "zendframework/zend-escaper": "*" }, "require-dev": { - "laminas/laminas-coding-standard": "~1.0.0", - "phpunit/phpunit": "^9.3", - "psalm/plugin-phpunit": "^0.12.2", - "vimeo/psalm": "^3.16" - }, - "suggest": { - "ext-iconv": "*", - "ext-mbstring": "*" + "infection/infection": "^0.26.6", + "laminas/laminas-coding-standard": "~2.4.0", + "maglnet/composer-require-checker": "^3.8.0", + "phpunit/phpunit": "^9.5.18", + "psalm/plugin-phpunit": "^0.17.0", + "vimeo/psalm": "^4.22.0" }, "type": "library", "autoload": { @@ -948,33 +1009,126 @@ "type": "community_bridge" } ], - "time": "2020-11-17T21:26:43+00:00" + "time": "2022-10-10T10:11:09+00:00" + }, + { + "name": "laminas/laminas-servicemanager", + "version": "3.17.0", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-servicemanager.git", + "reference": "360be5f16955dd1edbcce1cfaa98ed82a17f02ec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-servicemanager/zipball/360be5f16955dd1edbcce1cfaa98ed82a17f02ec", + "reference": "360be5f16955dd1edbcce1cfaa98ed82a17f02ec", + "shasum": "" + }, + "require": { + "laminas/laminas-stdlib": "^3.2.1", + "php": "~7.4.0 || ~8.0.0 || ~8.1.0", + "psr/container": "^1.0" + }, + "conflict": { + "ext-psr": "*", + "laminas/laminas-code": "<3.3.1", + "zendframework/zend-code": "<3.3.1", + "zendframework/zend-servicemanager": "*" + }, + "provide": { + "psr/container-implementation": "^1.0" + }, + "replace": { + "container-interop/container-interop": "^1.2.0" + }, + "require-dev": { + "composer/package-versions-deprecated": "^1.0", + "laminas/laminas-coding-standard": "~2.4.0", + "laminas/laminas-container-config-test": "^0.7", + "laminas/laminas-dependency-plugin": "^2.1.2", + "mikey179/vfsstream": "^1.6.10@alpha", + "ocramius/proxy-manager": "^2.11", + "phpbench/phpbench": "^1.1", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5.5", + "psalm/plugin-phpunit": "^0.17.0", + "vimeo/psalm": "^4.8" + }, + "suggest": { + "ocramius/proxy-manager": "ProxyManager ^2.1.1 to handle lazy initialization of services" + }, + "bin": [ + "bin/generate-deps-for-config-factory", + "bin/generate-factory-for-class" + ], + "type": "library", + "autoload": { + "files": [ + "src/autoload.php" + ], + "psr-4": { + "Laminas\\ServiceManager\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Factory-Driven Dependency Injection Container", + "homepage": "https://laminas.dev", + "keywords": [ + "PSR-11", + "dependency-injection", + "di", + "dic", + "laminas", + "service-manager", + "servicemanager" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-servicemanager/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-servicemanager/issues", + "rss": "https://github.com/laminas/laminas-servicemanager/releases.atom", + "source": "https://github.com/laminas/laminas-servicemanager" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2022-09-22T11:33:46+00:00" }, { "name": "laminas/laminas-stdlib", - "version": "3.3.1", + "version": "3.13.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-stdlib.git", - "reference": "d81c7ffe602ed0e6ecb18691019111c0f4bf1efe" + "reference": "66a6d03c381f6c9f1dd988bf8244f9afb9380d76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-stdlib/zipball/d81c7ffe602ed0e6ecb18691019111c0f4bf1efe", - "reference": "d81c7ffe602ed0e6ecb18691019111c0f4bf1efe", + "url": "https://api.github.com/repos/laminas/laminas-stdlib/zipball/66a6d03c381f6c9f1dd988bf8244f9afb9380d76", + "reference": "66a6d03c381f6c9f1dd988bf8244f9afb9380d76", "shasum": "" }, "require": { - "laminas/laminas-zendframework-bridge": "^1.0", - "php": "^7.3 || ^8.0" + "php": "^7.4 || ~8.0.0 || ~8.1.0" }, - "replace": { - "zendframework/zend-stdlib": "^3.2.1" + "conflict": { + "zendframework/zend-stdlib": "*" }, "require-dev": { - "laminas/laminas-coding-standard": "~1.0.0", - "phpbench/phpbench": "^0.17.1", - "phpunit/phpunit": "~9.3.7" + "laminas/laminas-coding-standard": "~2.3.0", + "phpbench/phpbench": "^1.2.6", + "phpstan/phpdoc-parser": "^0.5.4", + "phpunit/phpunit": "^9.5.23", + "psalm/plugin-phpunit": "^0.17.0", + "vimeo/psalm": "^4.26" }, "type": "library", "autoload": { @@ -1006,34 +1160,33 @@ "type": "community_bridge" } ], - "time": "2020-11-19T20:18:59+00:00" + "time": "2022-08-24T13:56:50+00:00" }, { "name": "laminas/laminas-uri", - "version": "2.8.1", + "version": "2.9.1", "source": { "type": "git", "url": "https://github.com/laminas/laminas-uri.git", - "reference": "79bd4c614c8cf9a6ba715a49fca8061e84933d87" + "reference": "7e837dc15c8fd3949df7d1213246fd7c8640032b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-uri/zipball/79bd4c614c8cf9a6ba715a49fca8061e84933d87", - "reference": "79bd4c614c8cf9a6ba715a49fca8061e84933d87", + "url": "https://api.github.com/repos/laminas/laminas-uri/zipball/7e837dc15c8fd3949df7d1213246fd7c8640032b", + "reference": "7e837dc15c8fd3949df7d1213246fd7c8640032b", "shasum": "" }, "require": { - "laminas/laminas-escaper": "^2.5", - "laminas/laminas-validator": "^2.10", - "laminas/laminas-zendframework-bridge": "^1.0", - "php": "^7.3 || ~8.0.0" + "laminas/laminas-escaper": "^2.9", + "laminas/laminas-validator": "^2.15", + "php": "^7.3 || ~8.0.0 || ~8.1.0" }, - "replace": { - "zendframework/zend-uri": "^2.7.1" + "conflict": { + "zendframework/zend-uri": "*" }, "require-dev": { - "laminas/laminas-coding-standard": "^2.1", - "phpunit/phpunit": "^9.3" + "laminas/laminas-coding-standard": "~2.2.1", + "phpunit/phpunit": "^9.5.5" }, "type": "library", "autoload": { @@ -1065,57 +1218,51 @@ "type": "community_bridge" } ], - "time": "2021-02-17T21:53:05+00:00" + "time": "2021-09-09T18:37:15+00:00" }, { "name": "laminas/laminas-validator", - "version": "2.14.4", + "version": "2.25.0", "source": { "type": "git", "url": "https://github.com/laminas/laminas-validator.git", - "reference": "e370c4695db1c81e6dfad38d8c4dbdb37b23d776" + "reference": "42de39b78e73b321db7d948cf8530a2764f8b9aa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-validator/zipball/e370c4695db1c81e6dfad38d8c4dbdb37b23d776", - "reference": "e370c4695db1c81e6dfad38d8c4dbdb37b23d776", + "url": "https://api.github.com/repos/laminas/laminas-validator/zipball/42de39b78e73b321db7d948cf8530a2764f8b9aa", + "reference": "42de39b78e73b321db7d948cf8530a2764f8b9aa", "shasum": "" }, "require": { - "container-interop/container-interop": "^1.1", - "laminas/laminas-stdlib": "^3.3", - "laminas/laminas-zendframework-bridge": "^1.0", - "php": "^7.3 || ~8.0.0" + "laminas/laminas-servicemanager": "^3.12.0", + "laminas/laminas-stdlib": "^3.13", + "php": "^7.4 || ~8.0.0 || ~8.1.0" }, - "replace": { - "zendframework/zend-validator": "^2.13.0" + "conflict": { + "zendframework/zend-validator": "*" }, "require-dev": { - "laminas/laminas-cache": "^2.6.1", - "laminas/laminas-coding-standard": "~1.0.0", - "laminas/laminas-config": "^2.6", - "laminas/laminas-db": "^2.7", - "laminas/laminas-filter": "^2.6", - "laminas/laminas-http": "^2.14.2", - "laminas/laminas-i18n": "^2.6", - "laminas/laminas-math": "^2.6", - "laminas/laminas-servicemanager": "^2.7.11 || ^3.0.3", - "laminas/laminas-session": "^2.8", - "laminas/laminas-uri": "^2.7", + "laminas/laminas-coding-standard": "^2.4.0", + "laminas/laminas-db": "^2.15.0", + "laminas/laminas-filter": "^2.18.0", + "laminas/laminas-http": "^2.16.0", + "laminas/laminas-i18n": "^2.17.0", + "laminas/laminas-session": "^2.13.0", + "laminas/laminas-uri": "^2.9.1", "phpspec/prophecy-phpunit": "^2.0", - "phpunit/phpunit": "^9.3", - "psalm/plugin-phpunit": "^0.15.0", + "phpunit/phpunit": "^9.5.24", + "psalm/plugin-phpunit": "^0.17.0", "psr/http-client": "^1.0", "psr/http-factory": "^1.0", "psr/http-message": "^1.0", - "vimeo/psalm": "^4.3" + "vimeo/psalm": "^4.27.0" }, "suggest": { "laminas/laminas-db": "Laminas\\Db component, required by the (No)RecordExists validator", "laminas/laminas-filter": "Laminas\\Filter component, required by the Digits validator", "laminas/laminas-i18n": "Laminas\\I18n component to allow translation of validation error messages", "laminas/laminas-i18n-resources": "Translations of validator messages", - "laminas/laminas-math": "Laminas\\Math component, required by the Csrf validator", "laminas/laminas-servicemanager": "Laminas\\ServiceManager component to allow using the ValidatorPluginManager and validator chains", "laminas/laminas-session": "Laminas\\Session component, ^2.8; required by the Csrf validator", "laminas/laminas-uri": "Laminas\\Uri component, required by the Uri and Sitemap\\Loc validators", @@ -1157,86 +1304,24 @@ "type": "community_bridge" } ], - "time": "2021-01-24T20:45:49+00:00" - }, - { - "name": "laminas/laminas-zendframework-bridge", - "version": "1.2.0", - "source": { - "type": "git", - "url": "https://github.com/laminas/laminas-zendframework-bridge.git", - "reference": "6cccbddfcfc742eb02158d6137ca5687d92cee32" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-zendframework-bridge/zipball/6cccbddfcfc742eb02158d6137ca5687d92cee32", - "reference": "6cccbddfcfc742eb02158d6137ca5687d92cee32", - "shasum": "" - }, - "require": { - "php": "^7.3 || ^8.0" - }, - "require-dev": { - "phpunit/phpunit": "^5.7 || ^6.5 || ^7.5 || ^8.1 || ^9.3", - "psalm/plugin-phpunit": "^0.15.1", - "squizlabs/php_codesniffer": "^3.5", - "vimeo/psalm": "^4.6" - }, - "type": "library", - "extra": { - "laminas": { - "module": "Laminas\\ZendFrameworkBridge" - } - }, - "autoload": { - "files": [ - "src/autoload.php" - ], - "psr-4": { - "Laminas\\ZendFrameworkBridge\\": "src//" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "description": "Alias legacy ZF class names to Laminas Project equivalents.", - "keywords": [ - "ZendFramework", - "autoloading", - "laminas", - "zf" - ], - "support": { - "forum": "https://discourse.laminas.dev/", - "issues": "https://github.com/laminas/laminas-zendframework-bridge/issues", - "rss": "https://github.com/laminas/laminas-zendframework-bridge/releases.atom", - "source": "https://github.com/laminas/laminas-zendframework-bridge" - }, - "funding": [ - { - "url": "https://funding.communitybridge.org/projects/laminas-project", - "type": "community_bridge" - } - ], - "time": "2021-02-25T21:54:58+00:00" + "time": "2022-09-20T11:33:19+00:00" }, { "name": "league/event", - "version": "2.2.0", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/thephpleague/event.git", - "reference": "d2cc124cf9a3fab2bb4ff963307f60361ce4d119" + "reference": "062ebb450efbe9a09bc2478e89b7c933875b0935" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/event/zipball/d2cc124cf9a3fab2bb4ff963307f60361ce4d119", - "reference": "d2cc124cf9a3fab2bb4ff963307f60361ce4d119", + "url": "https://api.github.com/repos/thephpleague/event/zipball/062ebb450efbe9a09bc2478e89b7c933875b0935", + "reference": "062ebb450efbe9a09bc2478e89b7c933875b0935", "shasum": "" }, "require": { - "php": ">=5.4.0" + "php": ">=7.1.0" }, "require-dev": { "henrikbjorn/phpspec-code-coverage": "~1.0.1", @@ -1271,9 +1356,9 @@ ], "support": { "issues": "https://github.com/thephpleague/event/issues", - "source": "https://github.com/thephpleague/event/tree/master" + "source": "https://github.com/thephpleague/event/tree/2.3.0" }, - "time": "2018-11-26T11:52:41+00:00" + "time": "2025-03-14T19:51:10+00:00" }, { "name": "league/oauth2-client", @@ -1505,12 +1590,12 @@ } }, "autoload": { - "psr-4": { - "League\\Uri\\": "src" - }, "files": [ "src/functions_include.php" - ] + ], + "psr-4": { + "League\\Uri\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1537,30 +1622,29 @@ "issues": "https://github.com/thephpleague/uri-parser/issues", "source": "https://github.com/thephpleague/uri-parser/tree/master" }, + "abandoned": "league/uri-interfaces", "time": "2018-11-22T07:55:51+00:00" }, { "name": "masterminds/html5", - "version": "2.7.4", + "version": "2.10.0", "source": { "type": "git", "url": "https://github.com/Masterminds/html5-php.git", - "reference": "9227822783c75406cfe400984b2f095cdf03d417" + "reference": "fcf91eb64359852f00d921887b219479b4f21251" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/9227822783c75406cfe400984b2f095cdf03d417", - "reference": "9227822783c75406cfe400984b2f095cdf03d417", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", + "reference": "fcf91eb64359852f00d921887b219479b4f21251", "shasum": "" }, "require": { - "ext-ctype": "*", "ext-dom": "*", - "ext-libxml": "*", "php": ">=5.3.0" }, "require-dev": { - "phpunit/phpunit": "^4.8.35" + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" }, "type": "library", "extra": { @@ -1604,21 +1688,21 @@ ], "support": { "issues": "https://github.com/Masterminds/html5-php/issues", - "source": "https://github.com/Masterminds/html5-php/tree/2.7.4" + "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" }, - "time": "2020-10-01T13:52:52+00:00" + "time": "2025-07-25T09:04:22+00:00" }, { "name": "nesbot/carbon", "version": "1.39.1", "source": { "type": "git", - "url": "https://github.com/briannesbitt/Carbon.git", + "url": "https://github.com/CarbonPHP/carbon.git", "reference": "4be0c005164249208ce1b5ca633cd57bdd42ff33" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/4be0c005164249208ce1b5ca633cd57bdd42ff33", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/4be0c005164249208ce1b5ca633cd57bdd42ff33", "reference": "4be0c005164249208ce1b5ca633cd57bdd42ff33", "shasum": "" }, @@ -1637,12 +1721,12 @@ ], "type": "library", "extra": { - "update-helper": "Carbon\\Upgrade", "laravel": { "providers": [ "Carbon\\Laravel\\ServiceProvider" ] - } + }, + "update-helper": "Carbon\\Upgrade" }, "autoload": { "psr-4": { @@ -1671,6 +1755,20 @@ "issues": "https://github.com/briannesbitt/Carbon/issues", "source": "https://github.com/briannesbitt/Carbon" }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], "time": "2019-10-14T05:51:36+00:00" }, { @@ -1728,20 +1826,20 @@ }, { "name": "psr/container", - "version": "1.1.1", + "version": "1.1.2", "source": { "type": "git", "url": "https://github.com/php-fig/container.git", - "reference": "8622567409010282b7aeebe4bb841fe98b58dcaf" + "reference": "513e0666f7216c7459170d56df27dfcefe1689ea" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/8622567409010282b7aeebe4bb841fe98b58dcaf", - "reference": "8622567409010282b7aeebe4bb841fe98b58dcaf", + "url": "https://api.github.com/repos/php-fig/container/zipball/513e0666f7216c7459170d56df27dfcefe1689ea", + "reference": "513e0666f7216c7459170d56df27dfcefe1689ea", "shasum": "" }, "require": { - "php": ">=7.2.0" + "php": ">=7.4.0" }, "type": "library", "autoload": { @@ -1770,31 +1868,31 @@ ], "support": { "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/1.1.1" + "source": "https://github.com/php-fig/container/tree/1.1.2" }, - "time": "2021-03-05T17:36:06+00:00" + "time": "2021-11-05T16:50:12+00:00" }, { "name": "psr/http-message", - "version": "1.0.1", + "version": "1.1", "source": { "type": "git", "url": "https://github.com/php-fig/http-message.git", - "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363" + "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-message/zipball/f6561bf28d520154e4b0ec72be95418abe6d9363", - "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/cb6ce4845ce34a8ad9e68117c10ee90a29919eba", + "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba", "shasum": "" }, "require": { - "php": ">=5.3.0" + "php": "^7.2 || ^8.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "1.1.x-dev" } }, "autoload": { @@ -1823,22 +1921,22 @@ "response" ], "support": { - "source": "https://github.com/php-fig/http-message/tree/master" + "source": "https://github.com/php-fig/http-message/tree/1.1" }, - "time": "2016-08-06T14:39:51+00:00" + "time": "2023-04-04T09:50:52+00:00" }, { "name": "psr/log", - "version": "1.1.3", + "version": "1.1.4", "source": { "type": "git", "url": "https://github.com/php-fig/log.git", - "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc" + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/0f73288fd15629204f9d42b7055f72dacbe811fc", - "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", "shasum": "" }, "require": { @@ -1862,7 +1960,7 @@ "authors": [ { "name": "PHP-FIG", - "homepage": "http://www.php-fig.org/" + "homepage": "https://www.php-fig.org/" } ], "description": "Common interface for logging libraries", @@ -1873,9 +1971,9 @@ "psr-3" ], "support": { - "source": "https://github.com/php-fig/log/tree/1.1.3" + "source": "https://github.com/php-fig/log/tree/1.1.4" }, - "time": "2020-03-23T09:12:05+00:00" + "time": "2021-05-03T11:20:27+00:00" }, { "name": "ralouphie/getallheaders", @@ -2081,6 +2179,7 @@ "issues": "https://github.com/swiftmailer/swiftmailer/issues", "source": "https://github.com/swiftmailer/swiftmailer/tree/v5.4.12" }, + "abandoned": "symfony/mailer", "time": "2018-07-31T09:26:32+00:00" }, { @@ -2151,41 +2250,41 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.22.1", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "c6c942b1ac76c82448322025e084cadc56048b4e" + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/c6c942b1ac76c82448322025e084cadc56048b4e", - "reference": "c6c942b1ac76c82448322025e084cadc56048b4e", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" }, "suggest": { "ext-ctype": "For best performance" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.22-dev" - }, "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Ctype\\": "" - }, "files": [ "bootstrap.php" - ] + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2210,7 +2309,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.22.1" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" }, "funding": [ { @@ -2221,52 +2320,52 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2021-01-07T16:49:33+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.22.1", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "2d63434d922daf7da8dd863e7907e67ee3031483" + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/2d63434d922daf7da8dd863e7907e67ee3031483", - "reference": "2d63434d922daf7da8dd863e7907e67ee3031483", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", "shasum": "" }, "require": { - "php": ">=7.1", - "symfony/polyfill-intl-normalizer": "^1.10", - "symfony/polyfill-php72": "^1.10" + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" }, "suggest": { "ext-intl": "For best performance" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.22-dev" - }, "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Intl\\Idn\\": "" - }, "files": [ "bootstrap.php" - ] + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2297,7 +2396,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.22.1" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0" }, "funding": [ { @@ -2308,50 +2407,51 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2021-01-22T09:19:47+00:00" + "time": "2024-09-10T14:38:51+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.22.1", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "43a0283138253ed1d48d352ab6d0bdb3f809f248" + "reference": "3833d7255cc303546435cb650316bff708a1c75c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/43a0283138253ed1d48d352ab6d0bdb3f809f248", - "reference": "43a0283138253ed1d48d352ab6d0bdb3f809f248", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "suggest": { "ext-intl": "For best performance" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.22-dev" - }, "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Intl\\Normalizer\\": "" - }, "files": [ "bootstrap.php" ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, "classmap": [ "Resources/stubs" ] @@ -2381,7 +2481,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.22.1" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" }, "funding": [ { @@ -2392,50 +2492,55 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2021-01-22T09:19:47+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.22.1", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "5232de97ee3b75b0360528dae24e73db49566ab1" + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/5232de97ee3b75b0360528dae24e73db49566ab1", - "reference": "5232de97ee3b75b0360528dae24e73db49566ab1", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", "shasum": "" }, "require": { - "php": ">=7.1" + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" }, "suggest": { "ext-mbstring": "For best performance" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.22-dev" - }, "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Mbstring\\": "" - }, "files": [ "bootstrap.php" - ] + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2461,7 +2566,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.22.1" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" }, "funding": [ { @@ -2472,12 +2577,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2021-01-22T09:19:47+00:00" + "time": "2024-12-23T08:48:59+00:00" }, { "name": "symfony/polyfill-php70", @@ -2498,12 +2607,12 @@ }, "type": "metapackage", "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + }, "branch-alias": { "dev-main": "1.20-dev" - }, - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" } }, "notification-url": "https://packagist.org/downloads/", @@ -2548,38 +2657,38 @@ "time": "2020-10-23T14:02:19+00:00" }, { - "name": "symfony/polyfill-php72", - "version": "v1.22.1", + "name": "symfony/polyfill-php80", + "version": "v1.33.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php72.git", - "reference": "cc6e6f9b39fe8075b3dabfbaf5b5f645ae1340c9" + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/cc6e6f9b39fe8075b3dabfbaf5b5f645ae1340c9", - "reference": "cc6e6f9b39fe8075b3dabfbaf5b5f645ae1340c9", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.22-dev" - }, "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Php72\\": "" - }, "files": [ "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" ] }, "notification-url": "https://packagist.org/downloads/", @@ -2587,6 +2696,10 @@ "MIT" ], "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, { "name": "Nicolas Grekas", "email": "p@tchwork.com" @@ -2596,7 +2709,7 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 7.2+ features to lower PHP versions", + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", @@ -2605,7 +2718,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php72/tree/v1.22.1" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" }, "funding": [ { @@ -2616,30 +2729,35 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2021-01-07T16:49:33+00:00" + "time": "2025-01-02T08:10:11+00:00" }, { "name": "symfony/translation", - "version": "v4.4.21", + "version": "v4.4.47", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "eb8f5428cc3b40d6dffe303b195b084f1c5fbd14" + "reference": "45036b1d53accc48fe9bab71ccd86d57eba0dd94" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/eb8f5428cc3b40d6dffe303b195b084f1c5fbd14", - "reference": "eb8f5428cc3b40d6dffe303b195b084f1c5fbd14", + "url": "https://api.github.com/repos/symfony/translation/zipball/45036b1d53accc48fe9bab71ccd86d57eba0dd94", + "reference": "45036b1d53accc48fe9bab71ccd86d57eba0dd94", "shasum": "" }, "require": { "php": ">=7.1.3", "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php80": "^1.16", "symfony/translation-contracts": "^1.1.6|^2" }, "conflict": { @@ -2652,7 +2770,7 @@ "symfony/translation-implementation": "1.0|2.0" }, "require-dev": { - "psr/log": "~1.0", + "psr/log": "^1|^2|^3", "symfony/config": "^3.4|^4.0|^5.0", "symfony/console": "^3.4|^4.0|^5.0", "symfony/dependency-injection": "^3.4|^4.0|^5.0", @@ -2693,7 +2811,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v4.4.21" + "source": "https://github.com/symfony/translation/tree/v4.4.47" }, "funding": [ { @@ -2709,20 +2827,20 @@ "type": "tidelift" } ], - "time": "2021-03-23T16:25:01+00:00" + "time": "2022-10-03T15:15:11+00:00" }, { "name": "symfony/translation-contracts", - "version": "v2.3.0", + "version": "v2.5.4", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "e2eaa60b558f26a4b0354e1bbb25636efaaad105" + "reference": "450d4172653f38818657022252f9d81be89ee9a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/e2eaa60b558f26a4b0354e1bbb25636efaaad105", - "reference": "e2eaa60b558f26a4b0354e1bbb25636efaaad105", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/450d4172653f38818657022252f9d81be89ee9a8", + "reference": "450d4172653f38818657022252f9d81be89ee9a8", "shasum": "" }, "require": { @@ -2733,12 +2851,12 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "2.3-dev" - }, "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "2.5-dev" } }, "autoload": { @@ -2771,7 +2889,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v2.3.0" + "source": "https://github.com/symfony/translation-contracts/tree/v2.5.4" }, "funding": [ { @@ -2787,20 +2905,20 @@ "type": "tidelift" } ], - "time": "2020-09-28T13:05:58+00:00" + "time": "2024-09-25T14:11:13+00:00" }, { "name": "tgalopin/html-sanitizer", - "version": "1.4.0", + "version": "1.5.0", "source": { "type": "git", "url": "https://github.com/tgalopin/html-sanitizer.git", - "reference": "56cca6b48de4e50d16a4f549e3e677ae0d561e91" + "reference": "5d02dcb6f2ea4f505731eac440798caa1b3b0913" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tgalopin/html-sanitizer/zipball/56cca6b48de4e50d16a4f549e3e677ae0d561e91", - "reference": "56cca6b48de4e50d16a4f549e3e677ae0d561e91", + "url": "https://api.github.com/repos/tgalopin/html-sanitizer/zipball/5d02dcb6f2ea4f505731eac440798caa1b3b0913", + "reference": "5d02dcb6f2ea4f505731eac440798caa1b3b0913", "shasum": "" }, "require": { @@ -2808,7 +2926,7 @@ "league/uri-parser": "^1.4.1", "masterminds/html5": "^2.4", "php": ">=7.1", - "psr/log": "^1.0" + "psr/log": "^1.0|^2.0|^3.0" }, "require-dev": { "phpunit/phpunit": "^7.4", @@ -2833,22 +2951,23 @@ "description": "Sanitize untrustworthy HTML user input", "support": { "issues": "https://github.com/tgalopin/html-sanitizer/issues", - "source": "https://github.com/tgalopin/html-sanitizer/tree/master" + "source": "https://github.com/tgalopin/html-sanitizer/tree/1.5.0" }, - "time": "2020-02-03T16:51:08+00:00" + "abandoned": "symfony/html-sanitizer", + "time": "2021-09-14T08:27:50+00:00" }, { "name": "twig/twig", - "version": "v1.44.2", + "version": "v1.44.8", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "138c493c5b8ee7cff3821f80b8896d371366b5fe" + "reference": "b1f009c449e435a0384814e67205d9190a4d050e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/138c493c5b8ee7cff3821f80b8896d371366b5fe", - "reference": "138c493c5b8ee7cff3821f80b8896d371366b5fe", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/b1f009c449e435a0384814e67205d9190a4d050e", + "reference": "b1f009c449e435a0384814e67205d9190a4d050e", "shasum": "" }, "require": { @@ -2901,7 +3020,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v1.44.2" + "source": "https://github.com/twigphp/Twig/tree/v1.44.8" }, "funding": [ { @@ -2913,26 +3032,2027 @@ "type": "tidelift" } ], - "time": "2021-01-05T10:10:05+00:00" + "time": "2024-09-09T17:17:16+00:00" } ], - "packages-dev": [], - "aliases": [], - "minimum-stability": "stable", - "stability-flags": [], - "prefer-stable": false, - "prefer-lowest": false, - "platform": { - "php": "^7.4", - "ext-pdo": "*", - "ext-curl": "*", - "ext-dom": "*", - "ext-json": "*", - "ext-libxml": "*", - "ext-mbstring": "*", - "ext-pdo_mysql": "*", - "ext-simplexml": "*" + "packages-dev": [ + { + "name": "doctrine/instantiator", + "version": "1.5.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/instantiator.git", + "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/0a0fa9780f5d4e507415a065172d26a98d02047b", + "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^11", + "ext-pdo": "*", + "ext-phar": "*", + "phpbench/phpbench": "^0.16 || ^1", + "phpstan/phpstan": "^1.4", + "phpstan/phpstan-phpunit": "^1", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "vimeo/psalm": "^4.30 || ^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "homepage": "https://ocramius.github.io/" + } + ], + "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", + "homepage": "https://www.doctrine-project.org/projects/instantiator.html", + "keywords": [ + "constructor", + "instantiate" + ], + "support": { + "issues": "https://github.com/doctrine/instantiator/issues", + "source": "https://github.com/doctrine/instantiator/tree/1.5.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", + "type": "tidelift" + } + ], + "time": "2022-12-30T00:15:36+00:00" + }, + { + "name": "hamcrest/hamcrest-php", + "version": "v2.1.1", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "classmap": [ + "hamcrest" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "support": { + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" + }, + "time": "2025-04-30T06:54:44+00:00" + }, + { + "name": "mockery/mockery", + "version": "1.6.12", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "^2.0.1", + "lib-pcre": ">=7.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.6.17", + "symplify/easy-coding-standard": "^12.1.14" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2024-05-16T03:13:13+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "9.2.32", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/85402a822d1ecf1db1096959413d35e1c37cf1a5", + "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^4.19.1 || ^5.1.0", + "php": ">=7.3", + "phpunit/php-file-iterator": "^3.0.6", + "phpunit/php-text-template": "^2.0.4", + "sebastian/code-unit-reverse-lookup": "^2.0.3", + "sebastian/complexity": "^2.0.3", + "sebastian/environment": "^5.1.5", + "sebastian/lines-of-code": "^1.0.4", + "sebastian/version": "^3.0.2", + "theseer/tokenizer": "^1.2.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.6" + }, + "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": "9.2.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.32" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-08-22T04:23:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "3.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.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", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2021-12-02T12:48:52+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "3.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.1-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", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:58:55+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.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", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T05:33:50+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "5.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "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": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:16:10+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "9.6.34", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "b36f02317466907a230d3aa1d34467041271ef4a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b36f02317466907a230d3aa1d34467041271ef4a", + "reference": "b36f02317466907a230d3aa1d34467041271ef4a", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.5.0 || ^2", + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=7.3", + "phpunit/php-code-coverage": "^9.2.32", + "phpunit/php-file-iterator": "^3.0.6", + "phpunit/php-invoker": "^3.1.1", + "phpunit/php-text-template": "^2.0.4", + "phpunit/php-timer": "^5.0.3", + "sebastian/cli-parser": "^1.0.2", + "sebastian/code-unit": "^1.0.8", + "sebastian/comparator": "^4.0.10", + "sebastian/diff": "^4.0.6", + "sebastian/environment": "^5.1.5", + "sebastian/exporter": "^4.0.8", + "sebastian/global-state": "^5.0.8", + "sebastian/object-enumerator": "^4.0.4", + "sebastian/resource-operations": "^3.0.4", + "sebastian/type": "^3.2.1", + "sebastian/version": "^3.0.2" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "9.6-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/9.6.34" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "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/phpunit", + "type": "tidelift" + } + ], + "time": "2026-01-27T05:45:00+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "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 parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T06:27:43+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "1.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "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": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:08:54+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:30:19+00:00" + }, + { + "name": "sebastian/comparator", + "version": "4.0.10", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d", + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/diff": "^4.0", + "sebastian/exporter": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.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": "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", + "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:22:56+00:00" + }, + { + "name": "sebastian/complexity", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a", + "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.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", + "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-22T06:19:30+00:00" + }, + { + "name": "sebastian/diff", + "version": "4.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc", + "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.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", + "source": "https://github.com/sebastianbergmann/diff/tree/4.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T06:30:58+00:00" + }, + { + "name": "sebastian/environment", + "version": "5.1.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.1-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": "http://www.github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "source": "https://github.com/sebastianbergmann/environment/tree/5.1.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:03:51+00:00" + }, + { + "name": "sebastian/exporter", + "version": "4.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/14c6ba52f95a36c3d27c835d65efc7123c446e8c", + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "ext-mbstring": "*", + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.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": "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", + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.8" + }, + "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": "2025-09-24T06:03:27+00:00" + }, + { + "name": "sebastian/global-state", + "version": "5.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6", + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-uopz": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.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": "http://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.8" + }, + "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": "2025-08-10T07:10:35+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "1.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "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 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", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-22T06:20:34+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:12:34+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.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", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:14:26+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "4.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "539c6691e0623af6dc6f9c20384c120f963465a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/539c6691e0623af6dc6f9c20384c120f963465a0", + "reference": "539c6691e0623af6dc6f9c20384c120f963465a0", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.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", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.6" + }, + "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": "2025-08-10T06:57:39+00:00" + }, + { + "name": "sebastian/resource-operations", + "version": "3.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/resource-operations.git", + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/05d5692a7993ecccd56a03e40cd7e5b09b1d404e", + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides a list of PHP built-in functions that operate on resources", + "homepage": "https://www.github.com/sebastianbergmann/resource-operations", + "support": { + "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-14T16:00:52+00:00" + }, + { + "name": "sebastian/type", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.2-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", + "source": "https://github.com/sebastianbergmann/type/tree/3.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:13:03+00:00" + }, + { + "name": "sebastian/version", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c6c1022351a901512170118436c764e473f6de8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c", + "reference": "c6c1022351a901512170118436c764e473f6de8c", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.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", + "source": "https://github.com/sebastianbergmann/version/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T06:39:44+00:00" + }, + { + "name": "symfony/process", + "version": "v5.4.51", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "467bfc56f18f5ef6d5ccb09324d7e988c1c0a98f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/467bfc56f18f5ef6d5ccb09324d7e988c1c0a98f", + "reference": "467bfc56f18f5ef6d5ccb09324d7e988c1c0a98f", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-php80": "^1.16" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v5.4.51" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-26T15:53:37+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "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" + } + ], + "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/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": "^7.4", + "ext-pdo": "*", + "ext-curl": "*", + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-pdo_mysql": "*", + "ext-simplexml": "*" + }, + "platform-dev": {}, + "platform-overrides": { + "php": "7.4.0" }, - "platform-dev": [], - "plugin-api-version": "2.0.0" + "plugin-api-version": "2.6.0" } diff --git a/api/phpunit.xml b/api/phpunit.xml new file mode 100644 index 0000000..ce7b285 --- /dev/null +++ b/api/phpunit.xml @@ -0,0 +1,22 @@ + + + + + + tests/Unit + + + tests/Functional + + + + + + src + + + diff --git a/api/tests/Unit/Core/PaginatedCollectionTest.php b/api/tests/Unit/Core/PaginatedCollectionTest.php new file mode 100644 index 0000000..329199b --- /dev/null +++ b/api/tests/Unit/Core/PaginatedCollectionTest.php @@ -0,0 +1,222 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + + Capsule::schema()->create(self::TABLE, static function ($table) { + $table->increments('id'); + $table->string('name'); + }); + + for ($i = 1; $i <= self::TOTAL; $i++) { + Capsule::table(self::TABLE)->insert(['name' => "item_{$i}"]); + } + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private function makeCollection(array $headers = []): PaginatedCollection + { + $this->setGlobalApp($headers); + return new PaginatedCollection(Capsule::table(self::TABLE)); + } + + private function applyToResponse(PaginatedCollection $collection): \MockSlimResponse + { + $response = $this->app->response; + $collection->setStatus($response); + $collection->setHeaders($response); + return $response; + } + + // ----------------------------------------------------------------------- + // Range header parsing + // ----------------------------------------------------------------------- + + public function testNoHeaderUsesDefaultRange(): void + { + // default_number_of_models_per_page = 15 (set in bootstrap) + $collection = $this->makeCollection(); + $page = $collection->get(null); + + $this->assertCount(15, $page); + } + + public function testRange0to14Returns15Items(): void + { + $collection = $this->makeCollection(['x-range' => '0-14']); + $page = $collection->get(null); + + $this->assertCount(15, $page); + $this->assertSame('item_1', $page[0]->name); + $this->assertSame('item_15', $page[14]->name); + } + + public function testRange15to19Returns5Items(): void + { + $collection = $this->makeCollection(['x-range' => '15-19']); + $page = $collection->get(null); + + $this->assertCount(5, $page); + $this->assertSame('item_16', $page[0]->name); + } + + public function testRange0to1Returns2Items(): void + { + $collection = $this->makeCollection(['x-range' => '0-1']); + $page = $collection->get(null); + + $this->assertCount(2, $page); + } + + public function testInvalidHeaderFallsBackToDefault(): void + { + $collection = $this->makeCollection(['x-range' => 'abc']); + $page = $collection->get(null); + $this->assertCount(15, $page); + } + + public function testMissingHeaderFallsBackToDefault(): void + { + $collection = $this->makeCollection([]); + $page = $collection->get(null); + $this->assertCount(15, $page); + } + + public function testRangeBeyondLastItemClampsToEnd(): void + { + // Request items 18–99 but only 20 exist → should return items 18–19 (2 items) + $collection = $this->makeCollection(['x-range' => '18-99']); + $page = $collection->get(null); + $this->assertCount(2, $page); + } + + public function testRangeStartingAtOrBeyondTotalSetsStatus400(): void + { + // Request items 20–30 when only 20 exist (indices 0–19) → invalid start index + $collection = $this->makeCollection(['x-range' => '20-30']); + $response = $this->applyToResponse($collection); + + $this->assertSame(400, $response->getStatus()); + } + + // ----------------------------------------------------------------------- + // HTTP status codes + // ----------------------------------------------------------------------- + + public function testPartialResultSetsStatus206(): void + { + $collection = $this->makeCollection(['x-range' => '0-4']); + $response = $this->applyToResponse($collection); + + $this->assertSame(206, $response->getStatus()); + } + + public function testFullResultSetsStatus200(): void + { + // Request all 20 items in one page + $collection = $this->makeCollection(['x-range' => '0-19']); + $response = $this->applyToResponse($collection); + + $this->assertSame(200, $response->getStatus()); + } + + public function testDefaultRangeSetsStatus206WhenMoreItemsExist(): void + { + // 20 total, default page = 15 → partial + $collection = $this->makeCollection([]); + $response = $this->applyToResponse($collection); + + $this->assertSame(206, $response->getStatus()); + } + + // ----------------------------------------------------------------------- + // Response headers + // ----------------------------------------------------------------------- + + public function testAcceptRangeHeaderContainsTotal(): void + { + $collection = $this->makeCollection(['x-range' => '0-4']); + $response = $this->applyToResponse($collection); + + $acceptRange = $response->headers['accept-range']; + $this->assertStringContainsString((string) self::TOTAL, $acceptRange); + } + + public function testContentRangeHeaderReflectsReturnedWindow(): void + { + $collection = $this->makeCollection(['x-range' => '5-9']); + $response = $this->applyToResponse($collection); + + $contentRange = $response->headers['content-range']; + $this->assertSame('5-9/20', $contentRange); + } + + public function testContentRangeHeaderForDefaultRange(): void + { + $collection = $this->makeCollection([]); + $response = $this->applyToResponse($collection); + + $contentRange = $response->headers['content-range']; + $this->assertSame('0-14/20', $contentRange); + } + + // ----------------------------------------------------------------------- + // Empty table + // ----------------------------------------------------------------------- + + public function testEmptyTableReturns200WithEmptyPage(): void + { + // Create a separate empty table for this test + Capsule::schema()->create('empty_test', static function ($table) { + $table->increments('id'); + }); + + $this->setGlobalApp(); + $collection = new PaginatedCollection(Capsule::table('empty_test')); + $page = $collection->get(null); + + $response = $this->app->response; + $collection->setStatus($response); + $collection->setHeaders($response); + + $this->assertEmpty($page); + $this->assertSame(200, $response->getStatus()); + + Capsule::schema()->drop('empty_test'); + } +} diff --git a/api/tests/Unit/Core/ToolTest.php b/api/tests/Unit/Core/ToolTest.php new file mode 100644 index 0000000..be41398 --- /dev/null +++ b/api/tests/Unit/Core/ToolTest.php @@ -0,0 +1,247 @@ +previousErrorLog = (string) ini_get('error_log'); + ini_set('error_log', sys_get_temp_dir() . '/phpunit_glpi_api.log'); + } + + protected function tearDown(): void + { + ini_set('error_log', $this->previousErrorLog); + parent::tearDown(); + } + + // ----------------------------------------------------------------------- + // randomSha1 + // ----------------------------------------------------------------------- + + public function testRandomSha1Returns40CharHexString(): void + { + $result = Tool::randomSha1(); + $this->assertMatchesRegularExpression('/^[0-9a-f]{40}$/', $result); + } + + public function testRandomSha1ReturnsDifferentValuesOnSuccessiveCalls(): void + { + $a = Tool::randomSha1(); + $b = Tool::randomSha1(); + $this->assertNotSame($a, $b); + } + + // ----------------------------------------------------------------------- + // getUrlSlug + // ----------------------------------------------------------------------- + + public function testGetUrlSlugLowercasesString(): void + { + $this->assertSame('hello-world', Tool::getUrlSlug('Hello World')); + } + + public function testGetUrlSlugReplacesNonAlphaWithDash(): void + { + $this->assertSame('foo-bar', Tool::getUrlSlug('foo bar')); + $this->assertSame('foo-bar', Tool::getUrlSlug('foo.bar')); + } + + public function testGetUrlSlugReplacesDigitsWithDash(): void + { + // The regex [^a-z] replaces digits too, so abc123 → abc--- + $this->assertSame('abc---', Tool::getUrlSlug('abc123')); + } + + // ----------------------------------------------------------------------- + // getRequestLang — uses x-lang header (not Accept-Language) + // ----------------------------------------------------------------------- + + public function testGetRequestLangDefaultsToEnWhenNoHeader(): void + { + $this->setGlobalApp([]); + $this->assertSame('en', Tool::getRequestLang()); + } + + public function testGetRequestLangReturnsFrenchWhenXLangIsFr(): void + { + $this->setGlobalApp(['x-lang' => 'fr']); + $this->assertSame('fr', Tool::getRequestLang()); + } + + public function testGetRequestLangReturnsEnglishWhenXLangIsEn(): void + { + $this->setGlobalApp(['x-lang' => 'en']); + $this->assertSame('en', Tool::getRequestLang()); + } + + public function testGetRequestLangReturnsSpanishWhenXLangIsEs(): void + { + $this->setGlobalApp(['x-lang' => 'es']); + $this->assertSame('es', Tool::getRequestLang()); + } + + public function testGetRequestLangFallsBackToEnForUnsupportedLanguage(): void + { + $this->setGlobalApp(['x-lang' => 'de']); + $this->assertSame('en', Tool::getRequestLang()); + } + + public function testGetRequestLangFallsBackToEnForInvalidValue(): void + { + $this->setGlobalApp(['x-lang' => '']); + $this->assertSame('en', Tool::getRequestLang()); + } + + // ----------------------------------------------------------------------- + // getBody + // ----------------------------------------------------------------------- + + public function testGetBodyParsesValidJsonObject(): void + { + $this->setGlobalApp([],'{"username":"john","age":30}'); + $body = Tool::getBody(); + $this->assertInstanceOf(\stdClass::class, $body); + $this->assertSame('john', $body->username); + $this->assertSame(30, $body->age); + } + + public function testGetBodyReturnsNullForEmptyBody(): void + { + $this->setGlobalApp([],''); + $body = Tool::getBody(); + $this->assertNull($body); + } + + public function testGetBodyReturnsNullForMalformedJson(): void + { + $this->setGlobalApp([],'{not valid json}'); + $body = Tool::getBody(); + $this->assertNull($body); + } + + public function testGetBodyReturnsArrayForJsonArray(): void + { + // json_decode of a JSON array returns an array, not stdClass + $this->setGlobalApp([],'[1,2,3]'); + $body = Tool::getBody(); + $this->assertIsArray($body); + } + + // ----------------------------------------------------------------------- + // makeEndpoint + // ----------------------------------------------------------------------- + + public function testMakeEndpointCallsWrappedCallable(): void + { + $called = false; + $endpoint = Tool::makeEndpoint(function () use (&$called) { + $called = true; + }); + + $endpoint(); + $this->assertTrue($called); + } + + public function testMakeEndpointPassesArgumentsToCallable(): void + { + $received = []; + $endpoint = Tool::makeEndpoint(function ($a, $b) use (&$received) { + $received = [$a, $b]; + }); + + $endpoint('foo', 42); + $this->assertSame(['foo', 42], $received); + } + + public function testMakeEndpointConvertsErrorResponseToJsonHalt(): void + { + $endpoint = Tool::makeEndpoint(function () { + throw new ResourceNotFound('Plugin', 'missing'); + }); + + try { + $endpoint(); + } catch (\Slim\Exception\Stop $stop) { + // halt() was called, which is the expected termination path + } + + $this->assertNotNull($this->app->halted, 'App::halt() should have been called'); + [$statusCode, $body] = $this->app->halted; + + $this->assertSame(404, $statusCode); + + $decoded = json_decode($body, true); + $this->assertArrayHasKey('error', $decoded); + $this->assertStringContainsString('RESOURCE_NOT_FOUND', $decoded['error']); + } + + public function testMakeEndpointConvertsLeagueAccessDeniedExceptionTo401(): void + { + $endpoint = Tool::makeEndpoint(function () { + throw new \League\OAuth2\Server\Exception\AccessDeniedException(); + }); + + try { + $endpoint(); + } catch (\Slim\Exception\Stop $stop) { + } + + $this->assertNotNull($this->app->halted); + [$statusCode, $body] = $this->app->halted; + + // AccessDeniedException is mapped to API\Exception\AccessDenied (HTTP 401) + $this->assertSame(401, $statusCode); + } + + public function testMakeEndpointWrapsUnknownExceptionAsServiceError(): void + { + $endpoint = Tool::makeEndpoint(function () { + throw new \RuntimeException('Something internal broke'); + }); + + try { + $endpoint(); + } catch (\Slim\Exception\Stop $stop) { + } + + $this->assertNotNull($this->app->halted); + [$statusCode, $body] = $this->app->halted; + + // ServiceError maps to 500 (or whatever its httpStatusCode is) + $decoded = json_decode($body, true); + $this->assertArrayHasKey('error', $decoded); + + // Should NOT leak the internal RuntimeException message + $this->assertStringNotContainsString('Something internal broke', $body); + } + + public function testMakeEndpointSwallowsSlimStopException(): void + { + // Slim\Exception\Stop raised inside the callable (e.g. from $app->halt) + // should be silently swallowed — not propagated out. + $endpoint = Tool::makeEndpoint(function () { + throw new \Slim\Exception\Stop(); + }); + + // No exception should escape the decorated endpoint + $endpoint(); + $this->assertTrue(true); // reached without exception + } +} diff --git a/api/tests/Unit/Core/ValidableXMLPluginDescriptionTest.php b/api/tests/Unit/Core/ValidableXMLPluginDescriptionTest.php new file mode 100644 index 0000000..109ff7c --- /dev/null +++ b/api/tests/Unit/Core/ValidableXMLPluginDescriptionTest.php @@ -0,0 +1,212 @@ + 'Test Plugin', + 'key' => 'testplugin', + 'state' => 'stable', + 'description' => ' + Short desc + Long description here. + ', + 'homepage' => 'https://example.com', + 'download' => 'https://example.com/download', + 'authors' => 'John Doe', + 'versions' => ' + + 1.0.0 + 9.5 + https://example.com/1.0.0 + + ', + 'langs' => 'en', + 'license' => 'GPL-2.0', + 'tags' => 'inventory', + ], $overrides); + + $body = implode("\n", $fields); + + return << + + {$body} + + XML; + } + + // ----------------------------------------------------------------------- + // Success cases + // ----------------------------------------------------------------------- + + public function testValidXmlPassesValidation(): void + { + $xml = new ValidableXMLPluginDescription($this->validXml()); + $this->assertTrue($xml->validate()); + } + + public function testValidXmlIsParsable(): void + { + $xml = new ValidableXMLPluginDescription($this->validXml()); + $this->assertTrue($xml->parsable); + } + + public function testChangelogNodeIsAccepted(): void + { + $xmlWithChangelog = $this->validXml([ + 'changelog' => 'https://example.com/CHANGELOG.md', + ]); + $xml = new ValidableXMLPluginDescription($xmlWithChangelog); + $this->assertTrue($xml->validate()); + } + + public function testContentsIsSimpleXmlElement(): void + { + $xml = new ValidableXMLPluginDescription($this->validXml()); + $this->assertInstanceOf(\SimpleXMLElement::class, $xml->contents); + } + + public function testKeyValueIsAccessible(): void + { + $xml = new ValidableXMLPluginDescription($this->validXml()); + $this->assertSame('testplugin', (string) $xml->contents->key); + } + + // ----------------------------------------------------------------------- + // Parse failure + // ----------------------------------------------------------------------- + + public function testInvalidXmlThrowsWithParseReason(): void + { + $this->expectException(InvalidXML::class); + + $xml = new ValidableXMLPluginDescription(''); + // Constructor throws immediately when XML is unparseable + } + + public function testInvalidXmlSetsParsableToFalse(): void + { + try { + new ValidableXMLPluginDescription('assertSame('parse', $e->getInfo('reason')); + } + } + + // ----------------------------------------------------------------------- + // Missing required field + // ----------------------------------------------------------------------- + + public function testMissingKeyThrowsInvalidXmlWithFieldReason(): void + { + $this->expectException(InvalidXML::class); + + $xml = new ValidableXMLPluginDescription($this->validXml(['key' => ''])); + $xml->validate(); + } + + public function testMissingAuthorsThrowsInvalidXml(): void + { + $this->expectException(InvalidXML::class); + + $xml = new ValidableXMLPluginDescription($this->validXml(['authors' => ''])); + $xml->validate(); + } + + public function testInvalidXmlReasonIsField(): void + { + try { + $xml = new ValidableXMLPluginDescription($this->validXml(['key' => ''])); + $xml->validate(); + } catch (InvalidXML $e) { + $this->assertSame('field', $e->getInfo('reason')); + } + } + + // ----------------------------------------------------------------------- + // collectMode — gathers all errors instead of throwing on first + // ----------------------------------------------------------------------- + + public function testCollectModeDoesNotThrowOnFirstError(): void + { + // Missing both 'key' and 'authors' — in collectMode all errors accumulate + $badXml = $this->validXml(['key' => '', 'authors' => '']); + $xml = new ValidableXMLPluginDescription($badXml, true); + + // Should not throw + $xml->validate(); + + $this->assertNotEmpty($xml->errors); + } + + public function testCollectModeCollectsMultipleErrors(): void + { + // Use *present but malformed* nodes so allFieldsOK() iterates all of them. + // hasAllRequiredFields() short-circuits after the first missing field, so + // we trigger errors through field-format validation instead. + // • ab → too short (< 3 chars) → 1 error + // • → wrong structure → 1 error + $badXml = $this->validXml([ + 'key' => 'ab', + 'description' => '', + ]); + $xml = new ValidableXMLPluginDescription($badXml, true); + $xml->validate(); + + $this->assertGreaterThan(1, count($xml->errors)); + foreach ($xml->errors as $error) { + $this->assertInstanceOf(InvalidXML::class, $error); + } + } + + public function testCollectModeAllErrorsHaveFieldReason(): void + { + $badXml = $this->validXml(['key' => '', 'name' => '']); + $xml = new ValidableXMLPluginDescription($badXml, true); + $xml->validate(); + + foreach ($xml->errors as $error) { + $this->assertSame('field', $error->getInfo('reason')); + } + } + + // ----------------------------------------------------------------------- + // Key validation edge cases + // ----------------------------------------------------------------------- + + public function testKeyTooShortFailsValidation(): void + { + $this->expectException(InvalidXML::class); + + $xml = new ValidableXMLPluginDescription($this->validXml(['key' => 'ab'])); + $xml->validate(); + } + + public function testKeyTooLongFailsValidation(): void + { + $this->expectException(InvalidXML::class); + + $longKey = str_repeat('a', 51); + $xml = new ValidableXMLPluginDescription( + $this->validXml(['key' => "{$longKey}"]) + ); + $xml->validate(); + } +} diff --git a/api/tests/Unit/Exception/ExceptionsTest.php b/api/tests/Unit/Exception/ExceptionsTest.php new file mode 100644 index 0000000..b45c4a7 --- /dev/null +++ b/api/tests/Unit/Exception/ExceptionsTest.php @@ -0,0 +1,336 @@ +assertInstanceOf(\Exception::class, $e); + $this->assertInstanceOf(\JsonSerializable::class, $e); + } + + public function testHttpStatusCodeIsAtLeast400(): void + { + $e = new ResourceNotFound(); + $this->assertGreaterThanOrEqual(400, $e->httpStatusCode); + } + + public function testChildOfReturnsSelf(): void + { + $parent = new \RuntimeException('parent'); + $e = new ResourceNotFound('Plugin', 'key'); + $result = $e->childOf($parent); + $this->assertSame($e, $result, 'childOf() must be fluent (return $this)'); + } + + public function testGetInfoReturnsStoredValue(): void + { + $e = new InvalidXML('field', 'name', 'must be singular'); + $this->assertSame('field', $e->getInfo('reason')); + $this->assertSame('name', $e->getInfo('field')); + } + + public function testGetInfoReturnsNullForUnknownKey(): void + { + $e = new ResourceNotFound(); + $this->assertNull($e->getInfo('nonexistent')); + } + + public function testJsonSerializeReturnsRepresentationString(): void + { + $e = new ResourceNotFound('Plugin', 'myplugin'); + $repr = $e->jsonSerialize(); + $this->assertIsString($repr); + $this->assertStringContainsString('RESOURCE_NOT_FOUND', $repr); + } + + public function testGetRepresentationPublicOmitsPrivateInfos(): void + { + // UnavailableName marks 'type' as public and 'name' as private + $e = new UnavailableName('Plugin', 'myplugin'); + $public = $e->getRepresentation(true); + $this->assertStringContainsString('type=Plugin', $public); + $this->assertStringNotContainsString('myplugin', $public); + } + + public function testGetRepresentationPrivateIncludesEverything(): void + { + $e = new UnavailableName('Plugin', 'myplugin'); + $private = $e->getRepresentation(false); + $this->assertStringContainsString('myplugin', $private); + } + + // ----------------------------------------------------------------------- + // ResourceNotFound + // ----------------------------------------------------------------------- + + public function testResourceNotFoundHas404Status(): void + { + $e = new ResourceNotFound('Plugin', 'fields'); + $this->assertSame(404, $e->httpStatusCode); + } + + public function testResourceNotFoundRepresentationContainsTypeAndKey(): void + { + $e = new ResourceNotFound('Plugin', 'fields'); + $repr = $e->getRepresentation(true); + $this->assertStringContainsString('RESOURCE_NOT_FOUND', $repr); + $this->assertStringContainsString('type=Plugin', $repr); + $this->assertStringContainsString('key=fields', $repr); + } + + public function testResourceNotFoundWithNoArgs(): void + { + $e = new ResourceNotFound(); + $this->assertSame(404, $e->httpStatusCode); + $this->assertStringContainsString('RESOURCE_NOT_FOUND', $e->getRepresentation()); + } + + // ----------------------------------------------------------------------- + // InvalidField + // ----------------------------------------------------------------------- + + public function testInvalidFieldHas400Status(): void + { + $e = new InvalidField('username'); + $this->assertSame(400, $e->httpStatusCode); + } + + public function testInvalidFieldRepresentationContainsField(): void + { + $e = new InvalidField('email'); + $repr = $e->getRepresentation(true); + $this->assertStringContainsString('INVALID_FIELD', $repr); + $this->assertStringContainsString('field=email', $repr); + } + + // ----------------------------------------------------------------------- + // InvalidCredentials + // ----------------------------------------------------------------------- + + public function testInvalidCredentialsHas401Status(): void + { + $e = new InvalidCredentials('john', 8); + $this->assertSame(401, $e->httpStatusCode); + } + + public function testInvalidCredentialsRepresentationContainsErrorCode(): void + { + $e = new InvalidCredentials('john', 8); + $this->assertStringContainsString('INVALID_CREDENTIALS', $e->getRepresentation()); + } + + // ----------------------------------------------------------------------- + // AccessDenied (actual HTTP code: 401, not 403) + // ----------------------------------------------------------------------- + + public function testAccessDeniedHas401Status(): void + { + $e = new AccessDenied(); + $this->assertSame(401, $e->httpStatusCode); + } + + public function testAccessDeniedRepresentation(): void + { + $e = new AccessDenied('Bearer abc123'); + $this->assertStringContainsString('ACCESS_DENIED', $e->getRepresentation()); + } + + // ----------------------------------------------------------------------- + // LackPermission (actual HTTP code: 401, not 403) + // ----------------------------------------------------------------------- + + public function testLackPermissionHas401Status(): void + { + $e = new LackPermission('Plugin', 'fields', 'john', 'manage_permissions'); + $this->assertSame(401, $e->httpStatusCode); + } + + public function testLackPermissionRepresentation(): void + { + $e = new LackPermission('Plugin', 'fields', 'john', 'manage_permissions'); + $this->assertStringContainsString('LACK_PERMISSION', $e->getRepresentation()); + } + + // ----------------------------------------------------------------------- + // UnavailableName + // ----------------------------------------------------------------------- + + public function testUnavailableNameHas400Status(): void + { + $e = new UnavailableName('User', 'johndoe'); + $this->assertSame(400, $e->httpStatusCode); + } + + public function testUnavailableNameRepresentationContainsType(): void + { + $e = new UnavailableName('User', 'johndoe'); + $repr = $e->getRepresentation(true); + $this->assertStringContainsString('UNAVAILABLE_NAME', $repr); + $this->assertStringContainsString('type=User', $repr); + } + + // ----------------------------------------------------------------------- + // InvalidXML + // ----------------------------------------------------------------------- + + public function testInvalidXmlFieldReasonHas400Status(): void + { + $e = new InvalidXML('field', 'name', 'must be singular'); + $this->assertSame(400, $e->httpStatusCode); + } + + public function testInvalidXmlFieldSerializesReason(): void + { + $e = new InvalidXML('field', 'name', 'must be singular'); + $repr = $e->getRepresentation(true); + $this->assertStringContainsString('INVALID_XML', $repr); + $this->assertStringContainsString('reason=field', $repr); + $this->assertStringContainsString('field=name', $repr); + } + + public function testInvalidXmlParseReasonSerializesLine(): void + { + $e = new InvalidXML('parse', 5, 'unexpected token'); + $repr = $e->getRepresentation(true); + $this->assertStringContainsString('reason=parse', $repr); + $this->assertStringContainsString('line=5', $repr); + } + + public function testInvalidXmlUrlReasonSerializesReason(): void + { + $e = new InvalidXML('url', 'https://example.com/plugin.xml'); + $repr = $e->getRepresentation(true); + $this->assertStringContainsString('reason=url', $repr); + } + + public function testInvalidXmlGetInfoReason(): void + { + $e = new InvalidXML('field', 'key', 'too short'); + $this->assertSame('field', $e->getInfo('reason')); + } + + // ----------------------------------------------------------------------- + // InvalidRecaptcha + // ----------------------------------------------------------------------- + + public function testInvalidRecaptchaHas400Status(): void + { + $e = new InvalidRecaptcha(); + $this->assertSame(400, $e->httpStatusCode); + } + + // ----------------------------------------------------------------------- + // WrongPasswordResetToken + // ----------------------------------------------------------------------- + + public function testWrongPasswordResetTokenHas400Status(): void + { + $e = new WrongPasswordResetToken(); + $this->assertSame(400, $e->httpStatusCode); + } + + // ----------------------------------------------------------------------- + // NoCredentialsLeft (actual HTTP code: 401, not 400) + // ----------------------------------------------------------------------- + + public function testNoCredentialsLeftHas401Status(): void + { + $e = new NoCredentialsLeft(); + $this->assertSame(401, $e->httpStatusCode); + } + + // ----------------------------------------------------------------------- + // AlreadyWatched + // ----------------------------------------------------------------------- + + public function testAlreadyWatchedHas400Status(): void + { + $e = new AlreadyWatched('fields'); + $this->assertSame(400, $e->httpStatusCode); + } + + public function testAlreadyWatchedRepresentation(): void + { + $e = new AlreadyWatched('fields'); + $this->assertStringContainsString('ALREADY_WATCHED', $e->getRepresentation()); + } + + // ----------------------------------------------------------------------- + // RightAlreadyExist + // ----------------------------------------------------------------------- + + public function testRightAlreadyExistHas400Status(): void + { + $e = new RightAlreadyExist('john', 'myplugin'); + $this->assertSame(400, $e->httpStatusCode); + } + + public function testRightAlreadyExistRepresentationContainsUsername(): void + { + $e = new RightAlreadyExist('john', 'myplugin'); + $repr = $e->getRepresentation(true); + $this->assertStringContainsString('RIGHT_ALREADY_EXIST', $repr); + $this->assertStringContainsString('username=john', $repr); + } + + // ----------------------------------------------------------------------- + // RightDoesntExist (actual HTTP code: 400, not 404) + // ----------------------------------------------------------------------- + + public function testRightDoesntExistHas400Status(): void + { + $e = new RightDoesntExist('jane', 'myplugin'); + $this->assertSame(400, $e->httpStatusCode); + } + + // ----------------------------------------------------------------------- + // CannotDeleteAdmin (actual HTTP code: 401, not 400) + // ----------------------------------------------------------------------- + + public function testCannotDeleteAdminHas401Status(): void + { + $e = new CannotDeleteAdmin('myplugin', 'john'); + $this->assertSame(401, $e->httpStatusCode); + } + + public function testCannotDeleteAdminRepresentationContainsPluginAndUser(): void + { + $e = new CannotDeleteAdmin('myplugin', 'john'); + $repr = $e->getRepresentation(true); + $this->assertStringContainsString('CANNOT_DELETE_ADMIN', $repr); + $this->assertStringContainsString('pluginKey=myplugin', $repr); + $this->assertStringContainsString('username=john', $repr); + } +} diff --git a/api/tests/Unit/Model/AppTest.php b/api/tests/Unit/Model/AppTest.php new file mode 100644 index 0000000..821f714 --- /dev/null +++ b/api/tests/Unit/Model/AppTest.php @@ -0,0 +1,115 @@ +assertTrue(App::isValidName('MyAp')); // exactly 4 chars + $this->assertTrue(App::isValidName('My Great App')); + } + + public function testIsValidNameRejectsTooShort(): void + { + $this->assertFalse(App::isValidName('')); + $this->assertFalse(App::isValidName('abc')); // 3 chars + } + + // ----------------------------------------------------------------------- + // isValidUrl + // ----------------------------------------------------------------------- + + public function testIsValidUrlAcceptsHttpsUrl(): void + { + $this->assertTrue(App::isValidUrl('https://myapp.example.com')); + $this->assertTrue(App::isValidUrl('http://localhost:3000/path')); + } + + public function testIsValidUrlRejectsNonUrl(): void + { + $this->assertFalse(App::isValidUrl('not-a-url')); + $this->assertFalse(App::isValidUrl('example.com')); + $this->assertFalse(App::isValidUrl('')); + } + + // ----------------------------------------------------------------------- + // isValidDescription + // ----------------------------------------------------------------------- + + public function testIsValidDescriptionAcceptsNormalString(): void + { + $this->assertTrue(App::isValidDescription('A short description.')); + $this->assertTrue(App::isValidDescription(str_repeat('a', 500))); + } + + public function testIsValidDescriptionAcceptsEmptyString(): void + { + // Empty string has length 0 which is <= 500, so it is valid + $this->assertTrue(App::isValidDescription('')); + } + + public function testIsValidDescriptionRejectsTooLong(): void + { + $this->assertFalse(App::isValidDescription(str_repeat('a', 501))); + } + + // ----------------------------------------------------------------------- + // setRandomClientId / setRandomSecret + // ----------------------------------------------------------------------- + + public function testSetRandomClientIdGeneratesNonEmptyString(): void + { + $app = new App(); + $app->setRandomClientId(); + $this->assertNotEmpty($app->id); + $this->assertIsString($app->id); + } + + public function testSetRandomSecretGeneratesNonEmptyString(): void + { + $app = new App(); + $app->setRandomSecret(); + $this->assertNotEmpty($app->secret); + $this->assertIsString($app->secret); + } + + public function testSetRandomClientIdProducesDifferentValuesEachCall(): void + { + $app1 = new App(); + $app1->setRandomClientId(); + + $app2 = new App(); + $app2->setRandomClientId(); + + $this->assertNotSame($app1->id, $app2->id); + } + + public function testSetRandomSecretProducesDifferentValuesEachCall(): void + { + $app1 = new App(); + $app1->setRandomSecret(); + + $app2 = new App(); + $app2->setRandomSecret(); + + $this->assertNotSame($app1->secret, $app2->secret); + } + + public function testSetRandomClientIdIsAtMost20Chars(): void + { + // SecureKey::generate() produces a 40-char hex; the code takes substr(, 0, 20) + $app = new App(); + $app->setRandomClientId(); + $this->assertLessThanOrEqual(20, strlen((string) $app->id)); + } +} diff --git a/api/tests/Unit/Model/AuthorTest.php b/api/tests/Unit/Model/AuthorTest.php new file mode 100644 index 0000000..736d22f --- /dev/null +++ b/api/tests/Unit/Model/AuthorTest.php @@ -0,0 +1,81 @@ +assertSame(['Jane Smith'], $result); + } + + public function testEmptyStringReturnsEmptyArray(): void + { + $result = Author::fixKnownDuplicates(''); + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + public function testKnownDuplicateIsNormalizedToCanonicalName(): void + { + // 'Xavier Caillaud / Infotel' maps to 'Xavier Caillaud' + $result = Author::fixKnownDuplicates('Xavier Caillaud / Infotel'); + $this->assertContains('Xavier Caillaud', $result); + } + + public function testKnownDuplicateAllCapsIsNormalized(): void + { + // 'Xavier CAILLAUD' also maps to 'Xavier Caillaud' + $result = Author::fixKnownDuplicates('Xavier CAILLAUD'); + $this->assertContains('Xavier Caillaud', $result); + } + + public function testCommaSeparatorSplitsMultipleAuthors(): void + { + $result = Author::fixKnownDuplicates('John Doe, Jane Smith'); + $this->assertCount(2, $result); + $this->assertContains('John Doe', $result); + $this->assertContains('Jane Smith', $result); + } + + public function testSlashSeparatorSplitsMultipleAuthors(): void + { + $result = Author::fixKnownDuplicates('Alice / Bob'); + $this->assertCount(2, $result); + $this->assertContains('Alice', $result); + $this->assertContains('Bob', $result); + } + + public function testCommaSeparatorTakesPrecedenceOverSlash(): void + { + // Comma is checked first in the separator loop; once found, slash is ignored + $result = Author::fixKnownDuplicates('Alice, Bob / Charlie'); + // split on comma → ['Alice', 'Bob / Charlie'] + $this->assertCount(2, $result); + $this->assertContains('Alice', $result); + } + + public function testNamesAreTrimmedAfterSplit(): void + { + $result = Author::fixKnownDuplicates(' John Doe , Jane Smith '); + $this->assertContains('John Doe', $result); + $this->assertContains('Jane Smith', $result); + } + + public function testOtherKnownDuplicatesAreNormalized(): void + { + // 'David DURIEUX' → 'David Durieux' + $result = Author::fixKnownDuplicates('David DURIEUX'); + $this->assertSame(['David Durieux'], $result); + } +} diff --git a/api/tests/Unit/Model/UserTest.php b/api/tests/Unit/Model/UserTest.php new file mode 100644 index 0000000..dcde5ae --- /dev/null +++ b/api/tests/Unit/Model/UserTest.php @@ -0,0 +1,147 @@ +assertTrue(User::isValidPassword('abc123')); // exactly 6 chars + } + + public function testIsValidPasswordAcceptsMaximumLength(): void + { + $this->assertTrue(User::isValidPassword(str_repeat('a', 26))); + } + + public function testIsValidPasswordRejectsShortPassword(): void + { + $this->assertFalse(User::isValidPassword('abc')); // 3 chars + $this->assertFalse(User::isValidPassword('abcde')); // 5 chars + } + + public function testIsValidPasswordRejectsEmptyString(): void + { + $this->assertFalse(User::isValidPassword('')); + } + + public function testIsValidPasswordRejectsTooLong(): void + { + $this->assertFalse(User::isValidPassword(str_repeat('a', 27))); + } + + public function testIsValidPasswordRejectsNonString(): void + { + $this->assertFalse(User::isValidPassword(123456)); + $this->assertFalse(User::isValidPassword(null)); + } + + // ----------------------------------------------------------------------- + // isValidRealname + // ----------------------------------------------------------------------- + + public function testIsValidRealnameAcceptsValidName(): void + { + $this->assertTrue(User::isValidRealname('John')); // exactly 4 chars + $this->assertTrue(User::isValidRealname('John Doe')); + } + + public function testIsValidRealnameAcceptsMaxLength(): void + { + $this->assertTrue(User::isValidRealname(str_repeat('a', 28))); + } + + public function testIsValidRealnameRejectsTooShort(): void + { + $this->assertFalse(User::isValidRealname('Jo')); // 2 chars + $this->assertFalse(User::isValidRealname('')); + } + + public function testIsValidRealnameRejectsTooLong(): void + { + $this->assertFalse(User::isValidRealname(str_repeat('a', 29))); + } + + public function testIsValidRealnameRejectsNonString(): void + { + $this->assertFalse(User::isValidRealname(null)); + $this->assertFalse(User::isValidRealname(1234)); + } + + // Note: the code only checks length (4–28 chars), not character set. + // Special characters are permitted by the implementation. + + // ----------------------------------------------------------------------- + // isValidWebsite + // ----------------------------------------------------------------------- + + public function testIsValidWebsiteAcceptsHttpUrl(): void + { + $this->assertTrue(User::isValidWebsite('https://example.com')); + $this->assertTrue(User::isValidWebsite('http://foo.bar/path')); + } + + public function testIsValidWebsiteRejectsPlainString(): void + { + $this->assertFalse(User::isValidWebsite('example.com')); + $this->assertFalse(User::isValidWebsite('not a url')); + $this->assertFalse(User::isValidWebsite('')); + } + + public function testIsValidWebsiteRejectsNonString(): void + { + $this->assertFalse(User::isValidWebsite(null)); + } + + // ----------------------------------------------------------------------- + // setPassword / assertPasswordIs + // ----------------------------------------------------------------------- + + public function testSetPasswordStoresBcryptHash(): void + { + $user = new User(); + $user->setPassword('mysecret'); + + $this->assertNotEmpty($user->password); + $this->assertNotSame('mysecret', $user->password); + $this->assertStringStartsWith('$2y$', $user->password); // bcrypt identifier + } + + public function testAssertPasswordIsReturnsTrueForCorrectPassword(): void + { + $user = new User(); + $user->setPassword('correctpassword'); + + $this->assertTrue($user->assertPasswordIs('correctpassword')); + } + + public function testAssertPasswordIsReturnsFalseForWrongPassword(): void + { + $user = new User(); + $user->setPassword('correctpassword'); + + $this->assertFalse($user->assertPasswordIs('wrongpassword')); + $this->assertFalse($user->assertPasswordIs('')); + } + + public function testSetPasswordHashIsDifferentEachTime(): void + { + $user1 = new User(); + $user1->setPassword('samepassword'); + + $user2 = new User(); + $user2->setPassword('samepassword'); + + // bcrypt uses random salt — two hashes for the same password must differ + $this->assertNotSame($user1->password, $user2->password); + } +} diff --git a/api/tests/Unit/OAuthServer/OAuthHelperTest.php b/api/tests/Unit/OAuthServer/OAuthHelperTest.php new file mode 100644 index 0000000..fc62111 --- /dev/null +++ b/api/tests/Unit/OAuthServer/OAuthHelperTest.php @@ -0,0 +1,153 @@ +mockToken = Mockery::mock('token'); + $this->mockToken->shouldReceive('hasScope') + ->byDefault() + ->andReturn(true); + + // Build a mock resource server + $this->resourceServer = Mockery::mock(\League\OAuth2\Server\ResourceServer::class); + $this->resourceServer->shouldReceive('isValidRequest') + ->byDefault() + ->andReturn(true); + $this->resourceServer->shouldReceive('getAccessToken') + ->byDefault() + ->andReturn($this->mockToken); + + $GLOBALS['resourceServer'] = $this->resourceServer; + } + + // ----------------------------------------------------------------------- + // needsScopes — passes + // ----------------------------------------------------------------------- + + public function testNeedsScopesDoesNotThrowWhenAllScopesPresent(): void + { + $this->mockToken->shouldReceive('hasScope') + ->with('plugins') + ->andReturn(true); + + // Should not throw + OAuthHelper::needsScopes(['plugins']); + $this->assertTrue(true); + } + + public function testNeedsScopesDoesNotThrowForEmptyScopeList(): void + { + OAuthHelper::needsScopes([]); + $this->assertTrue(true); + } + + public function testNeedsScopesDoesNotThrowWhenMultipleScopesAllPresent(): void + { + $this->mockToken->shouldReceive('hasScope')->andReturn(true); + + OAuthHelper::needsScopes(['plugins', 'plugin:card', 'user']); + $this->assertTrue(true); + } + + // ----------------------------------------------------------------------- + // needsScopes — throws + // ----------------------------------------------------------------------- + + public function testNeedsScopesThrowsWhenRequiredScopeIsMissing(): void + { + $this->expectException(AccessDeniedException::class); + + $this->mockToken->shouldReceive('hasScope') + ->with('plugin:submit') + ->andReturn(false); + + OAuthHelper::needsScopes(['plugin:submit']); + } + + public function testNeedsScopesThrowsOnFirstMissingScope(): void + { + $this->mockToken->shouldReceive('hasScope') + ->with('plugins') + ->andReturn(true); + $this->mockToken->shouldReceive('hasScope') + ->with('missing_scope') + ->andReturn(false); + + $this->expectException(AccessDeniedException::class); + OAuthHelper::needsScopes(['plugins', 'missing_scope']); + } + + public function testNeedsScopesThrowsWhenRequestIsInvalid(): void + { + $this->resourceServer->shouldReceive('isValidRequest') + ->andThrow(new \League\OAuth2\Server\Exception\AccessDeniedException()); + + $this->expectException(AccessDeniedException::class); + OAuthHelper::needsScopes(['plugins']); + } + + // ----------------------------------------------------------------------- + // Storage singletons — smoke tests (no DB required) + // ----------------------------------------------------------------------- + + public function testGetAccessTokenStorageReturnsSameInstance(): void + { + // Reset static singletons via reflection so tests don't affect each other + $ref = new \ReflectionProperty(OAuthHelper::class, 'accessTokenStorage'); + $ref->setAccessible(true); + $ref->setValue(null, null); + + $s1 = OAuthHelper::getAccessTokenStorage(); + $s2 = OAuthHelper::getAccessTokenStorage(); + + $this->assertSame($s1, $s2, 'getAccessTokenStorage() must return a singleton'); + } + + public function testGetRefreshTokenStorageReturnsSameInstance(): void + { + $ref = new \ReflectionProperty(OAuthHelper::class, 'refreshTokenStorage'); + $ref->setAccessible(true); + $ref->setValue(null, null); + + $s1 = OAuthHelper::getRefreshTokenStorage(); + $s2 = OAuthHelper::getRefreshTokenStorage(); + + $this->assertSame($s1, $s2); + } + + public function testGetClientStorageReturnsSameInstance(): void + { + $ref = new \ReflectionProperty(OAuthHelper::class, 'clientStorage'); + $ref->setAccessible(true); + $ref->setValue(null, null); + + $s1 = OAuthHelper::getClientStorage(); + $s2 = OAuthHelper::getClientStorage(); + + $this->assertSame($s1, $s2); + } +} diff --git a/api/tests/Unit/TestCase.php b/api/tests/Unit/TestCase.php new file mode 100644 index 0000000..e44d71d --- /dev/null +++ b/api/tests/Unit/TestCase.php @@ -0,0 +1,56 @@ +setGlobalApp(); + $this->setDefaultResourceServerStub(); + } + + /** + * ErrorResponse::log() accesses the global $resourceServer. + * Provide a minimal stub so it doesn't throw an Error on null. + * Tests that need specific behaviour override this in their own setUp(). + */ + protected function setDefaultResourceServerStub(): void + { + // isValidRequest() returns normally; getAccessToken() throws \Exception + // so the log() try/catch suppresses it cleanly. + $stub = \Mockery::mock(\League\OAuth2\Server\ResourceServer::class); + $stub->shouldReceive('isValidRequest')->andReturn(null); + $stub->shouldReceive('getAccessToken') + ->andThrow(new \League\OAuth2\Server\Exception\AccessDeniedException()); + $GLOBALS['resourceServer'] = $stub; + } + + protected function tearDown(): void + { + // Reset globals so tests don't bleed into each other + unset($GLOBALS['app']); + unset($GLOBALS['resourceServer']); + parent::tearDown(); + } + + /** + * Create a fresh MockSlimApp and inject it as the global $app. + */ + protected function setGlobalApp(array $headers = [], string $body = ''): \MockSlimApp + { + $this->app = new \MockSlimApp($headers, $body); + $GLOBALS['app'] = $this->app; + return $this->app; + } +} diff --git a/api/tests/bootstrap.php b/api/tests/bootstrap.php new file mode 100644 index 0000000..b5a9251 --- /dev/null +++ b/api/tests/bootstrap.php @@ -0,0 +1,152 @@ + 15, + 'recaptcha_secret' => 'test_recaptcha_secret', + 'client_url' => 'http://localhost', + 'api_url' => 'http://localhost/api', + 'msg_alerts' => [ + 'transport' => 'mail', + 'local_admins' => ['admin@example.com' => 'Admin'], + 'from' => ['noreply@example.com' => 'GLPi Plugins Test'], + ], + 'oauth' => [ + 'github' => ['clientId' => 'test_id', 'clientSecret' => 'test_secret'], + ], +]; + +// --------------------------------------------------------------------------- +// Lightweight Slim-2-compatible mock objects used by Tool and PaginatedCollection +// --------------------------------------------------------------------------- + +/** + * Mimics Slim\Helper\Set – supports array-access reads and offsetSet writes, + * plus a set() method used by Tool::endWithJson(). + */ +class MockHeadersSet implements ArrayAccess +{ + private array $data; + + public function __construct(array $data = []) + { + $this->data = array_change_key_case($data, CASE_LOWER); + } + + public function set(string $key, string $value): void + { + $this->data[strtolower($key)] = $value; + } + + public function get(string $key) + { + return $this->data[strtolower($key)] ?? null; + } + + public function offsetExists($offset): bool + { + return isset($this->data[strtolower((string) $offset)]); + } + + public function offsetGet($offset) + { + return $this->data[strtolower((string) $offset)] ?? null; + } + + public function offsetSet($offset, $value): void + { + $this->data[strtolower((string) $offset)] = $value; + } + + public function offsetUnset($offset): void + { + unset($this->data[strtolower((string) $offset)]); + } + + public function all(): array + { + return $this->data; + } +} + +class MockSlimRequest +{ + public MockHeadersSet $headers; + private string $body; + private string $method; + private string $uri; + + public function __construct(array $headers = [], string $body = '', string $method = 'GET', string $uri = '/') + { + $this->headers = new MockHeadersSet($headers); + $this->body = $body; + $this->method = $method; + $this->uri = $uri; + } + + public function getBody(): string + { + return $this->body; + } + + public function getResourceUri(): string + { + return $this->uri; + } + + public function getMethod(): string + { + return $this->method; + } +} + +class MockSlimResponse +{ + public MockHeadersSet $headers; + private int $statusCode = 200; + + public function __construct() + { + $this->headers = new MockHeadersSet(); + } + + public function status(int $code): void + { + $this->statusCode = $code; + } + + public function getStatus(): int + { + return $this->statusCode; + } +} + +/** + * Drop-in replacement for the global $app in unit tests. + * halt() stores arguments and throws Slim\Exception\Stop so makeEndpoint() + * behaves correctly (it swallows Stop silently). + */ +class MockSlimApp +{ + public MockSlimRequest $request; + public MockSlimResponse $response; + + /** Set by halt(); null if halt() was never called. */ + public ?array $halted = null; + + public function __construct(array $requestHeaders = [], string $requestBody = '') + { + $this->request = new MockSlimRequest($requestHeaders, $requestBody); + $this->response = new MockSlimResponse(); + } + + public function halt(int $code, string $body = '') + { + $this->halted = [$code, $body]; + throw new \Slim\Exception\Stop(); + } +} diff --git a/specs/api/endpoints.md b/specs/api/endpoints.md new file mode 100644 index 0000000..f9ee711 --- /dev/null +++ b/specs/api/endpoints.md @@ -0,0 +1,1033 @@ +# API Endpoint Reference + +Base URL: `https:///api` + +## Authentication + +All authenticated endpoints require a Bearer token in the `Authorization` header: + +``` +Authorization: Bearer +``` + +Tokens are issued by `POST /oauth/authorize` using one of three OAuth2 grant types: +- `password` — user login (username/password) +- `client_credentials` — API app key +- `refresh_token` — token renewal + +### Scopes + +| Scope | Description | +|-------|-------------| +| `plugins` | Browse plugin list | +| `plugins:search` | Search plugins | +| `plugin:card` | Read single plugin details | +| `plugin:star` | Rate a plugin | +| `plugin:submit` | Submit a new plugin | +| `plugin:download` | Download a plugin | +| `tags` | Browse tag list | +| `tag` | Read single tag | +| `authors` | Browse author list | +| `author` | Read single author | +| `version` | Filter by GLPI version | +| `user` | Read/edit own profile | +| `user:apps` | Manage own API apps | +| `user:externalaccounts` | Manage linked OAuth accounts | +| `users:search` | Search users | +| `message` | Send contact message | + +### Pagination + +Paginated endpoints support range-based pagination via HTTP headers: + +**Request header:** +``` +x-range: 0-14 +``` + +**Response headers:** +``` +accept-range: model 100 +content-range: 0-14/100 +``` + +HTTP status `206 Partial Content` is returned for partial results, `200 OK` for the complete set. + +Default page size: **15 items**. + +### Language + +Include the `x-lang` header to get localized plugin descriptions: + +``` +x-lang: fr +``` + +Supported values: `en`, `fr`, `es`. Any other value falls back to `en`. + +--- + +## OAuth & Authorization + +### `POST /oauth/authorize` + +Issue an access token. + +**No auth required.** + +**Request body (password grant):** +```json +{ + "grant_type": "password", + "username": "john", + "password": "secret", + "client_id": "", + "client_secret": "" +} +``` + +**Request body (refresh_token grant):** +```json +{ + "grant_type": "refresh_token", + "refresh_token": "", + "client_id": "", + "client_secret": "" +} +``` + +**Request body (client_credentials grant):** +```json +{ + "grant_type": "client_credentials", + "client_id": "", + "client_secret": "" +} +``` + +**Response `200`:** +```json +{ + "access_token": "...", + "refresh_token": "...", + "expires_in": 3600, + "token_type": "Bearer" +} +``` + +--- + +### `GET /oauth/associate/:service` + +OAuth2 callback from an external provider (currently: `github`). Handles three flows: + +1. **New user** — creates a GLPI account from external account info, returns tokens +2. **Link account** — links external account to the currently authenticated user (pass `access_token` via cookie) +3. **Returning user** — logs in and returns tokens if external account is already linked + +**No auth required.** Returns an HTML page that posts a `window.postMessage` with the result. + +**Path parameter:** `service` = `github` + +**Response data (in postMessage payload):** +```json +{ + "access_token": "...", + "refresh_token": "...", + "access_token_expires_in": 3600, + "account_created": true, + "external_account_linked": true +} +``` + +--- + +### `GET /oauth/available_emails` + +Returns all email addresses available through the user's linked external accounts. + +**Scopes required:** `user` + +**Response `200`:** +```json +[ + { "email": "user@example.com", "service": "github" } +] +``` + +--- + +## Users + +### `POST /user` + +Register a new account. Sends a confirmation email to the provided address. + +**No auth required.** + +**Request body:** +```json +{ + "username": "john", + "email": "john@example.com", + "password": "secret123", + "realname": "John Doe", + "location": "Paris", + "website": "https://example.com" +} +``` + +| Field | Required | Validation | +|-------|----------|------------| +| `username` | Yes | 4–28 chars, alphanumeric only | +| `email` | Yes | Valid email format, unique | +| `password` | Yes | Must pass `User::isValidPassword()` | +| `realname` | No | 4+ chars, alphanumeric + spaces | +| `location` | No | Non-empty string | +| `website` | No | Valid URL | + +**Response `200`:** empty body (email sent) + +**Errors:** +- `400 InvalidField` — invalid username/email/password format +- `400 UnavailableName` — username or email already taken + +--- + +### `GET /user` + +Get the current authenticated user's profile. + +**Scopes required:** `user` + +**Response `200`:** +```json +{ + "id": 1, + "username": "john", + "email": "john@example.com", + "realname": "John Doe", + "location": "Paris", + "website": "https://example.com", + "gravatar": "", + "active": true +} +``` + +--- + +### `PUT /user` + +Edit the current user's profile. All fields are optional. + +**Scopes required:** `user` + +**Request body:** +```json +{ + "email": "newemail@example.com", + "password": "newpassword", + "realname": "New Name", + "website": "https://new-site.com" +} +``` + +Note: changing `email` is only allowed if that address is verified through a linked external account. + +**Response `200`:** updated user object + +--- + +### `POST /user/delete` + +Delete the current user's account. Requires password confirmation. + +**Scopes required:** `user` + +**Request body:** +```json +{ "password": "current_password" } +``` + +**Response `200`:** empty body + +**Errors:** +- `401 InvalidCredentials` — wrong password + +--- + +### `GET /user/validatemail/:token` + +Validates the user's email address using the token from the confirmation email. Activates the account and returns an access token. + +**No auth required.** + +**Path parameter:** `token` — validation token from email + +**Response `200`:** +```json +{ + "access_token": "...", + "refresh_token": "...", + "expires_in": 3600 +} +``` + +**Errors:** +- `400 InvalidValidationToken` — token not found + +--- + +### `POST /user/sendpasswordresetlink` + +Sends a password reset link to the provided email address. + +**No auth required.** + +**Request body:** +```json +{ "email": "john@example.com" } +``` + +**Response `200`:** empty body + +**Errors:** +- `400 InvalidField` — missing/invalid email +- `404 AccountNotFound` — no account with that email + +--- + +### `PUT /user/password` + +Reset password using a token received by email. + +**No auth required.** + +**Request body:** +```json +{ + "token": "", + "password": "new_password" +} +``` + +**Response `200`:** empty body + +**Errors:** +- `400 WrongPasswordResetToken` — token missing or not found +- `400 InvalidField` — invalid password + +--- + +### `GET /user/plugins` + +List plugins the current user has permissions on (active plugins only). + +**Scopes required:** `user`, `plugins` + +**Response `200`:** array of plugin objects + +--- + +### `GET /user/watchs` + +List plugin keys that the current user is watching. + +**Scopes required:** `user`, `plugins` + +**Response `200`:** +```json +["myplugin", "anotherplugin"] +``` + +--- + +### `POST /user/watchs` + +Start watching a plugin. + +**Scopes required:** `user`, `plugins` + +**Request body:** +```json +{ "plugin_key": "myplugin" } +``` + +**Response `200`:** empty body + +**Errors:** +- `400 InvalidField` — missing plugin_key +- `404 ResourceNotFound` — plugin not found +- `400 AlreadyWatched` — already watching this plugin + +--- + +### `DELETE /user/watchs/:key` + +Stop watching a plugin. + +**Scopes required:** `user`, `plugins` + +**Path parameter:** `key` — plugin key + +**Response `200`:** empty body +**Response `404`:** plugin not found or not watching + +--- + +### `POST /user/search` + +Search users by username, realname, or exact email. + +**Scopes required:** `users:search` + +**Request body:** +```json +{ "search": "john" } +``` + +**Response `200`:** +```json +[ + { "username": "john", "realname": "John Doe" } +] +``` + +--- + +### `GET /user/external_accounts` + +List all external OAuth accounts linked to the current user. + +**Scopes required:** `user:externalaccounts` + +**Response `200`:** +```json +[ + { "id": 1, "service": "github", "external_user_id": "12345" } +] +``` + +--- + +### `DELETE /user/external_accounts/:id` + +Unlink an external OAuth account. + +**Scopes required:** `user:externalaccounts` + +**Path parameter:** `id` — external account ID + +**Response `200`:** empty body + +**Errors:** +- `401 NoCredentialsLeft` — cannot remove last external account when no password is set + +--- + +## User Apps (API Keys) + +### `GET /user/apps` + +List all API applications created by the current user. + +**Scopes required:** `user`, `user:apps` + +**Response `200`:** array of app objects + +--- + +### `GET /user/apps/:id` + +Get details of a specific app. + +**Scopes required:** `user`, `user:apps` + +**Response `200`:** +```json +{ + "id": 1, + "name": "My App", + "homepage_url": "https://myapp.com", + "description": "A great app", + "client_id": "...", + "secret": "..." +} +``` + +**Errors:** +- `404 ResourceNotFound` — app not found + +--- + +### `POST /user/apps` + +Create a new API application. Generates a `client_id` and `secret` automatically. + +**Scopes required:** `user`, `user:apps` + +**Request body:** +```json +{ + "name": "My App", + "homepage_url": "https://myapp.com", + "description": "A great app" +} +``` + +| Field | Required | Validation | +|-------|----------|------------| +| `name` | Yes | Must pass `App::isValidName()`, unique per user | +| `homepage_url` | No | Valid URL | +| `description` | No | Must pass `App::isValidDescription()` | + +**Errors:** +- `400 InvalidField` — invalid name/url/description +- `400 UnavailableName` — app name already taken + +--- + +### `PUT /user/apps/:id` + +Update an existing app. + +**Scopes required:** `user`, `user:apps` + +**Request body:** same optional fields as `POST /user/apps` + +**Response `200`:** updated app object + +**Errors:** +- `404 ResourceNotFound` — app not found + +--- + +### `DELETE /user/apps/:id` + +Delete an API application. + +**Scopes required:** `user`, `user:apps` + +**Response `200`:** empty body + +**Errors:** +- `404 ResourceNotFound` — app not found + +--- + +## Plugins + +### `GET /plugin` + +List all active plugins, paginated. Sorted by default ordering. + +**Scopes required:** `plugins` + +**Response `206/200`:** paginated array of plugin objects with authors, versions, descriptions + +--- + +### `POST /plugin` + +Submit a new plugin for review. + +**Scopes required:** `plugin:submit` + +**Request body:** +```json +{ + "plugin_url": "https://example.com/plugin.xml", + "recaptcha_response": "" +} +``` + +The XML at `plugin_url` is fetched and validated. The plugin `key` must be unique. + +**Response `200`:** +```json +{ "success": true } +``` + +**Errors:** +- `400 InvalidRecaptcha` — reCAPTCHA failed +- `400 InvalidField` — missing or invalid plugin_url +- `400 UnavailableName` — XML URL or plugin key already exists +- `400 InvalidXML` — XML unreachable or invalid + +--- + +### `GET /plugin/new` + +Most recently added plugins, paginated. + +**Scopes required:** `plugins` + +**Response `206/200`:** paginated array of plugin objects + +--- + +### `GET /plugin/popular` + +Most downloaded plugins, paginated. + +**Scopes required:** `plugins` + +--- + +### `GET /plugin/trending` + +Trending plugins (most downloaded in the last 2 weeks), paginated. + +**Scopes required:** `plugins` + +--- + +### `GET /plugin/updated` + +Most recently updated plugins, paginated. + +**Scopes required:** `plugins` + +--- + +### `GET /plugin/rss_new` + +RSS feed of the 30 newest plugins. + +**No auth required.** Returns RSS XML. + +--- + +### `GET /plugin/rss_updated` + +RSS feed of the 30 most recently updated plugins. + +**No auth required.** Returns RSS XML. + +--- + +### `POST /plugin/star` + +Rate a plugin. + +**Scopes required:** `plugin:star` + +**Request body:** +```json +{ + "plugin_id": 42, + "note": 4 +} +``` + +**Response `200`:** +```json +{ "new_average": 4.2 } +``` + +**Errors:** +- `400` — missing or non-numeric `plugin_id` or `note` +- `400` — plugin does not exist + +--- + +### `GET /plugin/:key` + +Get full details for a single active plugin. + +**Scopes required:** `plugin:card` + +**Path parameter:** `key` — unique plugin key (e.g. `fields`) + +**Response `200`:** +```json +{ + "id": 1, + "key": "fields", + "name": "Additional Fields", + "logo_url": "...", + "xml_url": "...", + "download_count": 15000, + "note": 4.5, + "nb_votes": 120, + "watched": false, + "descriptions": [...], + "authors": [...], + "versions": [...], + "screenshots": [...], + "tags": [...], + "langs": [...] +} +``` + +**Errors:** +- `404 ResourceNotFound` — plugin not found or inactive + +--- + +### `GET /plugin/:key/download` + +Track a download and redirect to the plugin's download URL (HTTP 301). + +**No auth required.** + +When `Accept: application/json` header is set, tracking happens but no redirect is issued. + +--- + +### `GET /plugin/:key/permissions` + +List users who have permissions on the plugin. + +**Scopes required:** `user`, `plugin:card` + +**Auth requirement:** caller must have `admin` flag on the plugin. + +**Response `200`:** array of user/permission objects + +--- + +### `POST /plugin/:key/permissions` + +Grant a user access to the plugin. + +**Scopes required:** `user`, `plugin:card` + +**Auth requirement:** caller must have `admin` flag on the plugin. + +**Request body:** +```json +{ "username": "jane" } +``` + +**Errors:** +- `400 InvalidField` — missing username +- `404 ResourceNotFound` — user not found +- `400 RightAlreadyExist` — user already has a permission entry + +--- + +### `DELETE /plugin/:key/permissions/:username` + +Remove a user's permission on the plugin. + +**Scopes required:** `user`, `plugin:card` + +**Auth requirement:** caller must be `admin`, unless removing their own (non-admin) permission. + +**Errors:** +- `400 RightDoesntExist` — user has no permission on this plugin +- `401 CannotDeleteAdmin` — cannot remove an admin's permission + +--- + +### `PATCH /plugin/:key/permissions/:username` + +Modify a specific permission flag for a user on a plugin. + +**Scopes required:** `user`, `plugin:card` + +**Auth requirement:** caller must have `admin` flag on the plugin. + +**Request body:** +```json +{ + "right": "allowed_refresh_xml", + "set": true +} +``` + +| `right` values | +|----------------| +| `allowed_refresh_xml` | +| `allowed_change_xml_url` | +| `allowed_notifications` | + +**Errors:** +- `400 InvalidField` — invalid `right` or `set` value + +--- + +### `POST /plugin/:key/refresh_xml` + +Re-fetch and update the plugin data from its XML URL. + +**Scopes required:** `user`, `plugin:card` + +**Auth requirement:** caller must have `admin` or `allowed_refresh_xml` flag. + +**Response `200`:** +```json +{ + "errors": [], + "xml_state": { ... } +} +``` + +**Errors in response body (not HTTP errors):** +- XML URL unreachable +- XML parse error +- XML field validation errors (collected in array) + +--- + +## Panel (Author Mode) + +### `GET /panel/plugin/:key` + +Author dashboard view of a plugin. Includes tags and stub statistics. + +**Scopes required:** `plugin:card`, `user` + +**Auth requirement:** caller must have `admin`, `allowed_refresh_xml`, or `allowed_change_xml_url` flag. + +**Response `200`:** +```json +{ + "card": { ...plugin object... }, + "tags": [...], + "statistics": { + "current_monthly_downloads": 500, + "current_weekly_downloads": 250 + } +} +``` + +--- + +### `POST /panel/plugin/:key` + +Update the plugin's XML URL. + +**Scopes required:** `user`, `plugin:card` + +**Auth requirement:** caller must have `admin` or `allowed_change_xml_url` flag. + +**Request body:** +```json +{ "xml_url": "https://example.com/new-plugin.xml" } +``` + +The new URL is validated: +1. Must be a valid URL +2. Must be fetchable over HTTP +3. XML must be valid and parseable +4. Plugin `key` in XML must match the existing key +5. All existing authors must still be present in the new XML + +**Response `200`:** empty body + +--- + +## Authors + +### `GET /author` + +List all authors who have contributed to at least one plugin, paginated. + +**Scopes required:** `authors` + +--- + +### `GET /author/top` + +List top authors (all, including non-contributors), paginated. + +**Scopes required:** `authors` + +--- + +### `GET /author/:id` + +Get details for a specific author. Includes gravatar hash if linked to a user account. + +**Scopes required:** `author` + +**Path parameter:** `id` — author ID (integer) + +**Response `200`:** +```json +{ + "id": 1, + "name": "John Doe", + "plugin_count": 5, + "username": "john", + "gravatar": "" +} +``` + +--- + +### `GET /author/:id/plugin` + +List plugins by a specific author, paginated. + +**Scopes required:** `author`, `plugins` + +--- + +### `POST /claimauthorship` + +Send a request to admins to claim authorship of a plugin author record. + +**Scopes required:** `user` + +**Request body:** +```json +{ + "author": "John Doe", + "recaptcha_response": "" +} +``` + +**Response `200`:** empty body (email sent to admins) + +**Errors:** +- `400 InvalidRecaptcha` — reCAPTCHA failed +- `400 InvalidField` — missing/invalid author name +- `404 ResourceNotFound` — author name not found + +--- + +## Tags + +### `GET /tags` + +List all tags ordered by usage count, paginated. Automatically falls back to `en` if no tags in requested language. + +**Scopes required:** `tags` + +**Response `206/200`:** paginated array of tag objects + +--- + +### `GET /tags/top` + +Same as `GET /tags`. Returns top tags by plugin count. + +**Scopes required:** `tags` + +--- + +### `GET /tags/:id` + +Get a single tag by key. + +**Scopes required:** `tag` + +**Path parameter:** `id` — tag key (string, e.g. `inventory`) + +**Response `200`:** +```json +{ "id": 1, "key": "inventory", "tag": "Inventory", "plugin_count": 12 } +``` + +**Errors:** +- `404 ResourceNotFound` — tag key not found + +--- + +### `GET /tags/:id/plugin` + +List active plugins associated with a tag, paginated. + +**Scopes required:** `tag`, `plugins` + +--- + +## Search + +### `POST /search` + +Full-text search across plugin names, keys, and descriptions. + +**Scopes required:** `plugins:search` + +**Request body:** +```json +{ "query_string": "inventory" } +``` + +Minimum length: **2 characters**. + +Results are ordered by `download_count DESC`, `note DESC`, `name ASC`. + +**Response `206/200`:** paginated array of plugin objects + +**Errors:** +- `400` — query_string missing or too short + +--- + +## Versions + +### `GET /version/:version/plugin` + +List plugins compatible with a given GLPI version, paginated. + +**Scopes required:** `version`, `plugins` + +**Path parameter:** `version` — GLPI version string (e.g. `9.5`, `10.0`) + +**Response `206/200`:** paginated array of plugin objects + +--- + +## Messages + +### `POST /message` + +Send a contact message to the site administrators. Saved to DB and emailed. + +**Scopes required:** `message` + +**Request body:** +```json +{ + "recaptcha_response": "", + "contact": { + "firstname": "John", + "lastname": "Doe", + "email": "john@example.com", + "subject": "Question about a plugin", + "message": "Hello, I have a question..." + } +} +``` + +| Field | Max length | +|-------|-----------| +| `firstname` | 45 chars | +| `lastname` | 45 chars | +| `subject` | 280 chars | +| `message` | 16000 chars | + +**Response `200`:** +```json +{ "success": true } +``` + +**Errors:** +- `400 InvalidRecaptcha` — reCAPTCHA failed +- `400 MissingField` — required contact field missing +- `400 InvalidField` — field validation failed + +--- + +## Error Response Format + +All errors return JSON with an HTTP status ≥ 400: + +```json +{ + "error": "RESOURCE_NOT_FOUND(type=Plugin, key=myplugin)" +} +``` + +All errors use a single `error` field containing the string representation of the exception. Extra context (resource type, field name, etc.) is encoded inline in that string. + +Common HTTP status codes used: +- `400 Bad Request` — invalid input +- `401 Unauthorized` — missing or invalid token, or insufficient scopes/permissions +- `404 Not Found` — resource does not exist +- `500 Internal Server Error` — unexpected server error diff --git a/specs/testing/unit.md b/specs/testing/unit.md new file mode 100644 index 0000000..1bb0065 --- /dev/null +++ b/specs/testing/unit.md @@ -0,0 +1,262 @@ +# Unit Test Specifications + +Unit tests cover individual classes in isolation, using mocks for all external dependencies +(database, HTTP, mailer, OAuth server). + +## Stack + +| Tool | Version | Purpose | +|------|---------|---------| +| [PHPUnit](https://phpunit.de/) | ^10.5 | Test runner and assertions | +| [Mockery](https://github.com/mockery/mockery) | ^1.6 | Mocking and stubbing | +| SQLite (in-memory) | — | `PaginatedCollectionTest` only; all other DB calls mocked | + +**Location:** `api/tests/Unit/` +**Bootstrap:** `api/tests/bootstrap.php` (sets `Tool::$config`, defines `MockSlimApp` / `MockSlimRequest` / `MockSlimResponse`) + +--- + +## 1. `API\Core\Tool` + +### 1.1 `getRequestLang()` +- Reads the `x-lang` HTTP header to determine the request language +- Supports only `"en"`, `"fr"`, and `"es"`; any other value falls back to `"en"` +- Returns `"en"` when the `x-lang` header is missing or empty + +### 1.2 `getBody()` +- Returns a `stdClass` for a valid JSON object body +- Returns an `array` for a valid JSON array body +- Returns `null` when body is empty +- Returns `null` when body is malformed JSON + +### 1.3 `makeEndpoint($callable)` +- Calls the wrapped callable and returns its result on success +- Catches `ErrorResponse` subclasses and converts them to JSON responses with the correct HTTP status +- Catches League OAuth2 `AccessDeniedException` and converts to a `401` response (via `API\Exception\AccessDenied`) +- Catches `Exception` (unexpected) and converts to a `500` response without leaking internals + +### 1.4 `randomSha1()` +- Returns a 40-character hexadecimal string +- Returns a different value on each call + +### 1.5 `assertRecaptchaValid()` +- Does not throw when reCAPTCHA verification returns success +- Throws `InvalidRecaptcha` when verification fails + +--- + +## 2. `API\Core\PaginatedCollection` + +### 2.1 Constructor parsing of `x-range` header +- Parses `"0-14"` into `start=0, end=14` +- Parses `"15-29"` into `start=15, end=29` +- Falls back to defaults `(0, page_size-1)` when header is absent +- Falls back to defaults when header format is invalid (e.g. `"abc"`) +- Does not normalize inverted ranges (`start > end`); they are passed through as-is + +### 2.2 `get()` +- Executes a `LIMIT/OFFSET` query derived from the parsed range +- Sets `content-range` response header to `"start-end/total"` +- Sets `accept-range` response header +- Returns HTTP `206` when result is a subset of total +- Returns HTTP `200` when result contains all records +- Returns HTTP `400` when `startIndex >= total` (start points beyond the last row) + +--- + +## 3. `API\Core\ValidableXMLPluginDescription` + +### 3.1 `validate()` — success cases +- Does not throw when XML has all required fields (`key`, `name`, `authors`) +- Parses `` nodes correctly + +### 3.2 `validate()` — failure cases +- Throws `InvalidXML` with reason `"field"` when required field is missing +- Throws `InvalidXML` with reason `"parse"` when XML is syntactically invalid +- In `collectMode`, collects all errors instead of throwing on the first one + +--- + +## 4. `API\Model\User` + +### 4.1 `isValidPassword($password)` +- Returns `true` for a password meeting minimum requirements +- Returns `false` for a password that is too short +- Returns `false` for an empty string + +### 4.2 `isValidRealname($realname)` +- Returns `true` for `"John Doe"` +- Returns `false` for a string shorter than 4 chars +- Returns `false` for a string with special characters + +### 4.3 `isValidWebsite($url)` +- Returns `true` for a valid HTTP URL +- Returns `false` for a plain string without scheme + +### 4.4 `setPassword($password)` / `assertPasswordIs($password)` +- `setPassword` stores a bcrypt hash (not plaintext) +- `assertPasswordIs` returns `true` for the correct password +- `assertPasswordIs` returns `false` for a wrong password + +--- + +## 5. `API\Model\App` + +### 5.1 `isValidName($name)` +- Returns `true` for a valid app name +- Returns `false` for an empty string + +### 5.2 `isValidUrl($url)` +- Returns `true` for a valid HTTPS URL +- Returns `false` for a non-URL string + +### 5.3 `isValidDescription($desc)` +- Returns `true` for a reasonable description string +- Returns `false` for a string exceeding the maximum length + +### 5.4 `setRandomClientId()` / `setRandomSecret()` +- Generates non-empty strings +- Two successive calls produce different values + +--- + +## 6. `API\Model\Author` + +### 6.1 `fixKnownDuplicates($name)` +- Returns an array containing at least the original name +- Returns multiple entries when the name matches a known duplicate mapping + +--- + +## 7. `API\Exception\ErrorResponse` and subclasses + +### 7.1 Base class +- `jsonSerialize()` returns the string representation of the error (same as `getRepresentation()`) +- HTTP status is exposed via the public `$httpStatusCode` property (integer ≥ 400) +- `childOf(Exception $e)` stores the parent exception and returns `$this` (fluent) + +### 7.2 Specific exceptions +- `ResourceNotFound` — HTTP 404, serializes `resource` and `key` +- `InvalidField` — HTTP 400, serializes `field` +- `InvalidCredentials` — HTTP 401 +- `AccessDenied` — HTTP 401 +- `LackPermission` — HTTP 401 +- `UnavailableName` — HTTP 400, serializes `type` and `name` +- `InvalidXML` — HTTP 400, serializes `reason` +- `InvalidRecaptcha` — HTTP 400 +- `WrongPasswordResetToken` — HTTP 400 +- `NoCredentialsLeft` — HTTP 401 +- `AlreadyWatched` — HTTP 400 +- `RightAlreadyExist` — HTTP 400 +- `RightDoesntExist` — HTTP 400 +- `CannotDeleteAdmin` — HTTP 401 + +--- + +## 8. `API\OAuthServer\OAuthHelper` + +### 8.1 `needsScopes(array $scopes)` +- Does not throw when access token has all required scopes +- Throws `AccessDeniedException` when a required scope is missing +- Throws when no access token is present in the request + +### 8.2 `currentlyAuthed()` +- Returns the `User` model associated with the current access token's session +- Throws when the session has no owner + +### 8.3 `createAccessTokenFromUserId($userId, $scopes)` +- Returns array with `token`, `refresh_token`, and `ttl` keys +- Token is persisted to the storage layer (mock verified) +- Grants the specified scopes to the token + +### 8.4 `grantScopesToAccessToken($token, $scopes)` +- Adds the listed scopes to the token without duplicates + +--- + +## Running the Tests + +```bash +cd api +composer install --ignore-platform-reqs # current composer.json still declares php ^7.4 +vendor/bin/phpunit --configuration phpunit.xml +``` + +### `phpunit.xml` + +```xml + + + + + tests/Unit + + + + + src + + + +``` + +### Required extensions + +| Extension | Used by | +|-----------|---------| +| `pdo_mysql` | Production (Eloquent) | +| `pdo_sqlite` | `PaginatedCollectionTest` in-memory DB | +| `sqlite3` | Same | +| `dom`, `simplexml`, `libxml` | `ValidableXMLPluginDescriptionTest` | +| `mbstring`, `json`, `curl` | Core library | + +--- + +## CI — GitHub Actions + +The workflow lives at `.github/workflows/tests_phpunit.yml` and runs the unit suite on +every push and pull request against `master`, across PHP 8.1 – 8.4. + +```yaml +name: Tests + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + unit: + name: Unit tests (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + + strategy: + matrix: + php: [ "8.1", "8.2", "8.3", "8.4" ] + + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP ${{ matrix.php }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: pdo_mysql, pdo_sqlite, sqlite3, dom, mbstring, json, simplexml, libxml, curl + coverage: none + + - name: Install dependencies + working-directory: api + run: composer install --no-interaction --prefer-dist --ignore-platform-reqs + + - name: Run unit tests + working-directory: api + run: vendor/bin/phpunit --configuration phpunit.xml +``` + +> `--ignore-platform-reqs` is required until `composer.json` is updated to +> declare `"php": "^8.1"` (planned as part of the PHP upgrade step). From 2f3a1e2a8004150ffacfb9774bc3032f793e3069 Mon Sep 17 00:00:00 2001 From: Alexandre Delaunay Date: Wed, 25 Mar 2026 12:44:36 +0100 Subject: [PATCH 3/4] add functional tests --- .docker/test/Dockerfile | 13 + .docker/test/api/Dockerfile | 11 + .docker/test/api/apache.conf | 15 + .github/workflows/tests_phpunit.yml | 26 +- api/src/core/Tool.php | 15 +- api/src/endpoints/App.php | 2 +- api/src/endpoints/Author.php | 1 + api/src/endpoints/Message.php | 4 +- api/src/endpoints/Plugin.php | 4 +- api/src/endpoints/User.php | 4 +- api/src/endpoints/Version.php | 18 +- api/src/oauthserver/AuthorizationServer.php | 3 + api/tests/Functional/Auth/OAuthTest.php | 226 +++++++++++ api/tests/Functional/Author/AuthorTest.php | 130 ++++++ api/tests/Functional/FunctionalTestCase.php | 297 ++++++++++++++ api/tests/Functional/MessageTest.php | 78 ++++ api/tests/Functional/Plugin/DownloadTest.php | 95 +++++ api/tests/Functional/Plugin/ListingTest.php | 210 ++++++++++ api/tests/Functional/Plugin/PanelTest.php | 100 +++++ .../Functional/Plugin/PermissionsTest.php | 237 +++++++++++ api/tests/Functional/Plugin/StarTest.php | 87 ++++ api/tests/Functional/Plugin/SubmitTest.php | 89 ++++ api/tests/Functional/SearchTest.php | 101 +++++ api/tests/Functional/Tags/TagsTest.php | 107 +++++ api/tests/Functional/User/AppsTest.php | 187 +++++++++ .../Functional/User/EmailValidationTest.php | 77 ++++ .../Functional/User/PasswordResetTest.php | 113 ++++++ api/tests/Functional/User/ProfileTest.php | 138 +++++++ .../Functional/User/RegistrationTest.php | 149 +++++++ api/tests/Functional/User/UserSearchTest.php | 90 +++++ api/tests/Functional/User/WatchesTest.php | 150 +++++++ api/tests/Functional/VersionTest.php | 75 ++++ api/tests/Functional/config.functional.php | 39 ++ api/tests/Functional/schema.sql | 246 +++++++++++ api/tests/Functional/seeds.sql | 23 ++ docker-compose.test.yml | 61 +++ specs/README.md | 47 +++ specs/testing/functional.md | 381 ++++++++++++++++++ 38 files changed, 3625 insertions(+), 24 deletions(-) create mode 100644 .docker/test/Dockerfile create mode 100644 .docker/test/api/Dockerfile create mode 100644 .docker/test/api/apache.conf create mode 100644 api/tests/Functional/Auth/OAuthTest.php create mode 100644 api/tests/Functional/Author/AuthorTest.php create mode 100644 api/tests/Functional/FunctionalTestCase.php create mode 100644 api/tests/Functional/MessageTest.php create mode 100644 api/tests/Functional/Plugin/DownloadTest.php create mode 100644 api/tests/Functional/Plugin/ListingTest.php create mode 100644 api/tests/Functional/Plugin/PanelTest.php create mode 100644 api/tests/Functional/Plugin/PermissionsTest.php create mode 100644 api/tests/Functional/Plugin/StarTest.php create mode 100644 api/tests/Functional/Plugin/SubmitTest.php create mode 100644 api/tests/Functional/SearchTest.php create mode 100644 api/tests/Functional/Tags/TagsTest.php create mode 100644 api/tests/Functional/User/AppsTest.php create mode 100644 api/tests/Functional/User/EmailValidationTest.php create mode 100644 api/tests/Functional/User/PasswordResetTest.php create mode 100644 api/tests/Functional/User/ProfileTest.php create mode 100644 api/tests/Functional/User/RegistrationTest.php create mode 100644 api/tests/Functional/User/UserSearchTest.php create mode 100644 api/tests/Functional/User/WatchesTest.php create mode 100644 api/tests/Functional/VersionTest.php create mode 100644 api/tests/Functional/config.functional.php create mode 100644 api/tests/Functional/schema.sql create mode 100644 api/tests/Functional/seeds.sql create mode 100644 docker-compose.test.yml create mode 100644 specs/README.md create mode 100644 specs/testing/functional.md diff --git a/.docker/test/Dockerfile b/.docker/test/Dockerfile new file mode 100644 index 0000000..01ddc5c --- /dev/null +++ b/.docker/test/Dockerfile @@ -0,0 +1,13 @@ +FROM php:7.4-cli-bullseye + +RUN apt-get update -q \ + && apt-get install -y -q --no-install-recommends \ + git unzip libssl-dev libcurl4-openssl-dev libzip-dev \ + && docker-php-ext-install pdo pdo_mysql \ + && docker-php-ext-configure curl \ + && docker-php-ext-install curl \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +WORKDIR /app diff --git a/.docker/test/api/Dockerfile b/.docker/test/api/Dockerfile new file mode 100644 index 0000000..041773d --- /dev/null +++ b/.docker/test/api/Dockerfile @@ -0,0 +1,11 @@ +FROM php:7.4-apache-bullseye + +RUN apt-get update -q \ + && apt-get install -y -q --no-install-recommends \ + libssl-dev libcurl4-openssl-dev wget \ + && docker-php-ext-configure curl \ + && docker-php-ext-install pdo pdo_mysql curl \ + && a2enmod headers setenvif \ + && rm -rf /var/lib/apt/lists/* + +COPY .docker/test/api/apache.conf /etc/apache2/sites-available/000-default.conf diff --git a/.docker/test/api/apache.conf b/.docker/test/api/apache.conf new file mode 100644 index 0000000..f4f4fa7 --- /dev/null +++ b/.docker/test/api/apache.conf @@ -0,0 +1,15 @@ + + DocumentRoot /var/www/api + + # Pass the Authorization header through to PHP (same as production VirtualHost) + SetEnvIf Authorization "(.+)" HTTP_AUTHORIZATION=$1 + + + Options FollowSymLinks + AllowOverride None + Require all granted + # FallbackResource serves index.php for any URI that doesn't map to a file, + # while preserving REQUEST_URI — so Slim 2 routing works correctly. + FallbackResource /index.php + + diff --git a/.github/workflows/tests_phpunit.yml b/.github/workflows/tests_phpunit.yml index 6a49e21..8f1c62b 100644 --- a/.github/workflows/tests_phpunit.yml +++ b/.github/workflows/tests_phpunit.yml @@ -7,6 +7,9 @@ on: branches: [ master ] jobs: + # --------------------------------------------------------------------------- + # Unit tests — no database required + # --------------------------------------------------------------------------- unit: name: Unit tests (PHP ${{ matrix.php }}) runs-on: ubuntu-latest @@ -31,4 +34,25 @@ jobs: - name: Run unit tests working-directory: api - run: vendor/bin/phpunit --configuration phpunit.xml + run: vendor/bin/phpunit --configuration phpunit.xml --testsuite Unit + + # --------------------------------------------------------------------------- + # Functional tests — Apache/PHP 7.4 + MySQL 8.0 via Docker Compose + # --------------------------------------------------------------------------- + functional: + name: Functional tests + runs-on: ubuntu-latest + needs: unit + + steps: + - uses: actions/checkout@v4 + + - name: Build test images + run: docker compose -f docker-compose.test.yml build + + - name: Run functional tests + run: docker compose -f docker-compose.test.yml run --rm phpunit + + - name: Tear down + if: always() + run: docker compose -f docker-compose.test.yml down -v diff --git a/api/src/core/Tool.php b/api/src/core/Tool.php index 66d8032..4e1fb0d 100644 --- a/api/src/core/Tool.php +++ b/api/src/core/Tool.php @@ -46,7 +46,13 @@ public static function endWithJson($_payload, $code = 200) { $payload = self::getPayload($_payload); $app->response->headers->set('Content-Type', 'application/json'); - $app->halt($code, json_encode($payload)); + // If the payload is a PaginatedCollection, getPayload() already called + // setStatus() on $app->response (e.g. 206 for partial range, 400 for + // out-of-range start). Respect that status instead of always using 200. + $effectiveCode = ($_payload instanceof \API\Core\PaginatedCollection) + ? $app->response->getStatus() + : $code; + $app->halt($effectiveCode, json_encode($payload)); } /** @@ -105,7 +111,7 @@ public static function endWithRSS($_payload, $feed_title = '', $code = 200) { break; } } - if (empty($description)) { + if (empty($description) && count($plugin['descriptions']) > 0) { $description = $plugin['descriptions'][0]['long_description']; } @@ -160,7 +166,7 @@ public static function makeEndpoint($callable) { try { call_user_func_array($callable, $args); } - catch (\Exception $e) { + catch (\Throwable $e) { global $app; if (!preg_match('/^API\\\\Exception/', get_class($e))) { switch (get_class($e)) { @@ -316,7 +322,8 @@ public static function getRequestLang() { public static $config = null; public static function getConfig() { if (!self::$config) { - require dirname(__FILE__) . '/../../config.php'; + $configFile = getenv('APP_CONFIG_FILE') ?: dirname(__FILE__) . '/../../config.php'; + require $configFile; self::$config = $config; } return self::$config; diff --git a/api/src/endpoints/App.php b/api/src/endpoints/App.php index af7711e..3db3b1d 100644 --- a/api/src/endpoints/App.php +++ b/api/src/endpoints/App.php @@ -68,7 +68,7 @@ throw new InvalidField('name'); } else if (App::where('user_id', '=', $user_id) ->where('name', '=', $body->name)->first() != null) { - throw new UnavailableName('app', $name); + throw new UnavailableName('app', $body->name); } else { $app->name = $body->name; diff --git a/api/src/endpoints/Author.php b/api/src/endpoints/Author.php index d7f25fd..1001f9c 100644 --- a/api/src/endpoints/Author.php +++ b/api/src/endpoints/Author.php @@ -74,6 +74,7 @@ ->withAverageNote() ->descWithLang(Tool::getRequestLang()) ->whereAuthor($author->id) + ->where('active', '=', 1) ) ); }); diff --git a/api/src/endpoints/Message.php b/api/src/endpoints/Message.php index 45123b8..c00b29f 100644 --- a/api/src/endpoints/Message.php +++ b/api/src/endpoints/Message.php @@ -22,8 +22,6 @@ use \API\Exception\MissingField; use \API\Exception\InvalidRecaptcha; -require dirname(__FILE__) . '/../../config.php'; - $send = Tool::makeEndpoint(function() use($app) { OAuthHelper::needsScopes(['message']); @@ -32,7 +30,7 @@ $fields = ['firstname', 'lastname', 'email', 'subject', 'message']; $recaptcha = new ReCaptcha(Tool::getConfig()['recaptcha_secret']); - $resp = $recaptcha->verify($body->recaptcha_response); + $resp = $recaptcha->verify($body->recaptcha_response ?? null); if (!$resp->isSuccess()) { throw new InvalidRecaptcha(); } diff --git a/api/src/endpoints/Plugin.php b/api/src/endpoints/Plugin.php index 58c86c2..e3aa716 100644 --- a/api/src/endpoints/Plugin.php +++ b/api/src/endpoints/Plugin.php @@ -146,7 +146,7 @@ if (isset($body->xml_url)) { // We check if the URL is a correct URI if (!filter_var($body->xml_url, FILTER_VALIDATE_URL)) { - throw new InvalidField; + throw new InvalidField('xml_url'); } // We check if we can fetch the file via HTTP @@ -530,7 +530,7 @@ $body = Tool::getBody(); $recaptcha = new ReCaptcha(Tool::getConfig()['recaptcha_secret']); - $resp = $recaptcha->verify($body->recaptcha_response); + $resp = $recaptcha->verify($body->recaptcha_response ?? null); if (!$resp->isSuccess()) { throw new InvalidRecaptcha; } diff --git a/api/src/endpoints/User.php b/api/src/endpoints/User.php index 2ea6cce..10c9cb9 100644 --- a/api/src/endpoints/User.php +++ b/api/src/endpoints/User.php @@ -34,6 +34,8 @@ use API\Exception\InvalidCredentials; use API\Exception\InvalidXML; use API\Exception\WrongPasswordResetToken; +use API\Exception\AccountNotFound; +use API\Exception\ResourceNotFound; use League\OAuth2\Server\Util\SecureKey; use API\OAuthServer\AuthorizationServer; @@ -471,7 +473,7 @@ $user->setPassword($body->password); $user->save(); // Deleting the ResetPasswordToken objects for this user - $user->passwordResetTokens()->truncate(); + $user->passwordResetTokens()->delete(); $app->halt(200); }); diff --git a/api/src/endpoints/Version.php b/api/src/endpoints/Version.php index aba840a..16aa157 100644 --- a/api/src/endpoints/Version.php +++ b/api/src/endpoints/Version.php @@ -1,21 +1,15 @@ with('authors', 'versions', 'descriptions') - ->withAverageNote() - ->descWithLang(Tool::getRequestLang()) - ->withGlpiVersion($version)); + ->with('authors', 'versions', 'descriptions') + ->withAverageNote() + ->descWithLang(Tool::getRequestLang()) + ->withGlpiVersion($version)); Tool::endWithJson($plugins); }); - -$app->get('/version/:version/plugin', $version_plugins); -$app->options('/version/:version/plugin', function() {}); +$app->get("/version/:version/plugin", $version_plugins); +$app->options("/version/:version/plugin", function() {}); diff --git a/api/src/oauthserver/AuthorizationServer.php b/api/src/oauthserver/AuthorizationServer.php index 60ce03c..a9367b3 100644 --- a/api/src/oauthserver/AuthorizationServer.php +++ b/api/src/oauthserver/AuthorizationServer.php @@ -53,6 +53,9 @@ public function __construct() { return false; } else { $user = $user->first(); + if (!$user->active) { + return false; + } if ($user->assertPasswordIs($password)) { return $user->id; } else { diff --git a/api/tests/Functional/Auth/OAuthTest.php b/api/tests/Functional/Auth/OAuthTest.php new file mode 100644 index 0000000..d46dea1 --- /dev/null +++ b/api/tests/Functional/Auth/OAuthTest.php @@ -0,0 +1,226 @@ +db(); + $pdo->exec('SET FOREIGN_KEY_CHECKS = 0'); + foreach ([ + 'user', 'sessions', 'access_tokens', 'access_tokens_scopes', + 'refresh_tokens', 'sessions_scopes', + 'user_validation_token', 'user_resetpassword_token', + ] as $table) { + $pdo->exec("TRUNCATE TABLE `{$table}`"); + } + $pdo->exec('SET FOREIGN_KEY_CHECKS = 1'); + } + + // ------------------------------------------------------------------------- + // Password grant — success + // ------------------------------------------------------------------------- + + public function testPasswordGrantReturns200WithTokens(): void + { + $this->createUser(); + + $response = self::$http->post('/oauth/authorize', [ + 'form_params' => [ + 'grant_type' => 'password', + 'client_id' => 'webapp', + 'username' => 'testuser', + 'password' => 'Password1', + 'scope' => 'plugins user', + ], + ]); + + $this->assertSame(200, $response->getStatusCode()); + + $body = json_decode((string) $response->getBody(), true); + $this->assertArrayHasKey('access_token', $body); + $this->assertArrayHasKey('refresh_token', $body); + $this->assertNotEmpty($body['access_token']); + $this->assertNotEmpty($body['refresh_token']); + } + + public function testPasswordGrantTokenIsPersistedInDatabase(): void + { + $this->createUser(); + $token = $this->getAccessToken('testuser', 'Password1'); + + $this->assertNotNull($token, 'getAccessToken() must succeed'); + + $stmt = $this->db()->prepare('SELECT id FROM access_tokens WHERE token = ?'); + $stmt->execute([$token]); + $this->assertNotFalse($stmt->fetch(), 'Issued token must be stored in access_tokens'); + } + + public function testPasswordGrantCanLoginByEmail(): void + { + $this->createUser(['username' => 'byemail', 'email' => 'byemail@example.com']); + + $response = self::$http->post('/oauth/authorize', [ + 'form_params' => [ + 'grant_type' => 'password', + 'client_id' => 'webapp', + 'username' => 'byemail@example.com', // use email as login + 'password' => 'Password1', + 'scope' => 'plugins', + ], + ]); + + $this->assertSame(200, $response->getStatusCode()); + } + + // ------------------------------------------------------------------------- + // Password grant — failures + // ------------------------------------------------------------------------- + + public function testWrongPasswordReturnsErrorStatus(): void + { + $this->createUser(); + + $response = self::$http->post('/oauth/authorize', [ + 'form_params' => [ + 'grant_type' => 'password', + 'client_id' => 'webapp', + 'username' => 'testuser', + 'password' => 'WrongPassword', + 'scope' => 'plugins', + ], + ]); + + $this->assertGreaterThanOrEqual(400, $response->getStatusCode()); + } + + public function testInactiveAccountReturnsErrorStatus(): void + { + $this->createUser(['active' => 0]); + + $response = self::$http->post('/oauth/authorize', [ + 'form_params' => [ + 'grant_type' => 'password', + 'client_id' => 'webapp', + 'username' => 'testuser', + 'password' => 'Password1', + 'scope' => 'plugins', + ], + ]); + + $this->assertGreaterThanOrEqual(400, $response->getStatusCode()); + } + + public function testUnknownUsernameReturnsErrorStatus(): void + { + $response = self::$http->post('/oauth/authorize', [ + 'form_params' => [ + 'grant_type' => 'password', + 'client_id' => 'webapp', + 'username' => 'nobody', + 'password' => 'Password1', + 'scope' => 'plugins', + ], + ]); + + $this->assertGreaterThanOrEqual(400, $response->getStatusCode()); + } + + // ------------------------------------------------------------------------- + // Refresh token grant + // ------------------------------------------------------------------------- + + public function testRefreshTokenGrantIssuesNewTokens(): void + { + $this->createUser(); + + $first = json_decode((string) self::$http->post('/oauth/authorize', [ + 'form_params' => [ + 'grant_type' => 'password', + 'client_id' => 'webapp', + 'username' => 'testuser', + 'password' => 'Password1', + 'scope' => 'plugins', + ], + ])->getBody(), true); + + $response = self::$http->post('/oauth/authorize', [ + 'form_params' => [ + 'grant_type' => 'refresh_token', + 'client_id' => 'webapp', + 'refresh_token' => $first['refresh_token'], + ], + ]); + + $this->assertSame(200, $response->getStatusCode()); + $second = json_decode((string) $response->getBody(), true); + $this->assertArrayHasKey('access_token', $second); + $this->assertNotSame($first['access_token'], $second['access_token']); + } + + public function testInvalidRefreshTokenReturnsErrorStatus(): void + { + $response = self::$http->post('/oauth/authorize', [ + 'form_params' => [ + 'grant_type' => 'refresh_token', + 'client_id' => 'webapp', + 'refresh_token' => 'completely_invalid_token', + ], + ]); + + $this->assertGreaterThanOrEqual(400, $response->getStatusCode()); + } + + // ------------------------------------------------------------------------- + // Client credentials grant + // ------------------------------------------------------------------------- + + public function testClientCredentialsGrantReturnsToken(): void + { + $this->db()->exec( + "INSERT INTO `apps` (`id`, `name`, `secret`) VALUES ('testapp0000000000001', 'Test App', 'appsecret')" + ); + + $response = self::$http->post('/oauth/authorize', [ + 'form_params' => [ + 'grant_type' => 'client_credentials', + 'client_id' => 'testapp0000000000001', + 'client_secret' => 'appsecret', + 'scope' => 'plugins', + ], + ]); + + $this->assertSame(200, $response->getStatusCode()); + $body = json_decode((string) $response->getBody(), true); + $this->assertArrayHasKey('access_token', $body); + } + + public function testClientCredentialsWithUnknownClientReturnsError(): void + { + $response = self::$http->post('/oauth/authorize', [ + 'form_params' => [ + 'grant_type' => 'client_credentials', + 'client_id' => 'does_not_exist', + 'client_secret' => 'wrong', + 'scope' => 'plugins', + ], + ]); + + $this->assertGreaterThanOrEqual(400, $response->getStatusCode()); + } +} diff --git a/api/tests/Functional/Author/AuthorTest.php b/api/tests/Functional/Author/AuthorTest.php new file mode 100644 index 0000000..0d82f7b --- /dev/null +++ b/api/tests/Functional/Author/AuthorTest.php @@ -0,0 +1,130 @@ +db(); + $pdo->exec('SET FOREIGN_KEY_CHECKS = 0'); + foreach ([ + 'author', 'plugin', 'plugin_author', 'plugin_description', 'plugin_stars', + 'user', 'sessions', 'access_tokens', 'access_tokens_scopes', + 'refresh_tokens', 'sessions_scopes', + ] as $t) { + $pdo->exec("TRUNCATE TABLE `{$t}`"); + } + $pdo->exec('SET FOREIGN_KEY_CHECKS = 1'); + } + + private function authedToken(array $scopes): string + { + $user = $this->createUser(); + return $this->getAccessToken($user['username'], $user['plain_password'], $scopes); + } + + // ------------------------------------------------------------------------- + // GET /author (§12.1) — only authors with plugins + // ------------------------------------------------------------------------- + + public function testListAuthorsReturns200(): void + { + $token = $this->authedToken(['authors']); + + $response = self::$http->get('/author', ['headers' => $this->bearer($token)]); + + $this->assertSame(200, $response->getStatusCode()); + } + + public function testListAuthorsExcludesAuthorsWithNoPlugins(): void + { + $this->createAuthor('NoPluginsAuthor'); + $token = $this->authedToken(['authors']); + + $response = self::$http->get('/author', ['headers' => $this->bearer($token)]); + + $body = json_decode((string) $response->getBody(), true); + $names = array_column($body, 'name'); + $this->assertNotContains('NoPluginsAuthor', $names); + } + + // ------------------------------------------------------------------------- + // GET /author/:id (§12.2) + // ------------------------------------------------------------------------- + + public function testGetSingleAuthorReturns200(): void + { + $author = $this->createAuthor('Solo Author'); + $token = $this->authedToken(['author']); + + $response = self::$http->get("/author/{$author['id']}", ['headers' => $this->bearer($token)]); + + $this->assertSame(200, $response->getStatusCode()); + $body = json_decode((string) $response->getBody(), true); + $this->assertSame('Solo Author', $body['name']); + } + + public function testGetAuthorLinkedToUserIncludesUsernameAndGravatar(): void + { + $user = $this->createUser(['username' => 'linkeduser', 'email' => 'linked@example.com']); + $author = $this->createAuthor('Linked Author'); + // link user to author + $this->db()->prepare("UPDATE `user` SET author_id = ? WHERE id = ?")->execute([$author['id'], $user['id']]); + $this->db()->prepare("UPDATE `author` SET username = ? WHERE id = ?")->execute([$user['username'], $author['id']]); + $token = $this->authedToken(['author']); + + $response = self::$http->get("/author/{$author['id']}", ['headers' => $this->bearer($token)]); + + $body = json_decode((string) $response->getBody(), true); + $this->assertArrayHasKey('username', $body); + $this->assertArrayHasKey('gravatar', $body); + } + + public function testGetUnknownAuthorReturns404(): void + { + $token = $this->authedToken(['author']); + + $response = self::$http->get('/author/99999', ['headers' => $this->bearer($token)]); + + $this->assertSame(404, $response->getStatusCode()); + } + + // ------------------------------------------------------------------------- + // GET /author/:id/plugin (§12.3) + // ------------------------------------------------------------------------- + + public function testGetAuthorPluginsReturnsOnlyActivePlugins(): void + { + $author = $this->createAuthor('Plugin Author'); + $activePlugin = $this->createPlugin(['key' => 'authoractive', 'name' => 'Author Active', 'active' => 1]); + $inactivePlugin = $this->createPlugin(['key' => 'authorinactive', 'name' => 'Author Inactive', 'active' => 0]); + + $this->db()->prepare("INSERT INTO `plugin_author` (`plugin_id`, `author_id`) VALUES (?, ?)") + ->execute([$activePlugin['id'], $author['id']]); + $this->db()->prepare("INSERT INTO `plugin_author` (`plugin_id`, `author_id`) VALUES (?, ?)") + ->execute([$inactivePlugin['id'], $author['id']]); + + $token = $this->authedToken(['author', 'plugins']); + + $response = self::$http->get("/author/{$author['id']}/plugin", ['headers' => $this->bearer($token)]); + + $this->assertSame(200, $response->getStatusCode()); + $body = json_decode((string) $response->getBody(), true); + $keys = array_column($body, 'key'); + $this->assertContains('authoractive', $keys); + $this->assertNotContains('authorinactive', $keys); + } +} diff --git a/api/tests/Functional/FunctionalTestCase.php b/api/tests/Functional/FunctionalTestCase.php new file mode 100644 index 0000000..9b2a49d --- /dev/null +++ b/api/tests/Functional/FunctionalTestCase.php @@ -0,0 +1,297 @@ +stop(); + } + }); + } + + // Per-test-class isolation: wipe + re-seed + static::cleanDatabase(); + static::seedDatabase(); + + self::$http = new Client([ + 'base_uri' => self::$apiUrl, + 'http_errors' => false, // let tests assert on 4xx/5xx themselves + 'allow_redirects' => false, // let tests assert on 3xx redirects + ]); + } + + // ------------------------------------------------------------------------- + // Infrastructure + // ------------------------------------------------------------------------- + + private static function configFile(): string + { + return __DIR__ . '/config.functional.php'; + } + + private static function connectDatabase(): void + { + require static::configFile(); + /** @var array $config */ + $db = $config['db_settings']; + + self::$pdo = new \PDO( + "mysql:host={$db['host']};charset=utf8", + $db['username'], + $db['password'], + [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION] + ); + + $name = $db['database']; + self::$pdo->exec( + "CREATE DATABASE IF NOT EXISTS `{$name}` CHARACTER SET utf8 COLLATE utf8_general_ci" + ); + self::$pdo->exec("USE `{$name}`"); + } + + private static function createSchema(): void + { + static::execSqlFile(__DIR__ . '/schema.sql'); + } + + private static function startServer(): void + { + $apiRoot = dirname(__DIR__, 2); + + self::$server = new Process( + ['php', '-S', 'localhost:' . self::PORT, 'index.php'], + $apiRoot, + ['APP_CONFIG_FILE' => static::configFile()] + ); + self::$server->start(); + + // Poll until the port accepts connections (max 3 s) + $deadline = microtime(true) + 3.0; + while (microtime(true) < $deadline) { + $sock = @fsockopen('localhost', self::PORT, $errno, $errstr, 0.1); + if ($sock !== false) { + fclose($sock); + return; + } + usleep(50_000); + } + + throw new \RuntimeException( + "PHP built-in server did not start in time.\nServer output:\n" . + self::$server->getOutput() . self::$server->getErrorOutput() + ); + } + + protected static function seedDatabase(): void + { + static::execSqlFile(__DIR__ . '/seeds.sql'); + } + + protected static function cleanDatabase(): void + { + self::$pdo->exec('SET FOREIGN_KEY_CHECKS = 0'); + foreach (static::truncatableTables() as $table) { + self::$pdo->exec("TRUNCATE TABLE `{$table}`"); + } + self::$pdo->exec('SET FOREIGN_KEY_CHECKS = 1'); + } + + /** + * Tables wiped before every test class. Static reference data (scopes, + * apps) is re-inserted by seedDatabase() right after. + */ + protected static function truncatableTables(): array + { + return [ + 'apps', 'scopes', + 'user', 'sessions', 'access_tokens', 'access_tokens_scopes', + 'refresh_tokens', 'sessions_scopes', + 'user_validation_token', 'user_resetpassword_token', + 'user_external_account', + 'author', 'plugin', 'plugin_author', 'plugin_description', + 'plugin_download', 'plugin_permission', 'plugin_screenshot', + 'plugin_stars', 'plugin_version', 'plugin_xml_fetch_fails', + 'plugin_plugin_lang', 'plugin_tags', 'user_plugin_watch', + 'tag', 'auth_codes', 'message', + ]; + } + + private static function execSqlFile(string $path): void + { + $sql = file_get_contents($path); + foreach (array_filter(array_map('trim', explode(';', $sql))) as $stmt) { + self::$pdo->exec($stmt); + } + } + + // ------------------------------------------------------------------------- + // Test helpers + // ------------------------------------------------------------------------- + + protected function db(): \PDO + { + return self::$pdo; + } + + /** + * Insert a plugin row directly (bypasses XML fetch/validation). + * Returns the inserted data plus `id`. + */ + protected function createPlugin(array $overrides = []): array + { + $defaults = [ + 'name' => 'Test Plugin', + 'key' => 'testplugin', + 'xml_url' => 'http://example.com/plugin.xml', + 'download_url' => 'http://example.com/plugin.zip', + 'active' => 1, + 'download_count' => 0, + 'date_added' => date('Y-m-d H:i:s'), + ]; + $data = array_merge($defaults, $overrides); + + $cols = implode(', ', array_map(static fn ($k) => "`{$k}`", array_keys($data))); + $vals = implode(', ', array_fill(0, count($data), '?')); + self::$pdo->prepare("INSERT INTO `plugin` ({$cols}) VALUES ({$vals})") + ->execute(array_values($data)); + + return array_merge($data, ['id' => (int) self::$pdo->lastInsertId()]); + } + + /** Insert an author row directly. Returns `id` and `name`. */ + protected function createAuthor(string $name): array + { + self::$pdo->prepare("INSERT INTO `author` (`name`) VALUES (?)")->execute([$name]); + return ['id' => (int) self::$pdo->lastInsertId(), 'name' => $name]; + } + + /** Insert a tag row directly. Returns the inserted data plus `id`. */ + protected function createTag(string $key, string $tag, string $lang = 'en'): array + { + self::$pdo->prepare("INSERT INTO `tag` (`key`, `tag`, `lang`) VALUES (?, ?, ?)") + ->execute([$key, $tag, $lang]); + return ['id' => (int) self::$pdo->lastInsertId(), 'key' => $key, 'tag' => $tag, 'lang' => $lang]; + } + + /** Grant a plugin permission to a user directly. */ + protected function grantPermission(int $pluginId, int $userId, bool $admin = false): void + { + self::$pdo->prepare( + "INSERT INTO `plugin_permission` (`plugin_id`, `user_id`, `admin`) VALUES (?, ?, ?)" + )->execute([$pluginId, $userId, $admin ? 1 : 0]); + } + + /** + * Insert a user row directly (bypasses validation and email sending). + * Returns the inserted data plus `id` and `plain_password`. + */ + protected function createUser(array $overrides = []): array + { + $plainPassword = $overrides['plain_password'] ?? 'Password1'; + unset($overrides['plain_password']); + + $defaults = [ + 'username' => 'testuser', + 'email' => 'testuser@example.com', + 'password' => password_hash($plainPassword, PASSWORD_BCRYPT), + 'realname' => 'Test User', + 'active' => 1, + ]; + $data = array_merge($defaults, $overrides); + + $cols = implode(', ', array_map(static fn ($k) => "`{$k}`", array_keys($data))); + $vals = implode(', ', array_fill(0, count($data), '?')); + self::$pdo->prepare("INSERT INTO `user` ({$cols}) VALUES ({$vals})") + ->execute(array_values($data)); + + return array_merge($data, [ + 'id' => (int) self::$pdo->lastInsertId(), + 'plain_password' => $plainPassword, + ]); + } + + /** + * Obtain a Bearer token via the password grant. + * Returns the access_token string, or null if authentication failed. + */ + protected function getAccessToken( + string $username, + string $password, + array $scopes = ['plugins', 'user'] + ): ?string { + $response = self::$http->post('/oauth/authorize', [ + 'form_params' => [ + 'grant_type' => 'password', + 'client_id' => 'webapp', + 'username' => $username, + 'password' => $password, + 'scope' => implode(' ', $scopes), + ], + ]); + + if ($response->getStatusCode() !== 200) { + return null; + } + + $body = json_decode((string) $response->getBody(), true); + return $body['access_token'] ?? null; + } + + /** Return an Authorization header array for the given Bearer token. */ + protected function bearer(string $token): array + { + return ['Authorization' => 'Bearer ' . $token]; + } +} diff --git a/api/tests/Functional/MessageTest.php b/api/tests/Functional/MessageTest.php new file mode 100644 index 0000000..644acdb --- /dev/null +++ b/api/tests/Functional/MessageTest.php @@ -0,0 +1,78 @@ +db(); + $pdo->exec('SET FOREIGN_KEY_CHECKS = 0'); + foreach ([ + 'user', 'sessions', 'access_tokens', 'access_tokens_scopes', + 'refresh_tokens', 'sessions_scopes', + ] as $t) { + $pdo->exec("TRUNCATE TABLE `{$t}`"); + } + $pdo->exec('SET FOREIGN_KEY_CHECKS = 1'); + } + + public function testMessageWithoutTokenReturns401(): void + { + $response = self::$http->post('/message', [ + 'json' => [ + 'firstname' => 'John', + 'lastname' => 'Doe', + 'email' => 'john@example.com', + 'subject' => 'Hello', + 'message' => 'Test message', + ], + ]); + + $this->assertSame(401, $response->getStatusCode()); + } + + public function testMessageWithoutMessageScopeReturns401(): void + { + $user = $this->createUser(); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['plugins']); + + $response = self::$http->post('/message', [ + 'headers' => $this->bearer($token), + 'json' => ['firstname' => 'John', 'email' => 'john@example.com'], + ]); + + $this->assertSame(401, $response->getStatusCode()); + } + + public function testMessageWithScopeButNoRecaptchaReturns400(): void + { + $user = $this->createUser(); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['message']); + + $response = self::$http->post('/message', [ + 'headers' => $this->bearer($token), + 'json' => [ + 'firstname' => 'John', + 'lastname' => 'Doe', + 'email' => 'john@example.com', + 'subject' => 'Hello', + 'message' => 'Test message body', + ], + ]); + + // 400: either InvalidRecaptcha (recaptcha_response missing/invalid) or + // a field error — both are correct 400 responses + $this->assertSame(400, $response->getStatusCode()); + } +} diff --git a/api/tests/Functional/Plugin/DownloadTest.php b/api/tests/Functional/Plugin/DownloadTest.php new file mode 100644 index 0000000..0b49f3f --- /dev/null +++ b/api/tests/Functional/Plugin/DownloadTest.php @@ -0,0 +1,95 @@ +db(); + $pdo->exec('SET FOREIGN_KEY_CHECKS = 0'); + foreach ([ + 'plugin', 'plugin_download', + 'user', 'sessions', 'access_tokens', 'access_tokens_scopes', + 'refresh_tokens', 'sessions_scopes', + ] as $t) { + $pdo->exec("TRUNCATE TABLE `{$t}`"); + } + $pdo->exec('SET FOREIGN_KEY_CHECKS = 1'); + } + + public function testDownloadIncrementsDownloadCount(): void + { + $plugin = $this->createPlugin([ + 'key' => 'dlplugin', + 'name' => 'Download Plugin', + 'active' => 1, + 'download_count' => 5, + 'download_url' => 'http://example.com/plugin.zip', + ]); + + self::$http->get("/plugin/{$plugin['key']}/download", [ + 'headers' => ['Accept' => 'application/json'], + ]); + + $stmt = $this->db()->prepare('SELECT download_count FROM `plugin` WHERE id = ?'); + $stmt->execute([$plugin['id']]); + $this->assertSame(6, (int) $stmt->fetchColumn()); + } + + public function testDownloadCreatesPluginDownloadRecord(): void + { + $plugin = $this->createPlugin([ + 'key' => 'dlrecordplugin', + 'name' => 'DL Record Plugin', + 'active' => 1, + 'download_url' => 'http://example.com/plugin.zip', + ]); + + self::$http->get("/plugin/{$plugin['key']}/download", [ + 'headers' => ['Accept' => 'application/json'], + ]); + + $stmt = $this->db()->prepare('SELECT COUNT(*) FROM `plugin_download` WHERE plugin_id = ?'); + $stmt->execute([$plugin['id']]); + $this->assertSame(1, (int) $stmt->fetchColumn()); + } + + public function testDownloadWithJsonAcceptReturns200WithoutRedirect(): void + { + $plugin = $this->createPlugin([ + 'key' => 'jsondownload', + 'name' => 'JSON Download Plugin', + 'active' => 1, + 'download_url' => 'http://example.com/plugin.zip', + ]); + + $response = self::$http->get("/plugin/{$plugin['key']}/download", [ + 'headers' => ['Accept' => 'application/json'], + ]); + + $this->assertSame(200, $response->getStatusCode()); + } + + public function testDownloadWithoutJsonAcceptRedirectsWith301(): void + { + $plugin = $this->createPlugin([ + 'key' => 'redirectdownload', + 'name' => 'Redirect Download Plugin', + 'active' => 1, + 'download_url' => 'http://example.com/plugin.zip', + ]); + + $response = self::$http->get("/plugin/{$plugin['key']}/download"); + + $this->assertSame(301, $response->getStatusCode()); + $this->assertSame('http://example.com/plugin.zip', $response->getHeaderLine('Location')); + } +} diff --git a/api/tests/Functional/Plugin/ListingTest.php b/api/tests/Functional/Plugin/ListingTest.php new file mode 100644 index 0000000..9e54270 --- /dev/null +++ b/api/tests/Functional/Plugin/ListingTest.php @@ -0,0 +1,210 @@ +db(); + $pdo->exec('SET FOREIGN_KEY_CHECKS = 0'); + foreach ([ + 'plugin', 'plugin_author', 'plugin_description', 'plugin_version', + 'plugin_download', 'plugin_stars', 'plugin_tags', 'plugin_screenshot', + 'plugin_permission', 'user_plugin_watch', + 'user', 'sessions', 'access_tokens', 'access_tokens_scopes', + 'refresh_tokens', 'sessions_scopes', + ] as $t) { + $pdo->exec("TRUNCATE TABLE `{$t}`"); + } + $pdo->exec('SET FOREIGN_KEY_CHECKS = 1'); + } + + private function authedToken(array $scopes = ['plugins']): string + { + $user = $this->createUser(); + return $this->getAccessToken($user['username'], $user['plain_password'], $scopes); + } + + // ------------------------------------------------------------------------- + // GET /plugin (§6.1) + // ------------------------------------------------------------------------- + + public function testListReturnsOnlyActivePlugins(): void + { + $this->createPlugin(['key' => 'activeplug', 'name' => 'Active Plugin', 'active' => 1]); + $this->createPlugin(['key' => 'inactiveplug', 'name' => 'Inactive Plugin', 'active' => 0]); + $token = $this->authedToken(['plugins']); + + $response = self::$http->get('/plugin', ['headers' => $this->bearer($token)]); + + $this->assertSame(200, $response->getStatusCode()); + $body = json_decode((string) $response->getBody(), true); + $keys = array_column($body, 'key'); + $this->assertContains('activeplug', $keys); + $this->assertNotContains('inactiveplug', $keys); + } + + public function testListWithoutTokenReturns401(): void + { + $response = self::$http->get('/plugin'); + $this->assertSame(401, $response->getStatusCode()); + } + + public function testListResponseHasPaginationHeaders(): void + { + $this->createPlugin(['key' => 'pagplug', 'name' => 'Pag Plugin', 'active' => 1]); + $token = $this->authedToken(['plugins']); + + $response = self::$http->get('/plugin', ['headers' => $this->bearer($token)]); + + $this->assertTrue($response->hasHeader('accept-range'), 'accept-range header must be present'); + $this->assertTrue($response->hasHeader('content-range'), 'content-range header must be present'); + } + + public function testXRangeHeaderRestrictsResults(): void + { + for ($i = 1; $i <= 5; $i++) { + $this->createPlugin(['key' => "rangeplug{$i}", 'name' => "Range Plugin {$i}", 'active' => 1]); + } + $token = $this->authedToken(['plugins']); + + $response = self::$http->get('/plugin', [ + 'headers' => array_merge($this->bearer($token), ['x-range' => '0-1']), + ]); + + $body = json_decode((string) $response->getBody(), true); + $this->assertCount(2, $body, 'x-range: 0-1 should return 2 items'); + $this->assertSame(206, $response->getStatusCode()); + } + + public function testRangeStartingBeyondTotalReturns400(): void + { + $this->createPlugin(['key' => 'onlyone', 'name' => 'Only One', 'active' => 1]); + $token = $this->authedToken(['plugins']); + + $response = self::$http->get('/plugin', [ + 'headers' => array_merge($this->bearer($token), ['x-range' => '999-1000']), + ]); + + $this->assertSame(400, $response->getStatusCode()); + } + + // ------------------------------------------------------------------------- + // GET /plugin/popular, /plugin/new, /plugin/updated (§6.2) + // ------------------------------------------------------------------------- + + public function testPopularReturnsOnlyActivePlugins(): void + { + $this->createPlugin(['key' => 'activepop', 'name' => 'Active Popular', 'active' => 1, 'download_count' => 100]); + $this->createPlugin(['key' => 'inactivepop', 'name' => 'Inactive Popular', 'active' => 0, 'download_count' => 200]); + $token = $this->authedToken(['plugins']); + + $response = self::$http->get('/plugin/popular', ['headers' => $this->bearer($token)]); + + $this->assertSame(200, $response->getStatusCode()); + $body = json_decode((string) $response->getBody(), true); + $keys = array_column($body, 'key'); + $this->assertNotContains('inactivepop', $keys); + } + + public function testNewReturnsOnlyActivePlugins(): void + { + $this->createPlugin(['key' => 'activenew', 'name' => 'Active New', 'active' => 1]); + $this->createPlugin(['key' => 'inactivenew', 'name' => 'Inactive New', 'active' => 0]); + $token = $this->authedToken(['plugins']); + + $response = self::$http->get('/plugin/new', ['headers' => $this->bearer($token)]); + + $body = json_decode((string) $response->getBody(), true); + $keys = array_column($body, 'key'); + $this->assertNotContains('inactivenew', $keys); + } + + // ------------------------------------------------------------------------- + // GET /plugin/:key (§6.3) + // ------------------------------------------------------------------------- + + public function testGetSinglePluginReturns200(): void + { + $plugin = $this->createPlugin(['key' => 'singleplug', 'name' => 'Single Plugin', 'active' => 1]); + $token = $this->authedToken(['plugin:card']); + + $response = self::$http->get("/plugin/{$plugin['key']}", ['headers' => $this->bearer($token)]); + + $this->assertSame(200, $response->getStatusCode()); + $body = json_decode((string) $response->getBody(), true); + $this->assertSame('singleplug', $body['key']); + } + + public function testGetInactivePluginReturns404(): void + { + $plugin = $this->createPlugin(['key' => 'inactivesingle', 'name' => 'Inactive Single', 'active' => 0]); + $token = $this->authedToken(['plugin:card']); + + $response = self::$http->get("/plugin/{$plugin['key']}", ['headers' => $this->bearer($token)]); + + $this->assertSame(404, $response->getStatusCode()); + } + + public function testGetUnknownPluginReturns404(): void + { + $token = $this->authedToken(['plugin:card']); + + $response = self::$http->get('/plugin/no_such_plugin', ['headers' => $this->bearer($token)]); + + $this->assertSame(404, $response->getStatusCode()); + } + + public function testGetSinglePluginIncludesWatchedFlagForWatcher(): void + { + $plugin = $this->createPlugin(['key' => 'watchedplug', 'name' => 'Watched Plugin', 'active' => 1]); + $user = $this->createUser(); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['plugin:card', 'user', 'plugins']); + + // Watch the plugin + self::$http->post('/user/watchs', [ + 'headers' => $this->bearer($token), + 'json' => ['plugin_key' => $plugin['key']], + ]); + + $response = self::$http->get("/plugin/{$plugin['key']}", ['headers' => $this->bearer($token)]); + $body = json_decode((string) $response->getBody(), true); + + $this->assertTrue($body['watched'], 'watched flag must be true for a watching user'); + } + + // ------------------------------------------------------------------------- + // GET /plugin/rss_new and /plugin/rss_updated (§6.4) + // ------------------------------------------------------------------------- + + public function testRssNewReturnsXmlWithoutAuth(): void + { + $this->createPlugin(['key' => 'rssplugin', 'name' => 'RSS Plugin', 'active' => 1]); + + $response = self::$http->get('/plugin/rss_new'); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertStringContainsString('getBody()); + } + + public function testRssUpdatedReturnsXmlWithoutAuth(): void + { + $response = self::$http->get('/plugin/rss_updated'); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertStringContainsString('getBody()); + } +} diff --git a/api/tests/Functional/Plugin/PanelTest.php b/api/tests/Functional/Plugin/PanelTest.php new file mode 100644 index 0000000..7f11c30 --- /dev/null +++ b/api/tests/Functional/Plugin/PanelTest.php @@ -0,0 +1,100 @@ +db(); + $pdo->exec('SET FOREIGN_KEY_CHECKS = 0'); + foreach ([ + 'plugin', 'plugin_permission', 'plugin_description', 'plugin_version', + 'plugin_stars', 'plugin_screenshot', 'plugin_tags', 'plugin_author', + 'user', 'sessions', 'access_tokens', 'access_tokens_scopes', + 'refresh_tokens', 'sessions_scopes', + ] as $t) { + $pdo->exec("TRUNCATE TABLE `{$t}`"); + } + $pdo->exec('SET FOREIGN_KEY_CHECKS = 1'); + } + + // ------------------------------------------------------------------------- + // GET /panel/plugin/:key (§11.1) + // ------------------------------------------------------------------------- + + public function testAdminUserCanViewPanel(): void + { + $admin = $this->createUser(); + $plugin = $this->createPlugin(['key' => 'panelplug', 'name' => 'Panel Plugin', 'active' => 1]); + $this->grantPermission($plugin['id'], $admin['id'], true); + $token = $this->getAccessToken($admin['username'], $admin['plain_password'], ['plugin:card', 'user']); + + $response = self::$http->get("/panel/plugin/{$plugin['key']}", [ + 'headers' => $this->bearer($token), + ]); + + $this->assertSame(200, $response->getStatusCode()); + $body = json_decode((string) $response->getBody(), true); + $this->assertArrayHasKey('card', $body); + $this->assertArrayHasKey('statistics', $body); + } + + public function testUserWithoutPermissionCannotViewPanel(): void + { + $user = $this->createUser(); + $plugin = $this->createPlugin(['key' => 'panelnoperm', 'name' => 'Panel No Perm', 'active' => 1]); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['plugin:card', 'user']); + + $response = self::$http->get("/panel/plugin/{$plugin['key']}", [ + 'headers' => $this->bearer($token), + ]); + + $this->assertSame(401, $response->getStatusCode()); + } + + // ------------------------------------------------------------------------- + // POST /panel/plugin/:key (§11.2) + // ------------------------------------------------------------------------- + + public function testUpdateWithNonUrlStringReturns400(): void + { + $admin = $this->createUser(); + $plugin = $this->createPlugin(['key' => 'paneledit', 'name' => 'Panel Edit', 'active' => 1]); + $this->grantPermission($plugin['id'], $admin['id'], true); + $token = $this->getAccessToken($admin['username'], $admin['plain_password'], ['user', 'plugin:card']); + + $response = self::$http->post("/panel/plugin/{$plugin['key']}", [ + 'headers' => $this->bearer($token), + 'json' => ['xml_url' => 'not-a-url'], + ]); + + $this->assertSame(400, $response->getStatusCode()); + } + + public function testUpdateWithUnfetchableUrlReturns400(): void + { + $admin = $this->createUser(); + $plugin = $this->createPlugin(['key' => 'panelunfetch', 'name' => 'Panel Unfetch', 'active' => 1]); + $this->grantPermission($plugin['id'], $admin['id'], true); + $token = $this->getAccessToken($admin['username'], $admin['plain_password'], ['user', 'plugin:card']); + + $response = self::$http->post("/panel/plugin/{$plugin['key']}", [ + 'headers' => $this->bearer($token), + 'json' => ['xml_url' => 'http://localhost:19999/nonexistent.xml'], + ]); + + $this->assertSame(400, $response->getStatusCode()); + } +} diff --git a/api/tests/Functional/Plugin/PermissionsTest.php b/api/tests/Functional/Plugin/PermissionsTest.php new file mode 100644 index 0000000..cd37d15 --- /dev/null +++ b/api/tests/Functional/Plugin/PermissionsTest.php @@ -0,0 +1,237 @@ +db(); + $pdo->exec('SET FOREIGN_KEY_CHECKS = 0'); + foreach ([ + 'plugin', 'plugin_permission', + 'user', 'sessions', 'access_tokens', 'access_tokens_scopes', + 'refresh_tokens', 'sessions_scopes', + ] as $t) { + $pdo->exec("TRUNCATE TABLE `{$t}`"); + } + $pdo->exec('SET FOREIGN_KEY_CHECKS = 1'); + } + + // ------------------------------------------------------------------------- + // GET /plugin/:key/permissions (§10.1) + // ------------------------------------------------------------------------- + + public function testAdminCanViewPermissions(): void + { + $admin = $this->createUser(); + $plugin = $this->createPlugin(['key' => 'permplug', 'name' => 'Perm Plugin', 'active' => 1]); + $this->grantPermission($plugin['id'], $admin['id'], true); + $token = $this->getAccessToken($admin['username'], $admin['plain_password'], ['user', 'plugin:card']); + + $response = self::$http->get("/plugin/{$plugin['key']}/permissions", [ + 'headers' => $this->bearer($token), + ]); + + $this->assertSame(200, $response->getStatusCode()); + } + + public function testNonAdminCannotViewPermissions(): void + { + $user = $this->createUser(); + $plugin = $this->createPlugin(['key' => 'permplug2', 'name' => 'Perm Plugin 2', 'active' => 1]); + $this->grantPermission($plugin['id'], $user['id'], false); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['user', 'plugin:card']); + + $response = self::$http->get("/plugin/{$plugin['key']}/permissions", [ + 'headers' => $this->bearer($token), + ]); + + $this->assertSame(401, $response->getStatusCode()); + } + + // ------------------------------------------------------------------------- + // POST /plugin/:key/permissions (§10.2) + // ------------------------------------------------------------------------- + + public function testAdminCanAddPermission(): void + { + $admin = $this->createUser(['username' => 'adminuser', 'email' => 'admin@example.com']); + $target = $this->createUser(['username' => 'targetuser', 'email' => 'target@example.com']); + $plugin = $this->createPlugin(['key' => 'addpermplug', 'name' => 'Add Perm Plugin', 'active' => 1]); + $this->grantPermission($plugin['id'], $admin['id'], true); + $token = $this->getAccessToken($admin['username'], $admin['plain_password'], ['user', 'plugin:card']); + + $response = self::$http->post("/plugin/{$plugin['key']}/permissions", [ + 'headers' => $this->bearer($token), + 'json' => ['username' => $target['username']], + ]); + + $this->assertSame(200, $response->getStatusCode()); + } + + public function testAddPermissionForNonExistentUserReturns404(): void + { + $admin = $this->createUser(); + $plugin = $this->createPlugin(['key' => 'addperm404', 'name' => 'Add Perm 404', 'active' => 1]); + $this->grantPermission($plugin['id'], $admin['id'], true); + $token = $this->getAccessToken($admin['username'], $admin['plain_password'], ['user', 'plugin:card']); + + $response = self::$http->post("/plugin/{$plugin['key']}/permissions", [ + 'headers' => $this->bearer($token), + 'json' => ['username' => 'nobody_here'], + ]); + + $this->assertSame(404, $response->getStatusCode()); + } + + public function testAddPermissionAlreadyExistsReturns400(): void + { + $admin = $this->createUser(['username' => 'admindup', 'email' => 'admindup@example.com']); + $target = $this->createUser(['username' => 'targetdup', 'email' => 'targetdup@example.com']); + $plugin = $this->createPlugin(['key' => 'addpermdup', 'name' => 'Add Perm Dup', 'active' => 1]); + $this->grantPermission($plugin['id'], $admin['id'], true); + $this->grantPermission($plugin['id'], $target['id'], false); + $token = $this->getAccessToken($admin['username'], $admin['plain_password'], ['user', 'plugin:card']); + + $response = self::$http->post("/plugin/{$plugin['key']}/permissions", [ + 'headers' => $this->bearer($token), + 'json' => ['username' => $target['username']], + ]); + + $this->assertSame(400, $response->getStatusCode()); + } + + public function testNonAdminCannotAddPermission(): void + { + $user = $this->createUser(); + $target = $this->createUser(['username' => 'targetna', 'email' => 'targetna@example.com']); + $plugin = $this->createPlugin(['key' => 'addpermna', 'name' => 'Add Perm NA', 'active' => 1]); + $this->grantPermission($plugin['id'], $user['id'], false); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['user', 'plugin:card']); + + $response = self::$http->post("/plugin/{$plugin['key']}/permissions", [ + 'headers' => $this->bearer($token), + 'json' => ['username' => $target['username']], + ]); + + $this->assertSame(401, $response->getStatusCode()); + } + + // ------------------------------------------------------------------------- + // DELETE /plugin/:key/permissions/:username (§10.3) + // ------------------------------------------------------------------------- + + public function testNonAdminCanRemoveOwnPermission(): void + { + $admin = $this->createUser(['username' => 'admindel', 'email' => 'admindel@example.com']); + $user = $this->createUser(['username' => 'selfremove', 'email' => 'selfremove@example.com']); + $plugin = $this->createPlugin(['key' => 'delpermplug', 'name' => 'Del Perm Plugin', 'active' => 1]); + $this->grantPermission($plugin['id'], $admin['id'], true); + $this->grantPermission($plugin['id'], $user['id'], false); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['user', 'plugin:card']); + + $response = self::$http->delete("/plugin/{$plugin['key']}/permissions/{$user['username']}", [ + 'headers' => $this->bearer($token), + ]); + + $this->assertSame(200, $response->getStatusCode()); + } + + public function testCannotDeleteAdminPermissionReturns401(): void + { + $admin = $this->createUser(['username' => 'adminnd', 'email' => 'adminnd@example.com']); + $other = $this->createUser(['username' => 'otheradmin', 'email' => 'otheradmin@example.com']); + $plugin = $this->createPlugin(['key' => 'nodeladminplug', 'name' => 'No Del Admin', 'active' => 1]); + $this->grantPermission($plugin['id'], $admin['id'], true); + $this->grantPermission($plugin['id'], $other['id'], true); + $token = $this->getAccessToken($admin['username'], $admin['plain_password'], ['user', 'plugin:card']); + + $response = self::$http->delete("/plugin/{$plugin['key']}/permissions/{$other['username']}", [ + 'headers' => $this->bearer($token), + ]); + + $this->assertSame(401, $response->getStatusCode()); + } + + public function testDeleteNonExistentPermissionReturns400(): void + { + $admin = $this->createUser(['username' => 'adminnoperm', 'email' => 'adminnoperm@example.com']); + $plugin = $this->createPlugin(['key' => 'delperm404', 'name' => 'Del Perm 404', 'active' => 1]); + $this->grantPermission($plugin['id'], $admin['id'], true); + $token = $this->getAccessToken($admin['username'], $admin['plain_password'], ['user', 'plugin:card']); + + $response = self::$http->delete("/plugin/{$plugin['key']}/permissions/nobody", [ + 'headers' => $this->bearer($token), + ]); + + $this->assertSame(400, $response->getStatusCode()); + } + + // ------------------------------------------------------------------------- + // PATCH /plugin/:key/permissions/:username (§10.4) + // ------------------------------------------------------------------------- + + public function testAdminCanModifyPermissionFlag(): void + { + $admin = $this->createUser(['username' => 'adminmod', 'email' => 'adminmod@example.com']); + $target = $this->createUser(['username' => 'targetmod', 'email' => 'targetmod@example.com']); + $plugin = $this->createPlugin(['key' => 'modpermplug', 'name' => 'Mod Perm Plugin', 'active' => 1]); + $this->grantPermission($plugin['id'], $admin['id'], true); + $this->grantPermission($plugin['id'], $target['id'], false); + $token = $this->getAccessToken($admin['username'], $admin['plain_password'], ['user', 'plugin:card']); + + $response = self::$http->patch("/plugin/{$plugin['key']}/permissions/{$target['username']}", [ + 'headers' => $this->bearer($token), + 'json' => ['right' => 'allowed_refresh_xml', 'set' => true], + ]); + + $this->assertSame(200, $response->getStatusCode()); + } + + public function testInvalidRightValueReturns400(): void + { + $admin = $this->createUser(['username' => 'adminbadright', 'email' => 'adminbadright@example.com']); + $target = $this->createUser(['username' => 'targetbadright', 'email' => 'targetbadright@example.com']); + $plugin = $this->createPlugin(['key' => 'badrightplug', 'name' => 'Bad Right Plugin', 'active' => 1]); + $this->grantPermission($plugin['id'], $admin['id'], true); + $this->grantPermission($plugin['id'], $target['id'], false); + $token = $this->getAccessToken($admin['username'], $admin['plain_password'], ['user', 'plugin:card']); + + $response = self::$http->patch("/plugin/{$plugin['key']}/permissions/{$target['username']}", [ + 'headers' => $this->bearer($token), + 'json' => ['right' => 'nonexistent_right', 'set' => true], + ]); + + $this->assertSame(400, $response->getStatusCode()); + } + + public function testMissingSetFieldReturns400(): void + { + $admin = $this->createUser(['username' => 'adminnoset', 'email' => 'adminnoset@example.com']); + $target = $this->createUser(['username' => 'targetnoset', 'email' => 'targetnoset@example.com']); + $plugin = $this->createPlugin(['key' => 'nosetplug', 'name' => 'No Set Plugin', 'active' => 1]); + $this->grantPermission($plugin['id'], $admin['id'], true); + $this->grantPermission($plugin['id'], $target['id'], false); + $token = $this->getAccessToken($admin['username'], $admin['plain_password'], ['user', 'plugin:card']); + + $response = self::$http->patch("/plugin/{$plugin['key']}/permissions/{$target['username']}", [ + 'headers' => $this->bearer($token), + 'json' => ['right' => 'allowed_refresh_xml'], + ]); + + $this->assertSame(400, $response->getStatusCode()); + } +} diff --git a/api/tests/Functional/Plugin/StarTest.php b/api/tests/Functional/Plugin/StarTest.php new file mode 100644 index 0000000..e89bbcf --- /dev/null +++ b/api/tests/Functional/Plugin/StarTest.php @@ -0,0 +1,87 @@ +db(); + $pdo->exec('SET FOREIGN_KEY_CHECKS = 0'); + foreach ([ + 'plugin', 'plugin_stars', + 'user', 'sessions', 'access_tokens', 'access_tokens_scopes', + 'refresh_tokens', 'sessions_scopes', + ] as $t) { + $pdo->exec("TRUNCATE TABLE `{$t}`"); + } + $pdo->exec('SET FOREIGN_KEY_CHECKS = 1'); + } + + public function testStarPluginCreatesRecordAndReturnsNewAverage(): void + { + $plugin = $this->createPlugin(['key' => 'starplugin', 'name' => 'Star Plugin', 'active' => 1]); + $user = $this->createUser(); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['plugin:star']); + + $response = self::$http->post('/plugin/star', [ + 'headers' => $this->bearer($token), + 'json' => ['plugin_id' => $plugin['id'], 'note' => 4], + ]); + + $this->assertSame(200, $response->getStatusCode()); + $body = json_decode((string) $response->getBody(), true); + $this->assertArrayHasKey('new_average', $body); + $this->assertEquals(4.0, (float) $body['new_average'], '', 0.01); + + $stmt = $this->db()->prepare('SELECT COUNT(*) FROM `plugin_stars` WHERE plugin_id = ?'); + $stmt->execute([$plugin['id']]); + $this->assertSame(1, (int) $stmt->fetchColumn()); + } + + public function testNonNumericPluginIdReturns400(): void + { + $user = $this->createUser(); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['plugin:star']); + + $response = self::$http->post('/plugin/star', [ + 'headers' => $this->bearer($token), + 'json' => ['plugin_id' => 'abc', 'note' => 3], + ]); + + $this->assertSame(400, $response->getStatusCode()); + } + + public function testNonExistentPluginReturns400(): void + { + $user = $this->createUser(); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['plugin:star']); + + $response = self::$http->post('/plugin/star', [ + 'headers' => $this->bearer($token), + 'json' => ['plugin_id' => 99999, 'note' => 3], + ]); + + $this->assertSame(400, $response->getStatusCode()); + } + + public function testMissingScopeReturns401(): void + { + $user = $this->createUser(); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['plugins']); + + $response = self::$http->post('/plugin/star', [ + 'headers' => $this->bearer($token), + 'json' => ['plugin_id' => 1, 'note' => 3], + ]); + + $this->assertSame(401, $response->getStatusCode()); + } +} diff --git a/api/tests/Functional/Plugin/SubmitTest.php b/api/tests/Functional/Plugin/SubmitTest.php new file mode 100644 index 0000000..7a4278e --- /dev/null +++ b/api/tests/Functional/Plugin/SubmitTest.php @@ -0,0 +1,89 @@ +db(); + $pdo->exec('SET FOREIGN_KEY_CHECKS = 0'); + foreach ([ + 'plugin', 'plugin_permission', + 'user', 'sessions', 'access_tokens', 'access_tokens_scopes', + 'refresh_tokens', 'sessions_scopes', + ] as $t) { + $pdo->exec("TRUNCATE TABLE `{$t}`"); + } + $pdo->exec('SET FOREIGN_KEY_CHECKS = 1'); + } + + public function testSubmitWithoutTokenReturns401(): void + { + $response = self::$http->post('/plugin', [ + 'json' => ['plugin_url' => 'http://example.com/plugin.xml'], + ]); + + $this->assertSame(401, $response->getStatusCode()); + } + + public function testSubmitWithoutSubmitScopeReturns401(): void + { + $user = $this->createUser(); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['plugins']); + + $response = self::$http->post('/plugin', [ + 'headers' => $this->bearer($token), + 'json' => ['plugin_url' => 'http://example.com/plugin.xml'], + ]); + + $this->assertSame(401, $response->getStatusCode()); + } + + public function testSubmitWithMissingPluginUrlReturns400(): void + { + $user = $this->createUser(); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['plugin:submit']); + + $response = self::$http->post('/plugin', [ + 'headers' => $this->bearer($token), + 'json' => [], + ]); + + // 400 returned — either InvalidRecaptcha (checked first) or InvalidField + $this->assertSame(400, $response->getStatusCode()); + } + + public function testSubmitWithDuplicateXmlUrlReturns400(): void + { + $this->createPlugin(['xml_url' => 'http://example.com/existing.xml']); + $user = $this->createUser(); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['plugin:submit']); + + $response = self::$http->post('/plugin', [ + 'headers' => $this->bearer($token), + 'json' => [ + 'plugin_url' => 'http://example.com/existing.xml', + 'recaptcha_response' => 'dummy', + ], + ]); + + $this->assertSame(400, $response->getStatusCode()); + } +} diff --git a/api/tests/Functional/SearchTest.php b/api/tests/Functional/SearchTest.php new file mode 100644 index 0000000..105d08e --- /dev/null +++ b/api/tests/Functional/SearchTest.php @@ -0,0 +1,101 @@ +db(); + $pdo->exec('SET FOREIGN_KEY_CHECKS = 0'); + foreach ([ + 'plugin', 'plugin_description', 'plugin_stars', + 'user', 'sessions', 'access_tokens', 'access_tokens_scopes', + 'refresh_tokens', 'sessions_scopes', + ] as $t) { + $pdo->exec("TRUNCATE TABLE `{$t}`"); + } + $pdo->exec('SET FOREIGN_KEY_CHECKS = 1'); + } + + private function authedToken(): string + { + $user = $this->createUser(); + return $this->getAccessToken($user['username'], $user['plain_password'], ['plugins:search']); + } + + public function testSearchMatchesOnPluginName(): void + { + $this->createPlugin(['key' => 'uniquepluginname', 'name' => 'UniquePluginName', 'active' => 1]); + $token = $this->authedToken(); + + $response = self::$http->post('/search', [ + 'headers' => $this->bearer($token), + 'json' => ['query_string' => 'UniquePlugin'], + ]); + + $this->assertSame(200, $response->getStatusCode()); + $body = json_decode((string) $response->getBody(), true); + $keys = array_column($body, 'key'); + $this->assertContains('uniquepluginname', $keys); + } + + public function testSearchMatchesOnPluginKey(): void + { + $this->createPlugin(['key' => 'searchbykey', 'name' => 'Search By Key Plugin', 'active' => 1]); + $token = $this->authedToken(); + + $response = self::$http->post('/search', [ + 'headers' => $this->bearer($token), + 'json' => ['query_string' => 'searchbykey'], + ]); + + $body = json_decode((string) $response->getBody(), true); + $keys = array_column($body, 'key'); + $this->assertContains('searchbykey', $keys); + } + + public function testSearchExcludesInactivePlugins(): void + { + $this->createPlugin(['key' => 'inactivesearch', 'name' => 'InactiveSearchPlugin', 'active' => 0]); + $token = $this->authedToken(); + + $response = self::$http->post('/search', [ + 'headers' => $this->bearer($token), + 'json' => ['query_string' => 'InactiveSearch'], + ]); + + $body = json_decode((string) $response->getBody(), true); + $keys = array_column($body, 'key'); + $this->assertNotContains('inactivesearch', $keys); + } + + public function testShortQueryReturns400(): void + { + $token = $this->authedToken(); + + $response = self::$http->post('/search', [ + 'headers' => $this->bearer($token), + 'json' => ['query_string' => 'a'], + ]); + + $this->assertSame(400, $response->getStatusCode()); + } + + public function testMissingQueryStringReturns400(): void + { + $token = $this->authedToken(); + + $response = self::$http->post('/search', [ + 'headers' => $this->bearer($token), + 'json' => [], + ]); + + $this->assertSame(400, $response->getStatusCode()); + } +} diff --git a/api/tests/Functional/Tags/TagsTest.php b/api/tests/Functional/Tags/TagsTest.php new file mode 100644 index 0000000..8d2d734 --- /dev/null +++ b/api/tests/Functional/Tags/TagsTest.php @@ -0,0 +1,107 @@ +db(); + $pdo->exec('SET FOREIGN_KEY_CHECKS = 0'); + foreach ([ + 'tag', 'plugin', 'plugin_tags', 'plugin_description', 'plugin_stars', + 'user', 'sessions', 'access_tokens', 'access_tokens_scopes', + 'refresh_tokens', 'sessions_scopes', + ] as $t) { + $pdo->exec("TRUNCATE TABLE `{$t}`"); + } + $pdo->exec('SET FOREIGN_KEY_CHECKS = 1'); + } + + private function authedToken(array $scopes): string + { + $user = $this->createUser(); + return $this->getAccessToken($user['username'], $user['plain_password'], $scopes); + } + + // ------------------------------------------------------------------------- + // GET /tags and GET /tags/top (§13.1) + // ------------------------------------------------------------------------- + + public function testListTagsReturns200(): void + { + $this->createTag('network', 'Network', 'en'); + $token = $this->authedToken(['tags']); + + $response = self::$http->get('/tags', ['headers' => $this->bearer($token)]); + + $this->assertSame(200, $response->getStatusCode()); + } + + public function testTopTagsReturns200(): void + { + $this->createTag('inventory', 'Inventory', 'en'); + $token = $this->authedToken(['tags']); + + $response = self::$http->get('/tags/top', ['headers' => $this->bearer($token)]); + + $this->assertSame(200, $response->getStatusCode()); + } + + // ------------------------------------------------------------------------- + // GET /tags/:id (§13.2) + // ------------------------------------------------------------------------- + + public function testGetSingleTagReturns200(): void + { + $tag = $this->createTag('security', 'Security', 'en'); + $token = $this->authedToken(['tag']); + + $response = self::$http->get("/tags/{$tag['key']}", ['headers' => $this->bearer($token)]); + + $this->assertSame(200, $response->getStatusCode()); + $body = json_decode((string) $response->getBody(), true); + $this->assertSame('security', $body['key']); + } + + public function testGetUnknownTagReturns404(): void + { + $token = $this->authedToken(['tag']); + + $response = self::$http->get('/tags/no_such_tag', ['headers' => $this->bearer($token)]); + + $this->assertSame(404, $response->getStatusCode()); + } + + // ------------------------------------------------------------------------- + // GET /tags/:id/plugin (§13.3) + // ------------------------------------------------------------------------- + + public function testTagPluginsReturnsPluginsWithThatTag(): void + { + $tag = $this->createTag('reporting', 'Reporting', 'en'); + $plugin = $this->createPlugin(['key' => 'taggedplug', 'name' => 'Tagged Plugin', 'active' => 1]); + $this->db()->prepare("INSERT INTO `plugin_tags` (`plugin_id`, `tag_id`) VALUES (?, ?)") + ->execute([$plugin['id'], $tag['id']]); + $token = $this->authedToken(['tag', 'plugins']); + + $response = self::$http->get("/tags/{$tag['key']}/plugin", ['headers' => $this->bearer($token)]); + + $this->assertSame(200, $response->getStatusCode()); + $body = json_decode((string) $response->getBody(), true); + $keys = array_column($body, 'key'); + $this->assertContains('taggedplug', $keys); + } +} diff --git a/api/tests/Functional/User/AppsTest.php b/api/tests/Functional/User/AppsTest.php new file mode 100644 index 0000000..33992b5 --- /dev/null +++ b/api/tests/Functional/User/AppsTest.php @@ -0,0 +1,187 @@ +db(); + $pdo->exec('SET FOREIGN_KEY_CHECKS = 0'); + foreach (['user', 'sessions', 'access_tokens', 'access_tokens_scopes', 'refresh_tokens', 'sessions_scopes'] as $t) { + $pdo->exec("TRUNCATE TABLE `{$t}`"); + } + // Remove user-owned apps (keep webapp/glpidefault which have no user_id) + $pdo->exec("DELETE FROM `apps` WHERE `user_id` IS NOT NULL"); + $pdo->exec('SET FOREIGN_KEY_CHECKS = 1'); + } + + // ------------------------------------------------------------------------- + // POST /user/apps (§19.1) + // ------------------------------------------------------------------------- + + public function testCreateAppReturns200(): void + { + $user = $this->createUser(); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['user', 'user:apps']); + + $response = self::$http->post('/user/apps', [ + 'headers' => $this->bearer($token), + 'json' => ['name' => 'My Test App'], + ]); + + $this->assertSame(200, $response->getStatusCode()); + } + + public function testCreatedAppHasRandomClientIdAndSecret(): void + { + $user = $this->createUser(); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['user', 'user:apps']); + + self::$http->post('/user/apps', [ + 'headers' => $this->bearer($token), + 'json' => ['name' => 'SecretApp'], + ]); + + $stmt = $this->db()->prepare( + "SELECT id, secret FROM `apps` WHERE user_id = ? AND name = 'SecretApp'" + ); + $stmt->execute([$user['id']]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + $this->assertNotFalse($row); + $this->assertNotEmpty($row['id']); + $this->assertNotEmpty($row['secret']); + } + + public function testDuplicateAppNameReturns400(): void + { + $user = $this->createUser(); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['user', 'user:apps']); + + self::$http->post('/user/apps', ['headers' => $this->bearer($token), 'json' => ['name' => 'DupApp']]); + $response = self::$http->post('/user/apps', ['headers' => $this->bearer($token), 'json' => ['name' => 'DupApp']]); + + $this->assertSame(400, $response->getStatusCode()); + } + + public function testInvalidAppNameReturns400(): void + { + $user = $this->createUser(); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['user', 'user:apps']); + + $response = self::$http->post('/user/apps', [ + 'headers' => $this->bearer($token), + 'json' => ['name' => 'ab'], // too short (< 4 chars) + ]); + + $this->assertSame(400, $response->getStatusCode()); + } + + // ------------------------------------------------------------------------- + // GET /user/apps and GET /user/apps/:id (§19.2) + // ------------------------------------------------------------------------- + + public function testListAppsReturnsOnlyOwnApps(): void + { + $user = $this->createUser(); + $other = $this->createUser(['username' => 'otheruser', 'email' => 'other@example.com']); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['user', 'user:apps']); + + self::$http->post('/user/apps', ['headers' => $this->bearer($token), 'json' => ['name' => 'OwnApp']]); + + $response = self::$http->get('/user/apps', ['headers' => $this->bearer($token)]); + + $this->assertSame(200, $response->getStatusCode()); + $body = json_decode((string) $response->getBody(), true); + foreach ($body as $app) { + $stmt = $this->db()->prepare('SELECT user_id FROM `apps` WHERE id = ?'); + $stmt->execute([$app['id']]); + $this->assertSame($user['id'], (int) $stmt->fetchColumn(), 'Must only return own apps'); + } + } + + public function testGetSingleAppReturns200(): void + { + $user = $this->createUser(); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['user', 'user:apps']); + + self::$http->post('/user/apps', ['headers' => $this->bearer($token), 'json' => ['name' => 'SingleApp']]); + $stmt = $this->db()->prepare("SELECT id FROM `apps` WHERE name = 'SingleApp'"); + $stmt->execute(); + $id = $stmt->fetchColumn(); + + $response = self::$http->get("/user/apps/{$id}", ['headers' => $this->bearer($token)]); + + $this->assertSame(200, $response->getStatusCode()); + } + + public function testGetUnknownAppReturns404(): void + { + $user = $this->createUser(); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['user', 'user:apps']); + + $response = self::$http->get('/user/apps/99999', ['headers' => $this->bearer($token)]); + + $this->assertSame(404, $response->getStatusCode()); + } + + // ------------------------------------------------------------------------- + // PUT /user/apps/:id (§19.3) + // ------------------------------------------------------------------------- + + public function testUpdateAppPersistsChanges(): void + { + $user = $this->createUser(); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['user', 'user:apps']); + + self::$http->post('/user/apps', ['headers' => $this->bearer($token), 'json' => ['name' => 'UpdateMe']]); + $stmt = $this->db()->prepare("SELECT id FROM `apps` WHERE name = 'UpdateMe'"); + $stmt->execute(); + $id = $stmt->fetchColumn(); + + $response = self::$http->put("/user/apps/{$id}", [ + 'headers' => $this->bearer($token), + 'json' => ['name' => 'UpdatedName'], + ]); + + $this->assertSame(200, $response->getStatusCode()); + $stmt = $this->db()->prepare("SELECT name FROM `apps` WHERE id = ?"); + $stmt->execute([$id]); + $this->assertSame('UpdatedName', $stmt->fetchColumn()); + } + + // ------------------------------------------------------------------------- + // DELETE /user/apps/:id (§19.4) + // ------------------------------------------------------------------------- + + public function testDeleteAppRemovesRecord(): void + { + $user = $this->createUser(); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['user', 'user:apps']); + + self::$http->post('/user/apps', ['headers' => $this->bearer($token), 'json' => ['name' => 'DeleteMe']]); + $stmt = $this->db()->prepare("SELECT id FROM `apps` WHERE name = 'DeleteMe'"); + $stmt->execute(); + $id = $stmt->fetchColumn(); + + $response = self::$http->delete("/user/apps/{$id}", ['headers' => $this->bearer($token)]); + + $this->assertSame(200, $response->getStatusCode()); + $stmt = $this->db()->prepare("SELECT COUNT(*) FROM `apps` WHERE id = ?"); + $stmt->execute([$id]); + $this->assertSame(0, (int) $stmt->fetchColumn()); + } +} diff --git a/api/tests/Functional/User/EmailValidationTest.php b/api/tests/Functional/User/EmailValidationTest.php new file mode 100644 index 0000000..5e089f2 --- /dev/null +++ b/api/tests/Functional/User/EmailValidationTest.php @@ -0,0 +1,77 @@ +db(); + $pdo->exec('SET FOREIGN_KEY_CHECKS = 0'); + foreach (['user', 'user_validation_token', 'sessions', 'access_tokens', 'access_tokens_scopes', 'refresh_tokens', 'sessions_scopes'] as $t) { + $pdo->exec("TRUNCATE TABLE `{$t}`"); + } + $pdo->exec('SET FOREIGN_KEY_CHECKS = 1'); + } + + public function testValidTokenActivatesUserAndReturnsAccessToken(): void + { + $user = $this->createUser(['active' => 0]); + $token = 'validtoken123'; + $this->db()->prepare( + "INSERT INTO `user_validation_token` (`token`, `user_id`) VALUES (?, ?)" + )->execute([$token, $user['id']]); + + $response = self::$http->get("/user/validatemail/{$token}"); + + $this->assertSame(200, $response->getStatusCode()); + + $body = json_decode((string) $response->getBody(), true); + $this->assertArrayHasKey('access_token', $body); + $this->assertNotEmpty($body['access_token']); + } + + public function testValidTokenActivatesUserInDatabase(): void + { + $user = $this->createUser(['active' => 0]); + $token = 'activatetoken456'; + $this->db()->prepare( + "INSERT INTO `user_validation_token` (`token`, `user_id`) VALUES (?, ?)" + )->execute([$token, $user['id']]); + + self::$http->get("/user/validatemail/{$token}"); + + $stmt = $this->db()->prepare('SELECT active FROM `user` WHERE id = ?'); + $stmt->execute([$user['id']]); + $this->assertSame(1, (int) $stmt->fetchColumn(), 'User must be active after email validation'); + } + + public function testValidTokenIsDeletedAfterUse(): void + { + $user = $this->createUser(['active' => 0]); + $token = 'deletetoken789'; + $this->db()->prepare( + "INSERT INTO `user_validation_token` (`token`, `user_id`) VALUES (?, ?)" + )->execute([$token, $user['id']]); + + self::$http->get("/user/validatemail/{$token}"); + + $stmt = $this->db()->prepare('SELECT COUNT(*) FROM `user_validation_token` WHERE token = ?'); + $stmt->execute([$token]); + $this->assertSame(0, (int) $stmt->fetchColumn(), 'Token must be deleted after use'); + } + + public function testInvalidTokenReturns400(): void + { + $response = self::$http->get('/user/validatemail/no_such_token'); + + $this->assertSame(400, $response->getStatusCode()); + } +} diff --git a/api/tests/Functional/User/PasswordResetTest.php b/api/tests/Functional/User/PasswordResetTest.php new file mode 100644 index 0000000..279bae4 --- /dev/null +++ b/api/tests/Functional/User/PasswordResetTest.php @@ -0,0 +1,113 @@ +db(); + $pdo->exec('SET FOREIGN_KEY_CHECKS = 0'); + foreach (['user', 'user_resetpassword_token', 'sessions', 'access_tokens', 'access_tokens_scopes', 'refresh_tokens', 'sessions_scopes'] as $t) { + $pdo->exec("TRUNCATE TABLE `{$t}`"); + } + $pdo->exec('SET FOREIGN_KEY_CHECKS = 1'); + } + + // ------------------------------------------------------------------------- + // Send reset link (§5.1) + // ------------------------------------------------------------------------- + + public function testKnownEmailCreatesResetTokenAndReturns200(): void + { + $user = $this->createUser(); + + $response = self::$http->post('/user/sendpasswordresetlink', [ + 'json' => ['email' => $user['email']], + ]); + + $this->assertSame(200, $response->getStatusCode()); + + $stmt = $this->db()->prepare('SELECT COUNT(*) FROM `user_resetpassword_token` WHERE user_id = ?'); + $stmt->execute([$user['id']]); + $this->assertSame(1, (int) $stmt->fetchColumn(), 'A reset token must be created'); + } + + public function testUnknownEmailReturns404(): void + { + $response = self::$http->post('/user/sendpasswordresetlink', [ + 'json' => ['email' => 'nobody@example.com'], + ]); + + $this->assertSame(404, $response->getStatusCode()); + } + + public function testMissingEmailFieldReturns400(): void + { + $response = self::$http->post('/user/sendpasswordresetlink', [ + 'json' => [], + ]); + + $this->assertSame(400, $response->getStatusCode()); + } + + // ------------------------------------------------------------------------- + // Reset password (§5.2) + // ------------------------------------------------------------------------- + + public function testValidTokenUpdatesPasswordAndDeletesToken(): void + { + $user = $this->createUser(); + $token = 'resettoken123abc'; + $this->db()->prepare( + "INSERT INTO `user_resetpassword_token` (`token`, `user_id`) VALUES (?, ?)" + )->execute([$token, $user['id']]); + + $response = self::$http->put('/user/password', [ + 'json' => ['token' => $token, 'password' => 'NewPassword1'], + ]); + + $this->assertSame(200, $response->getStatusCode()); + + // Password hash updated + $stmt = $this->db()->prepare('SELECT password FROM `user` WHERE id = ?'); + $stmt->execute([$user['id']]); + $this->assertTrue(password_verify('NewPassword1', $stmt->fetchColumn())); + + // Reset tokens deleted + $stmt = $this->db()->prepare('SELECT COUNT(*) FROM `user_resetpassword_token` WHERE user_id = ?'); + $stmt->execute([$user['id']]); + $this->assertSame(0, (int) $stmt->fetchColumn(), 'Reset tokens must be deleted after use'); + } + + public function testInvalidTokenReturns400(): void + { + $response = self::$http->put('/user/password', [ + 'json' => ['token' => 'completely_wrong_token', 'password' => 'NewPassword1'], + ]); + + $this->assertSame(400, $response->getStatusCode()); + } + + public function testMissingPasswordReturns400(): void + { + $user = $this->createUser(); + $token = 'resettoken456xyz'; + $this->db()->prepare( + "INSERT INTO `user_resetpassword_token` (`token`, `user_id`) VALUES (?, ?)" + )->execute([$token, $user['id']]); + + $response = self::$http->put('/user/password', [ + 'json' => ['token' => $token], + ]); + + $this->assertSame(400, $response->getStatusCode()); + } +} diff --git a/api/tests/Functional/User/ProfileTest.php b/api/tests/Functional/User/ProfileTest.php new file mode 100644 index 0000000..3bbfce8 --- /dev/null +++ b/api/tests/Functional/User/ProfileTest.php @@ -0,0 +1,138 @@ +db(); + $pdo->exec('SET FOREIGN_KEY_CHECKS = 0'); + foreach (['user', 'sessions', 'access_tokens', 'access_tokens_scopes', 'refresh_tokens', 'sessions_scopes'] as $t) { + $pdo->exec("TRUNCATE TABLE `{$t}`"); + } + $pdo->exec('SET FOREIGN_KEY_CHECKS = 1'); + } + + // ------------------------------------------------------------------------- + // View profile (§4.1) + // ------------------------------------------------------------------------- + + public function testViewProfileReturnsUserFields(): void + { + $user = $this->createUser(['realname' => 'Test User']); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['user']); + + $response = self::$http->get('/user', ['headers' => $this->bearer($token)]); + + $this->assertSame(200, $response->getStatusCode()); + $body = json_decode((string) $response->getBody(), true); + $this->assertSame($user['username'], $body['username']); + $this->assertArrayHasKey('gravatar', $body); + } + + public function testViewProfileWithoutTokenReturns401(): void + { + $response = self::$http->get('/user'); + + $this->assertSame(401, $response->getStatusCode()); + } + + // ------------------------------------------------------------------------- + // Edit profile (§4.2) + // ------------------------------------------------------------------------- + + public function testEditRealnameIsPersisted(): void + { + $user = $this->createUser(); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['user']); + + self::$http->put('/user', [ + 'headers' => $this->bearer($token), + 'json' => ['realname' => 'Updated Name'], + ]); + + $stmt = $this->db()->prepare('SELECT realname FROM `user` WHERE username = ?'); + $stmt->execute([$user['username']]); + $this->assertSame('Updated Name', $stmt->fetchColumn()); + } + + public function testEditPasswordIsHashedBeforeSaving(): void + { + $user = $this->createUser(); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['user']); + + self::$http->put('/user', [ + 'headers' => $this->bearer($token), + 'json' => ['password' => 'NewPassword1'], + ]); + + $stmt = $this->db()->prepare('SELECT password FROM `user` WHERE username = ?'); + $stmt->execute([$user['username']]); + $hash = $stmt->fetchColumn(); + + $this->assertNotSame('NewPassword1', $hash, 'Password must not be stored in plain text'); + $this->assertTrue(password_verify('NewPassword1', $hash), 'Stored hash must match new password'); + } + + public function testEditProfileWithoutTokenReturns401(): void + { + $response = self::$http->put('/user', ['json' => ['realname' => 'Nobody']]); + + $this->assertSame(401, $response->getStatusCode()); + } + + // ------------------------------------------------------------------------- + // Delete account (§4.3) + // ------------------------------------------------------------------------- + + public function testDeleteAccountWithCorrectPasswordSucceeds(): void + { + $user = $this->createUser(); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['user']); + + $response = self::$http->post('/user/delete', [ + 'headers' => $this->bearer($token), + 'json' => ['password' => $user['plain_password']], + ]); + + $this->assertSame(200, $response->getStatusCode()); + + $stmt = $this->db()->prepare('SELECT COUNT(*) FROM `user` WHERE username = ?'); + $stmt->execute([$user['username']]); + $this->assertSame(0, (int) $stmt->fetchColumn(), 'User record must be deleted'); + } + + public function testDeleteAccountWithWrongPasswordReturns401(): void + { + $user = $this->createUser(); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['user']); + + $response = self::$http->post('/user/delete', [ + 'headers' => $this->bearer($token), + 'json' => ['password' => 'WrongPass1'], + ]); + + $this->assertSame(401, $response->getStatusCode()); + } + + public function testDeleteAccountWithInvalidPasswordFormatReturns400(): void + { + $user = $this->createUser(); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['user']); + + $response = self::$http->post('/user/delete', [ + 'headers' => $this->bearer($token), + 'json' => ['password' => 'ab'], // too short + ]); + + $this->assertSame(400, $response->getStatusCode()); + } +} diff --git a/api/tests/Functional/User/RegistrationTest.php b/api/tests/Functional/User/RegistrationTest.php new file mode 100644 index 0000000..91b55ef --- /dev/null +++ b/api/tests/Functional/User/RegistrationTest.php @@ -0,0 +1,149 @@ +post('/user', [ + 'json' => [ + 'username' => 'newuser', + 'email' => 'newuser@example.com', + 'password' => 'Password1', + ], + ]); + + $this->assertSame(200, $response->getStatusCode()); + } + + public function testRegistrationCreatesInactiveUser(): void + { + self::$http->post('/user', [ + 'json' => [ + 'username' => 'newuser', + 'email' => 'newuser@example.com', + 'password' => 'Password1', + ], + ]); + + $stmt = $this->db()->prepare('SELECT active FROM `user` WHERE username = ?'); + $stmt->execute(['newuser']); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + + $this->assertNotFalse($row, 'User must be created in the database'); + $this->assertSame(0, (int) $row['active'], 'Newly registered user must be inactive'); + } + + public function testRegistrationCreatesValidationToken(): void + { + self::$http->post('/user', [ + 'json' => [ + 'username' => 'newuser', + 'email' => 'newuser@example.com', + 'password' => 'Password1', + ], + ]); + + $stmt = $this->db()->prepare( + 'SELECT t.token + FROM user_validation_token t + JOIN `user` u ON u.id = t.user_id + WHERE u.username = ?' + ); + $stmt->execute(['newuser']); + $token = $stmt->fetchColumn(); + + $this->assertNotFalse($token, 'A validation token must be created for the new user'); + $this->assertNotEmpty($token); + } + + // ------------------------------------------------------------------------- + // Field validation failures + // ------------------------------------------------------------------------- + + public function testMissingUsernameReturns400(): void + { + $response = self::$http->post('/user', [ + 'json' => ['email' => 'test@example.com', 'password' => 'Password1'], + ]); + + $this->assertSame(400, $response->getStatusCode()); + } + + public function testUsernameTooShortReturns400(): void + { + $response = self::$http->post('/user', [ + 'json' => ['username' => 'ab', 'email' => 'test@example.com', 'password' => 'Password1'], + ]); + + $this->assertSame(400, $response->getStatusCode()); + } + + public function testUsernameWithSpecialCharactersReturns400(): void + { + $response = self::$http->post('/user', [ + 'json' => ['username' => 'bad!user', 'email' => 'test@example.com', 'password' => 'Password1'], + ]); + + $this->assertSame(400, $response->getStatusCode()); + } + + public function testDuplicateUsernameReturns400(): void + { + $this->createUser(['username' => 'taken', 'email' => 'taken@example.com']); + + $response = self::$http->post('/user', [ + 'json' => ['username' => 'taken', 'email' => 'other@example.com', 'password' => 'Password1'], + ]); + + $this->assertSame(400, $response->getStatusCode()); + } + + public function testInvalidEmailReturns400(): void + { + $response = self::$http->post('/user', [ + 'json' => ['username' => 'newuser', 'email' => 'not-an-email', 'password' => 'Password1'], + ]); + + $this->assertSame(400, $response->getStatusCode()); + } + + public function testDuplicateEmailReturns400(): void + { + $this->createUser(['username' => 'user1', 'email' => 'shared@example.com']); + + $response = self::$http->post('/user', [ + 'json' => ['username' => 'user2', 'email' => 'shared@example.com', 'password' => 'Password1'], + ]); + + $this->assertSame(400, $response->getStatusCode()); + } + + public function testPasswordTooShortReturns400(): void + { + $response = self::$http->post('/user', [ + 'json' => ['username' => 'newuser', 'email' => 'test@example.com', 'password' => '123'], + ]); + + $this->assertSame(400, $response->getStatusCode()); + } +} diff --git a/api/tests/Functional/User/UserSearchTest.php b/api/tests/Functional/User/UserSearchTest.php new file mode 100644 index 0000000..546a85e --- /dev/null +++ b/api/tests/Functional/User/UserSearchTest.php @@ -0,0 +1,90 @@ +db(); + $pdo->exec('SET FOREIGN_KEY_CHECKS = 0'); + foreach (['user', 'sessions', 'access_tokens', 'access_tokens_scopes', 'refresh_tokens', 'sessions_scopes'] as $t) { + $pdo->exec("TRUNCATE TABLE `{$t}`"); + } + $pdo->exec('SET FOREIGN_KEY_CHECKS = 1'); + } + + public function testSearchMatchesOnUsername(): void + { + $this->createUser(['username' => 'findme', 'email' => 'findme@example.com', 'realname' => 'Find Me']); + $searcher = $this->createUser(['username' => 'searcher', 'email' => 'searcher@example.com']); + $token = $this->getAccessToken($searcher['username'], $searcher['plain_password'], ['users:search']); + + $response = self::$http->post('/user/search', [ + 'headers' => $this->bearer($token), + 'json' => ['search' => 'findme'], + ]); + + $this->assertSame(200, $response->getStatusCode()); + $body = json_decode((string) $response->getBody(), true); + $usernames = array_column($body, 'username'); + $this->assertContains('findme', $usernames); + } + + public function testSearchMatchesOnRealname(): void + { + $this->createUser(['username' => 'jsmith', 'email' => 'jsmith@example.com', 'realname' => 'John Smith']); + $searcher = $this->createUser(['username' => 'searcher2', 'email' => 'searcher2@example.com']); + $token = $this->getAccessToken($searcher['username'], $searcher['plain_password'], ['users:search']); + + $response = self::$http->post('/user/search', [ + 'headers' => $this->bearer($token), + 'json' => ['search' => 'John'], + ]); + + $this->assertSame(200, $response->getStatusCode()); + $body = json_decode((string) $response->getBody(), true); + $usernames = array_column($body, 'username'); + $this->assertContains('jsmith', $usernames); + } + + public function testResultsContainOnlyUsernameAndRealname(): void + { + $this->createUser(['username' => 'checkfields', 'email' => 'checkfields@example.com']); + $searcher = $this->createUser(['username' => 'searcher3', 'email' => 'searcher3@example.com']); + $token = $this->getAccessToken($searcher['username'], $searcher['plain_password'], ['users:search']); + + $response = self::$http->post('/user/search', [ + 'headers' => $this->bearer($token), + 'json' => ['search' => 'checkfields'], + ]); + + $body = json_decode((string) $response->getBody(), true); + $this->assertNotEmpty($body); + $result = $body[0]; + $this->assertArrayHasKey('username', $result); + $this->assertArrayHasKey('realname', $result); + $this->assertArrayNotHasKey('password', $result); + $this->assertArrayNotHasKey('email', $result); + } + + public function testMissingSearchFieldReturns400(): void + { + $user = $this->createUser(); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['users:search']); + + $response = self::$http->post('/user/search', [ + 'headers' => $this->bearer($token), + 'json' => [], + ]); + + $this->assertSame(400, $response->getStatusCode()); + } +} diff --git a/api/tests/Functional/User/WatchesTest.php b/api/tests/Functional/User/WatchesTest.php new file mode 100644 index 0000000..3230f1b --- /dev/null +++ b/api/tests/Functional/User/WatchesTest.php @@ -0,0 +1,150 @@ +db(); + $pdo->exec('SET FOREIGN_KEY_CHECKS = 0'); + foreach ([ + 'user', 'plugin', 'user_plugin_watch', + 'sessions', 'access_tokens', 'access_tokens_scopes', + 'refresh_tokens', 'sessions_scopes', + ] as $t) { + $pdo->exec("TRUNCATE TABLE `{$t}`"); + } + $pdo->exec('SET FOREIGN_KEY_CHECKS = 1'); + } + + // ------------------------------------------------------------------------- + // POST /user/watchs (§16.1) + // ------------------------------------------------------------------------- + + public function testAddWatchCreatesRecord(): void + { + $user = $this->createUser(); + $plugin = $this->createPlugin(); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['user', 'plugins']); + + $response = self::$http->post('/user/watchs', [ + 'headers' => $this->bearer($token), + 'json' => ['plugin_key' => $plugin['key']], + ]); + + $this->assertSame(200, $response->getStatusCode()); + + $stmt = $this->db()->prepare( + 'SELECT COUNT(*) FROM `user_plugin_watch` WHERE user_id = ? AND plugin_id = ?' + ); + $stmt->execute([$user['id'], $plugin['id']]); + $this->assertSame(1, (int) $stmt->fetchColumn()); + } + + public function testAddWatchForUnknownPluginReturns404(): void + { + $user = $this->createUser(); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['user', 'plugins']); + + $response = self::$http->post('/user/watchs', [ + 'headers' => $this->bearer($token), + 'json' => ['plugin_key' => 'no_such_plugin'], + ]); + + $this->assertSame(404, $response->getStatusCode()); + } + + public function testAddWatchTwiceReturns400(): void + { + $user = $this->createUser(); + $plugin = $this->createPlugin(); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['user', 'plugins']); + + self::$http->post('/user/watchs', [ + 'headers' => $this->bearer($token), + 'json' => ['plugin_key' => $plugin['key']], + ]); + $response = self::$http->post('/user/watchs', [ + 'headers' => $this->bearer($token), + 'json' => ['plugin_key' => $plugin['key']], + ]); + + $this->assertSame(400, $response->getStatusCode()); + } + + // ------------------------------------------------------------------------- + // DELETE /user/watchs/:key (§16.2) + // ------------------------------------------------------------------------- + + public function testRemoveWatchDeletesRecord(): void + { + $user = $this->createUser(); + $plugin = $this->createPlugin(); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['user', 'plugins']); + + // Watch first + self::$http->post('/user/watchs', [ + 'headers' => $this->bearer($token), + 'json' => ['plugin_key' => $plugin['key']], + ]); + + $response = self::$http->delete('/user/watchs/' . $plugin['key'], [ + 'headers' => $this->bearer($token), + ]); + + $this->assertSame(200, $response->getStatusCode()); + + $stmt = $this->db()->prepare( + 'SELECT COUNT(*) FROM `user_plugin_watch` WHERE user_id = ? AND plugin_id = ?' + ); + $stmt->execute([$user['id'], $plugin['id']]); + $this->assertSame(0, (int) $stmt->fetchColumn()); + } + + public function testRemoveNonExistingWatchReturns404(): void + { + $user = $this->createUser(); + $plugin = $this->createPlugin(); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['user', 'plugins']); + + $response = self::$http->delete('/user/watchs/' . $plugin['key'], [ + 'headers' => $this->bearer($token), + ]); + + $this->assertSame(404, $response->getStatusCode()); + } + + // ------------------------------------------------------------------------- + // GET /user/watchs (§16.3) + // ------------------------------------------------------------------------- + + public function testGetWatchesReturnsPluginKeys(): void + { + $user = $this->createUser(); + $plugin1 = $this->createPlugin(['key' => 'watchplugin1', 'name' => 'Watch Plugin 1']); + $plugin2 = $this->createPlugin(['key' => 'watchplugin2', 'name' => 'Watch Plugin 2']); + $token = $this->getAccessToken($user['username'], $user['plain_password'], ['user', 'plugins']); + + self::$http->post('/user/watchs', ['headers' => $this->bearer($token), 'json' => ['plugin_key' => $plugin1['key']]]); + self::$http->post('/user/watchs', ['headers' => $this->bearer($token), 'json' => ['plugin_key' => $plugin2['key']]]); + + $response = self::$http->get('/user/watchs', ['headers' => $this->bearer($token)]); + + $this->assertSame(200, $response->getStatusCode()); + $body = json_decode((string) $response->getBody(), true); + $this->assertContains($plugin1['key'], $body); + $this->assertContains($plugin2['key'], $body); + } +} diff --git a/api/tests/Functional/VersionTest.php b/api/tests/Functional/VersionTest.php new file mode 100644 index 0000000..f260078 --- /dev/null +++ b/api/tests/Functional/VersionTest.php @@ -0,0 +1,75 @@ +db(); + $pdo->exec('SET FOREIGN_KEY_CHECKS = 0'); + foreach ([ + 'plugin', 'plugin_version', 'plugin_description', 'plugin_stars', + 'user', 'sessions', 'access_tokens', 'access_tokens_scopes', + 'refresh_tokens', 'sessions_scopes', + ] as $t) { + $pdo->exec("TRUNCATE TABLE `{$t}`"); + } + $pdo->exec('SET FOREIGN_KEY_CHECKS = 1'); + } + + private function authedToken(): string + { + $user = $this->createUser(); + return $this->getAccessToken($user['username'], $user['plain_password'], ['version', 'plugins']); + } + + public function testReturnsPluginsCompatibleWithVersion(): void + { + $plugin = $this->createPlugin(['key' => 'versionplug', 'name' => 'Version Plugin', 'active' => 1]); + $this->db()->prepare( + "INSERT INTO `plugin_version` (`plugin_id`, `num`, `compatibility`) VALUES (?, ?, ?)" + )->execute([$plugin['id'], '1.0.0', '9.5']); + $token = $this->authedToken(); + + $response = self::$http->get('/version/9.5/plugin', ['headers' => $this->bearer($token)]); + + $this->assertSame(200, $response->getStatusCode()); + $body = json_decode((string) $response->getBody(), true); + $this->assertIsArray($body); + $keys = array_column($body, 'key'); + $this->assertContains('versionplug', $keys); + } + + public function testReturnsEmptyListWhenNoPluginsMatchVersion(): void + { + $token = $this->authedToken(); + + $response = self::$http->get('/version/0.0.1/plugin', ['headers' => $this->bearer($token)]); + + $this->assertSame(200, $response->getStatusCode()); + $body = json_decode((string) $response->getBody(), true); + $this->assertSame([], $body); + } + + public function testDoesNotReturnPluginsForDifferentVersion(): void + { + $plugin = $this->createPlugin(['key' => 'wrongversionplug', 'name' => 'Wrong Version', 'active' => 1]); + $this->db()->prepare( + "INSERT INTO `plugin_version` (`plugin_id`, `num`, `compatibility`) VALUES (?, ?, ?)" + )->execute([$plugin['id'], '2.0.0', '10.0']); + $token = $this->authedToken(); + + $response = self::$http->get('/version/9.5/plugin', ['headers' => $this->bearer($token)]); + + $body = json_decode((string) $response->getBody(), true); + $this->assertIsArray($body); + $keys = array_column($body, 'key'); + $this->assertNotContains('wrongversionplug', $keys); + } +} diff --git a/api/tests/Functional/config.functional.php b/api/tests/Functional/config.functional.php new file mode 100644 index 0000000..1288a4c --- /dev/null +++ b/api/tests/Functional/config.functional.php @@ -0,0 +1,39 @@ + [ + 'driver' => 'mysql', + 'host' => getenv('TEST_DB_HOST') ?: 'localhost', + 'database' => getenv('TEST_DB_NAME') ?: 'glpi_plugins_functional_test', + 'username' => getenv('TEST_DB_USER') ?: 'glpi', + 'password' => getenv('TEST_DB_PASS') ?: 'glpi', + 'charset' => 'utf8', + 'collation' => 'utf8_general_ci', + 'prefix' => '', + 'strict' => false, + ], + 'log_queries' => false, + 'default_number_of_models_per_page' => 15, + 'recaptcha_secret' => 'test_recaptcha_secret', + 'client_url' => 'http://localhost', + 'api_url' => 'http://localhost/api', + 'plugin_max_consecutive_xml_fetch_fails' => 4, + 'glpi_plugin_directory_user_agent' => 'GlpiPluginDirectory/test', + 'msg_alerts' => [ + 'transport' => 'mail', + 'local_admins' => ['admin@example.com' => 'Admin'], + 'from' => ['noreply@example.com' => 'GLPi Plugins Test'], + 'subject_prefix' => '[TEST]', + ], + 'oauth' => [ + 'github' => ['clientId' => 'test_id', 'clientSecret' => 'test_secret'], + ], +]; diff --git a/api/tests/Functional/schema.sql b/api/tests/Functional/schema.sql new file mode 100644 index 0000000..c45fed6 --- /dev/null +++ b/api/tests/Functional/schema.sql @@ -0,0 +1,246 @@ +-- Functional test schema +-- All tables use CREATE TABLE IF NOT EXISTS so re-runs are safe. + +CREATE TABLE IF NOT EXISTS `apps` ( + `id` varchar(20) NOT NULL, + `name` varchar(255) NOT NULL, + `secret` varchar(255) NOT NULL DEFAULT '', + `redirect_uri` varchar(255) DEFAULT NULL, + `description` text DEFAULT NULL, + `user_id` int(11) DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `scopes` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `identifier` varchar(100) NOT NULL, + `description` text DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `identifier` (`identifier`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `user` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `username` varchar(255) NOT NULL, + `email` varchar(255) NOT NULL, + `password` varchar(255) DEFAULT NULL, + `realname` varchar(255) DEFAULT NULL, + `location` varchar(255) DEFAULT NULL, + `website` varchar(255) DEFAULT NULL, + `active` tinyint(1) NOT NULL DEFAULT 0, + `author_id` int(11) DEFAULT NULL, + `gravatar` varchar(32) DEFAULT NULL, + `created_at` datetime DEFAULT NULL, + `updated_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `username` (`username`), + UNIQUE KEY `email` (`email`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `sessions` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `owner_type` varchar(50) DEFAULT NULL, + `owner_id` int(11) DEFAULT NULL, + `app_id` varchar(20) NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `access_tokens` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `token` varchar(255) NOT NULL, + `session_id` int(11) NOT NULL, + `expire_time` int(11) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `token` (`token`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `access_tokens_scopes` ( + `access_token_id` int(11) NOT NULL, + `scope_id` int(11) NOT NULL, + PRIMARY KEY (`access_token_id`, `scope_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `refresh_tokens` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `token` varchar(255) NOT NULL, + `access_token_id` int(11) NOT NULL, + `expire_time` int(11) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `token` (`token`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `sessions_scopes` ( + `session_id` int(11) NOT NULL, + `scope_id` int(11) NOT NULL, + PRIMARY KEY (`session_id`, `scope_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `user_validation_token` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `token` varchar(255) NOT NULL, + `user_id` int(11) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `token` (`token`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `user_resetpassword_token` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `token` varchar(255) NOT NULL, + `user_id` int(11) NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `user_external_account` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL, + `service` varchar(50) NOT NULL, + `external_user_id` varchar(255) NOT NULL, + `token` varchar(255) DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `author` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + `username` varchar(255) DEFAULT NULL, + `gravatar` varchar(32) DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `plugin` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + `key` varchar(50) NOT NULL, + `logo_url` varchar(255) DEFAULT NULL, + `xml_url` varchar(255) DEFAULT NULL, + `homepage_url` varchar(255) DEFAULT NULL, + `download_url` varchar(255) DEFAULT NULL, + `issues_url` varchar(255) DEFAULT NULL, + `readme_url` varchar(255) DEFAULT NULL, + `changelog_url` varchar(255) DEFAULT NULL, + `license` varchar(50) DEFAULT NULL, + `date_added` datetime DEFAULT NULL, + `date_updated` datetime DEFAULT NULL, + `download_count` int(11) NOT NULL DEFAULT 0, + `xml_state` varchar(50) DEFAULT NULL, + `active` tinyint(1) NOT NULL DEFAULT 0, + `note` float DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `key` (`key`), + KEY `active` (`active`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `plugin_author` ( + `plugin_id` int(11) NOT NULL, + `author_id` int(11) NOT NULL, + PRIMARY KEY (`plugin_id`, `author_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `plugin_permission` ( + `plugin_id` int(11) NOT NULL, + `user_id` int(11) NOT NULL, + `admin` tinyint(1) NOT NULL DEFAULT 0, + `allowed_refresh_xml` tinyint(1) NOT NULL DEFAULT 0, + `allowed_change_xml_url` tinyint(1) NOT NULL DEFAULT 0, + `allowed_notifications` tinyint(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`plugin_id`, `user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `plugin_description` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `plugin_id` int(11) NOT NULL, + `lang` varchar(10) NOT NULL, + `short_description` text DEFAULT NULL, + `long_description` text DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `plugin_version` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `plugin_id` int(11) NOT NULL, + `num` varchar(50) NOT NULL, + `compatibility` varchar(50) NOT NULL, + `download_url` varchar(255) DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `plugin_download` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `plugin_id` int(11) NOT NULL, + `downloaded_at` datetime NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `plugin_screenshot` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `plugin_id` int(11) NOT NULL, + `url` varchar(255) NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `plugin_stars` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `plugin_id` int(11) NOT NULL, + `note` int(11) NOT NULL, + `date` datetime DEFAULT NULL, + `user_id` int(11) DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `plugin_xml_fetch_fails` ( + `plugin_id` int(11) NOT NULL, + `n` int(11) NOT NULL DEFAULT 0, + PRIMARY KEY (`plugin_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `plugin_lang` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `lang` varchar(10) NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `plugin_plugin_lang` ( + `plugin_id` int(11) NOT NULL, + `plugin_lang_id` int(11) NOT NULL, + PRIMARY KEY (`plugin_id`, `plugin_lang_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `tag` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `key` varchar(100) NOT NULL, + `lang` varchar(10) NOT NULL, + `tag` varchar(255) NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `plugin_tags` ( + `plugin_id` int(11) NOT NULL, + `tag_id` int(11) NOT NULL, + PRIMARY KEY (`plugin_id`, `tag_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `user_plugin_watch` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL, + `plugin_id` int(11) NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `auth_codes` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `code` varchar(255) NOT NULL, + `session_id` int(11) NOT NULL, + `expire_time` int(11) NOT NULL, + `redirect_uri` varchar(255) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `code` (`code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `message` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `name` varchar(255) DEFAULT NULL, + `email` varchar(255) DEFAULT NULL, + `subject` varchar(255) DEFAULT NULL, + `message` text DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; diff --git a/api/tests/Functional/seeds.sql b/api/tests/Functional/seeds.sql new file mode 100644 index 0000000..78f55de --- /dev/null +++ b/api/tests/Functional/seeds.sql @@ -0,0 +1,23 @@ +-- Built-in OAuth clients (webapp / glpidefault use empty secret) +INSERT INTO `apps` (`id`, `name`, `secret`) VALUES + ('webapp', 'Web App', ''), + ('glpidefault', 'GLPI Default', ''); + +-- All API scopes +INSERT INTO `scopes` (`identifier`, `description`) VALUES + ('plugins', 'Browse plugin list'), + ('plugins:search', 'Search plugins'), + ('plugin:card', 'Read single plugin details'), + ('plugin:star', 'Rate a plugin'), + ('plugin:submit', 'Submit a new plugin'), + ('plugin:download', 'Download a plugin'), + ('tags', 'Browse tag list'), + ('tag', 'Read single tag'), + ('authors', 'Browse author list'), + ('author', 'Read single author'), + ('version', 'Filter by GLPI version'), + ('user', 'Read/edit own profile'), + ('user:apps', 'Manage own API apps'), + ('user:externalaccounts','Manage linked OAuth accounts'), + ('users:search', 'Search users'), + ('message', 'Send contact message'); diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..fa0a637 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,61 @@ +# Functional test stack: Apache/PHP 7.4 + MySQL 8.0 +# Mirrors the production Apache setup — no PHP CLI server quirks. +# +# Usage (local or CI): +# docker compose -f docker-compose.test.yml run --rm phpunit + +services: + + mysql: + image: mysql:8.0 + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: glpi_plugins_functional_test + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 5s + timeout: 5s + retries: 10 + + api: + build: + context: . + dockerfile: .docker/test/api/Dockerfile + volumes: + - ./api:/var/www/api + environment: + APP_CONFIG_FILE: /var/www/api/tests/Functional/config.functional.php + TEST_DB_HOST: mysql + TEST_DB_NAME: glpi_plugins_functional_test + TEST_DB_USER: root + TEST_DB_PASS: root + depends_on: + mysql: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost/"] + interval: 5s + timeout: 5s + retries: 10 + + phpunit: + build: + context: . + dockerfile: .docker/test/Dockerfile + volumes: + - ./api:/app + working_dir: /app + depends_on: + api: + condition: service_healthy + mysql: + condition: service_healthy + environment: + TEST_DB_HOST: mysql + TEST_DB_NAME: glpi_plugins_functional_test + TEST_DB_USER: root + TEST_DB_PASS: root + TEST_API_URL: http://api/ + command: > + sh -c "composer install --no-interaction --prefer-dist --ignore-platform-reqs && + vendor/bin/phpunit --configuration phpunit.xml --testsuite Functional" diff --git a/specs/README.md b/specs/README.md new file mode 100644 index 0000000..8a527c1 --- /dev/null +++ b/specs/README.md @@ -0,0 +1,47 @@ +# GLPI Plugin Directory — Modernization Specs + +This folder contains specifications for modernizing the GLPI Plugin Directory. +The project consists of a **Slim 2 PHP REST API** and an **AngularJS 1 frontend**. + +## Modernization Roadmap + +| Step | Status | Description | +|------|--------|-------------| +| 1 | 🔄 In Progress | Add unit, functional, and E2E tests | +| 2 | ⏳ Planned | Upgrade backend (PHP 8+, Slim 4, modern OAuth) | +| 3 | ⏳ Planned | Migrate frontend to a modern framework (Vue 3 / React) | +| 4 | ⏳ Planned | CI/CD pipeline, containerization | + +## Repository Layout + +``` +plugins/ +├── api/ # Slim 2 PHP backend +│ ├── src/ +│ │ ├── core/ # Tool, DB, Mailer, BackgroundTasks, OAuthClient +│ │ ├── endpoints/ # Route handlers (one file per resource) +│ │ ├── models/ # Eloquent ORM models +│ │ ├── exceptions/ # Custom exception hierarchy +│ │ └── oauthserver/ # OAuth2 server implementation +│ ├── mailtemplates/ # Twig email templates +│ └── misc/ # Background task runner scripts +├── frontend/ # AngularJS 1 SPA +│ ├── app/ +│ │ ├── scripts/ # Controllers, services, directives, filters +│ │ └── views/ # HTML templates +│ └── test/ # Existing Karma/Jasmine specs +└── specs/ # ← You are here + ├── api/ + │ └── endpoints.md # Full API endpoint reference + └── testing/ + ├── unit.md # Unit test specifications + ├── functional.md # Functional/integration test specifications + └── e2e.md # End-to-end test specifications +``` + +## Specs Index + +- [API Endpoint Reference](api/endpoints.md) +- [Unit Test Specifications](testing/unit.md) +- [Functional Test Specifications](testing/functional.md) +- [E2E Test Specifications](testing/e2e.md) diff --git a/specs/testing/functional.md b/specs/testing/functional.md new file mode 100644 index 0000000..356938d --- /dev/null +++ b/specs/testing/functional.md @@ -0,0 +1,381 @@ +# Functional Test Specifications + +Functional tests exercise the full HTTP stack against a **real test database**. +Requests are sent over HTTP to a real server process — no framework-specific test +client is used — so the suite is portable across Slim, Symfony, or any other PHP +framework. + +## Stack + +| Tool | Purpose | +|------|---------| +| [PHPUnit](https://phpunit.de/) ≥ 10 | Test runner | +| [Guzzle](https://docs.guzzlephp.org/) (`guzzlehttp/guzzle`) | HTTP client — framework-agnostic | +| PHP built-in server (`php -S`) | Real HTTP server, started as a subprocess | +| MySQL (dedicated test DB) | Test database; SQLite where feasible | +| Database migrations/seeds | Repeatable test fixtures | + +**Location:** `api/tests/Functional/` + +### Why PHP built-in server + Guzzle + +Using a real HTTP server means tests are completely decoupled from the framework +layer. The test code only knows about HTTP — methods, headers, status codes, and +JSON bodies. This makes the suite reusable if the backend framework changes. + +A `symfony/process`-managed `php -S` subprocess is started once per suite in +`setUpBeforeClass` and stopped in `tearDownAfterClass`. Each test creates a +`GuzzleHttp\Client` pointed at that server. + +``` +phpunit + └── FunctionalTestCase (setUpBeforeClass) + ├── start: php -S localhost:PORT -t api/public api/public/index.php + ├── wait for port to be ready + └── tearDownAfterClass: terminate process +``` + +### Test database strategy + +- Use a dedicated MySQL test database (separate from dev/prod). +- Run migrations once before the suite (`setUpBeforeClass`). +- Seed fixtures before each test class; wrap each test in a transaction and roll + back in `tearDown` to keep tests isolated. + +--- + +## 2. Authentication (`POST /oauth/authorize`) + +### 2.1 Password grant — success +- Authenticating with valid `username` + `password` returns `200` with `access_token`, `refresh_token`, `expires_in` +- Token is stored in the `access_tokens` table + +### 2.2 Password grant — failures +- Wrong password returns `401` +- Unknown username returns `401` +- Inactive account returns `401` + +### 2.3 Refresh token grant +- A valid `refresh_token` issues a new `access_token` and new `refresh_token` +- An expired or unknown `refresh_token` returns `401` + +### 2.4 Client credentials grant +- Valid `client_id` + `client_secret` returns an access token +- Unknown `client_id` returns `401` + +--- + +## 3. User Registration & Validation (`POST /user`, `GET /user/validatemail/:token`) + +### 3.1 Registration success +- Creates user with `active = false` +- Creates a `ValidationToken` record +- Returns `200` + +### 3.2 Registration validation +- Missing `username` → `400 InvalidField` +- `username` shorter than 4 chars → `400 InvalidField` +- `username` with special characters → `400 InvalidField` +- Duplicate `username` → `400 UnavailableName` +- Invalid `email` format → `400 InvalidField` +- Duplicate `email` → `400 UnavailableName` +- Weak `password` → `400 InvalidField` + +### 3.3 Email validation +- Valid token activates user (`active = true`), deletes token, returns access token +- Invalid/unknown token → `400 InvalidValidationToken` + +--- + +## 4. User Profile (`GET /user`, `PUT /user`, `POST /user/delete`) + +### 4.1 View profile +- Returns current user's fields including computed `gravatar` +- Missing token → `401` + +### 4.2 Edit profile +- `realname`, `website` are updated and persisted +- `password` is hashed before saving +- Changing `email` with an address not in external accounts is silently ignored +- Changing `email` to one verified via GitHub updates it and activates the account + +### 4.3 Delete account +- Correct password deletes user, sessions, and access tokens +- Wrong password → `401 InvalidCredentials` +- Short/invalid password format → `400 InvalidField` + +--- + +## 5. Password Reset (`POST /user/sendpasswordresetlink`, `PUT /user/password`) + +### 5.1 Send reset link +- Known email creates a `ResetPasswordToken` and returns `200` +- Unknown email → `404 AccountNotFound` +- Missing email field → `400 InvalidField` + +### 5.2 Reset password +- Valid token + new password updates password hash, deletes all reset tokens +- Invalid token → `400 WrongPasswordResetToken` +- Missing password → `400 InvalidField` + +--- + +## 6. Plugin Listing + +### 6.1 `GET /plugin` +- Returns only `active = 1` plugins +- Response is paginated according to `x-range` header +- `accept-range` and `content-range` headers are set correctly +- Without auth token → `401` + +### 6.2 `GET /plugin/popular`, `/plugin/new`, `/plugin/trending`, `/plugin/updated` +- Return only active plugins +- Results are ordered correctly (popular: `download_count DESC`, new: `date_added DESC`) + +### 6.3 `GET /plugin/:key` +- Returns all relations (descriptions, authors, versions, screenshots, tags) +- Includes `watched: true` for a user who is watching the plugin +- Inactive plugin → `404 ResourceNotFound` +- Unknown key → `404 ResourceNotFound` + +### 6.4 `GET /plugin/rss_new`, `/plugin/rss_updated` +- Returns valid RSS XML without authentication +- Contains at most 30 entries + +--- + +## 7. Plugin Submission (`POST /plugin`) + +### 7.1 Validation +- Missing `plugin_url` → `400 InvalidField` +- Duplicate `xml_url` → `400 UnavailableName` +- XML not fetchable → `400 InvalidXML` +- Duplicate plugin `key` in XML → `400 UnavailableName` + +### 7.2 Success +- Creates plugin with `active = false` +- Creates a permission entry with `admin = true` for the submitting user +- Returns `{ "success": true }` + +--- + +## 8. Plugin Rating (`POST /plugin/star`) + +- Creates a `PluginStar` record +- Returns updated `new_average` +- Non-numeric `plugin_id` or `note` → `400` +- Non-existent plugin → `400` +- Missing required scope → `401` + +--- + +## 9. Plugin Download (`GET /plugin/:key/download`) + +- Increments `download_count` by 1 +- Creates a `PluginDownload` record +- Redirects to `download_url` with HTTP `301` (when `Accept` is not `application/json`) +- Returns `200` without redirect when `Accept: application/json` + +--- + +## 10. Plugin Permissions + +### 10.1 View permissions (`GET /plugin/:key/permissions`) +- Non-admin user → `401 LackPermission` +- Admin user → `200` with array of permission objects + +### 10.2 Add permission (`POST /plugin/:key/permissions`) +- Adds permission for a valid username +- Non-existent username → `404 ResourceNotFound` +- Already has permission → `400 RightAlreadyExist` +- Non-admin caller → `401 LackPermission` + +### 10.3 Delete permission (`DELETE /plugin/:key/permissions/:username`) +- Non-admin user can remove their own non-admin permission +- Admin cannot delete another admin's permission → `401 CannotDeleteAdmin` +- Non-admin cannot delete another user's permission → `401 LackPermission` +- Non-existent permission → `400 RightDoesntExist` + +### 10.4 Modify permission (`PATCH /plugin/:key/permissions/:username`) +- Sets `allowed_refresh_xml`, `allowed_change_xml_url`, `allowed_notifications` +- Invalid `right` value → `400 InvalidField` +- Missing `set` field → `400 InvalidField` +- Non-admin caller → `401 LackPermission` + +--- + +## 11. Author Panel (`GET /panel/plugin/:key`, `POST /panel/plugin/:key`) + +### 11.1 View +- User with `admin` flag → `200` with card, tags, statistics +- User with no permission → `401 LackPermission` + +### 11.2 Update XML URL +- Valid URL with matching key and authors updates `xml_url` +- URL not fetchable → `400 InvalidXML` +- Key mismatch in new XML → `400 DifferentPluginSignature` +- Author removed in new XML → `400 DifferentPluginSignature` +- Non-URL string → `400 InvalidField` + +--- + +## 12. Authors + +### 12.1 `GET /author` / `GET /author/top` +- Returns paginated list +- `GET /author` returns only authors with `plugin_count > 0` + +### 12.2 `GET /author/:id` +- Returns `username` and `gravatar` when author is linked to a user account +- Unknown ID → `404 ResourceNotFound` + +### 12.3 `GET /author/:id/plugin` +- Returns only the author's active plugins + +### 12.4 `POST /claimauthorship` +- Known author name → `200` (email sent to admins) +- Unknown author name → `404 ResourceNotFound` +- Invalid reCAPTCHA → `400 InvalidRecaptcha` + +--- + +## 13. Tags + +### 13.1 `GET /tags` / `GET /tags/top` +- Returns tags ordered by `plugin_count DESC` +- Falls back to `en` when no tags exist for requested language + +### 13.2 `GET /tags/:id` +- Known tag key → `200` with tag data +- Unknown key → `404 ResourceNotFound` + +### 13.3 `GET /tags/:id/plugin` +- Returns plugins that have the specified tag + +--- + +## 14. Search (`POST /search`) + +- Returns plugins matching `name`, `key`, `short_description`, or `long_description` +- Query shorter than 2 chars → `400` +- Missing `query_string` → `400` +- Results ordered by `download_count DESC`, `note DESC`, `name ASC` + +--- + +## 15. Version Filter (`GET /version/:version/plugin`) + +- Returns plugins compatible with the given GLPI version +- Returns empty list (not error) when no plugins match + +--- + +## 16. User Watches + +### 16.1 `POST /user/watchs` +- Creates a `PluginWatch` record +- Unknown `plugin_key` → `404 ResourceNotFound` +- Already watched → `400 AlreadyWatched` + +### 16.2 `DELETE /user/watchs/:key` +- Removes the watch record +- Not watching → `404` + +### 16.3 `GET /user/watchs` +- Returns an array of plugin keys + +--- + +## 17. User Search (`POST /user/search`) + +- Matches on `username`, `realname` (partial), or `email` (exact) +- Missing `search` field → `400 InvalidField` +- Results include only `username` and `realname` + +--- + +## 18. External Accounts + +### 18.1 `GET /user/external_accounts` +- Returns list of linked external accounts for current user + +### 18.2 `DELETE /user/external_accounts/:id` +- Removes the link +- Last external account with no password set → `401 NoCredentialsLeft` + +--- + +## 19. User Apps + +### 19.1 `POST /user/apps` +- Creates app with random `client_id` and `secret` +- Duplicate name (same user) → `400 UnavailableName` +- Invalid name → `400 InvalidField` + +### 19.2 `GET /user/apps`, `GET /user/apps/:id` +- Returns only apps belonging to the current user +- Unknown app ID → `404 ResourceNotFound` + +### 19.3 `PUT /user/apps/:id` +- Updates modifiable fields; auto-generated fields unchanged + +### 19.4 `DELETE /user/apps/:id` +- Removes the app record + +--- + +## 20. Contact Messages (`POST /message`) + +- Creates a `Message` record in the database +- Missing required contact field → `400 MissingField` +- Invalid email format → `400 InvalidField` +- Failed reCAPTCHA → `400 InvalidRecaptcha` + +--- + +## Running the Tests + +```bash +cd api +cp config.example.php config.test.php # set test DB credentials +APP_ENV=test vendor/bin/phpunit --configuration phpunit.xml --testsuite Functional +``` + +### Base test case skeleton + +```php +abstract class FunctionalTestCase extends \PHPUnit\Framework\TestCase +{ + private static \Symfony\Component\Process\Process $server; + protected static GuzzleHttp\Client $http; + + public static function setUpBeforeClass(): void + { + // Start PHP built-in server + self::$server = new \Symfony\Component\Process\Process( + ['php', '-S', 'localhost:8099', 'public/index.php'], + __DIR__ . '/../../' + ); + self::$server->start(); + // Wait until the port accepts connections + usleep(200_000); + + self::$http = new \GuzzleHttp\Client([ + 'base_uri' => 'http://localhost:8099', + 'http_errors' => false, // don't throw on 4xx/5xx + 'allow_redirects' => false, + ]); + } + + public static function tearDownAfterClass(): void + { + self::$server->stop(); + } +} +``` + +> `http_errors: false` is important — it lets tests assert on 4xx/5xx responses +> instead of catching Guzzle exceptions. + +> `allow_redirects: false` is important for endpoints that return HTTP `301` +> (e.g. plugin download) so the redirect itself can be asserted. From 5524dc47e196c27e6c0000b632fcde8b5aac33be Mon Sep 17 00:00:00 2001 From: Alexandre Delaunay Date: Fri, 27 Mar 2026 12:10:33 +0100 Subject: [PATCH 4/4] e2e tests --- .docker/e2e/frontend/Dockerfile | 33 +++++ .docker/e2e/frontend/nginx.conf | 20 +++ .github/workflows/tests_e2e.yml | 59 +++++++++ .gitignore | 2 + api/config.e2e.php | 36 +++++ api/tests/E2E/fixtures/fields-updated.xml | 35 +++++ api/tests/E2E/seeds.sql | 63 +++++++++ docker-compose.e2e.yml | 50 +++++++ frontend/e2e/auth.spec.js | 78 +++++++++++ frontend/e2e/helpers.js | 65 +++++++++ frontend/e2e/homepage.spec.js | 39 ++++++ frontend/e2e/package-lock.json | 79 +++++++++++ frontend/e2e/package.json | 15 +++ frontend/e2e/panel.spec.js | 66 ++++++++++ frontend/e2e/playwright.config.js | 39 ++++++ frontend/e2e/plugin.spec.js | 47 +++++++ frontend/e2e/search.spec.js | 42 ++++++ specs/testing/e2e.md | 152 ++++++++++++++++++++++ 18 files changed, 920 insertions(+) create mode 100644 .docker/e2e/frontend/Dockerfile create mode 100644 .docker/e2e/frontend/nginx.conf create mode 100644 .github/workflows/tests_e2e.yml create mode 100644 api/config.e2e.php create mode 100644 api/tests/E2E/fixtures/fields-updated.xml create mode 100644 api/tests/E2E/seeds.sql create mode 100644 docker-compose.e2e.yml create mode 100644 frontend/e2e/auth.spec.js create mode 100644 frontend/e2e/helpers.js create mode 100644 frontend/e2e/homepage.spec.js create mode 100644 frontend/e2e/package-lock.json create mode 100644 frontend/e2e/package.json create mode 100644 frontend/e2e/panel.spec.js create mode 100644 frontend/e2e/playwright.config.js create mode 100644 frontend/e2e/plugin.spec.js create mode 100644 frontend/e2e/search.spec.js create mode 100644 specs/testing/e2e.md diff --git a/.docker/e2e/frontend/Dockerfile b/.docker/e2e/frontend/Dockerfile new file mode 100644 index 0000000..fcc828e --- /dev/null +++ b/.docker/e2e/frontend/Dockerfile @@ -0,0 +1,33 @@ +# Stage 1 — build the AngularJS SPA +# Uses the same Node 10 + Ruby/Compass combination as the development Docker. +FROM debian:bullseye-slim AS builder + +RUN apt-get update -q \ + && apt-get install -y -q --no-install-recommends \ + ca-certificates curl git unzip ruby-dev build-essential \ + && mkdir /opt/nodejs \ + && curl -sL https://nodejs.org/download/release/v10.24.1/node-v10.24.1-linux-x64.tar.gz \ + | tar -xz --strip-components=1 -C /opt/nodejs \ + && ln -s /opt/nodejs/bin/node /usr/bin/node \ + && ln -s /opt/nodejs/bin/npm /usr/bin/npm \ + && gem install compass \ + && npm install -g bower grunt-cli \ + && rm -rf /var/lib/apt/lists/* + +ENV PATH="/opt/nodejs/bin/:${PATH}" + +WORKDIR /build/frontend +COPY frontend/package.json frontend/bower.json frontend/.bowerrc ./ +RUN npm install +RUN bower install --allow-root + +COPY frontend/ ./ +# Point the API constant to /api — nginx (stage 2) proxies it to the api container. +RUN sed 's|http://glpiplugindirectory/api|/api|' app/scripts/conf.example.js \ + > app/scripts/conf.js \ + && grunt build --force + +# Stage 2 — serve dist/ with nginx + proxy /api to the PHP backend +FROM nginx:1.25-alpine +COPY --from=builder /build/frontend/dist /usr/share/nginx/html +COPY .docker/e2e/frontend/nginx.conf /etc/nginx/conf.d/default.conf diff --git a/.docker/e2e/frontend/nginx.conf b/.docker/e2e/frontend/nginx.conf new file mode 100644 index 0000000..a31b8d2 --- /dev/null +++ b/.docker/e2e/frontend/nginx.conf @@ -0,0 +1,20 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + # Proxy all /api requests to the PHP backend (strips /api prefix). + # The AngularJS app uses API_URL = '/api', so /api/plugin becomes + # http://api/plugin on the backend. + location /api { + proxy_pass http://api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Authorization $http_authorization; + } + + # SPA routing: serve index.html for all unmatched frontend routes. + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/.github/workflows/tests_e2e.yml b/.github/workflows/tests_e2e.yml new file mode 100644 index 0000000..e356407 --- /dev/null +++ b/.github/workflows/tests_e2e.yml @@ -0,0 +1,59 @@ +name: E2E Tests + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + e2e: + name: E2E tests (Playwright) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Build and start the E2E stack + run: docker compose -f docker-compose.e2e.yml up -d --build + + - name: Wait for the frontend to be ready + run: | + for i in $(seq 1 30); do + curl -sf http://localhost:4200 && break + echo "Waiting for frontend… ($i/30)" + sleep 5 + done + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/e2e/package-lock.json + + - name: Install dependencies + working-directory: frontend/e2e + run: npm ci + + - name: Install Playwright browsers + working-directory: frontend/e2e + run: npx playwright install --with-deps chromium + + - name: Run E2E tests + working-directory: frontend/e2e + env: + E2E_BASE_URL: http://localhost:4200 + run: npx playwright test + + - name: Upload test report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: frontend/e2e/playwright-report/ + retention-days: 7 + + - name: Tear down + if: always() + run: docker compose -f docker-compose.e2e.yml down -v diff --git a/.gitignore b/.gitignore index 21ccf2b..99f3876 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ npm-debug.log api/config.php *.swp /frontend/app/scripts/conf.js +/frontend/**/node_modules +/frontend/e2e/test-results /misc/illuminate_queries.log .vagrant /docker-compose.override.yaml diff --git a/api/config.e2e.php b/api/config.e2e.php new file mode 100644 index 0000000..c32144b --- /dev/null +++ b/api/config.e2e.php @@ -0,0 +1,36 @@ + [ + 'driver' => 'mysql', + 'host' => getenv('TEST_DB_HOST') ?: 'localhost', + 'database' => getenv('TEST_DB_NAME') ?: 'glpi_plugins_e2e', + 'username' => getenv('TEST_DB_USER') ?: 'glpi', + 'password' => getenv('TEST_DB_PASS') ?: 'glpi', + 'charset' => 'utf8', + 'collation' => 'utf8_general_ci', + 'prefix' => '', + 'strict' => false, + ], + 'log_queries' => false, + 'default_number_of_models_per_page' => 15, + 'recaptcha_secret' => 'test_recaptcha_secret', + 'client_url' => 'http://localhost:4200', + 'api_url' => 'http://localhost:4200/api', + 'plugin_max_consecutive_xml_fetch_fails' => 4, + 'glpi_plugin_directory_user_agent' => 'GlpiPluginDirectory/e2e', + 'msg_alerts' => [ + 'transport' => 'mail', + 'local_admins' => ['admin@example.com' => 'Admin'], + 'from' => ['noreply@example.com' => 'GLPi Plugins'], + 'subject_prefix' => '[E2E]', + ], + 'oauth' => [ + 'github' => ['clientId' => 'test_id', 'clientSecret' => 'test_secret'], + ], +]; diff --git a/api/tests/E2E/fixtures/fields-updated.xml b/api/tests/E2E/fixtures/fields-updated.xml new file mode 100644 index 0000000..2d16115 --- /dev/null +++ b/api/tests/E2E/fixtures/fields-updated.xml @@ -0,0 +1,35 @@ + + + Fields + fields + stable + + + Add custom fields to GLPI objects. + + + Updated Fields plugin metadata for E2E panel save coverage. + + + https://example.com/fields + https://example.com/fields/download + + Plugin Author + + + + 1.2.0 + 10.0 + https://example.com/fields/1.2.0 + + + + en + + GPL-2.0 + + + inventory + + + \ No newline at end of file diff --git a/api/tests/E2E/seeds.sql b/api/tests/E2E/seeds.sql new file mode 100644 index 0000000..6336941 --- /dev/null +++ b/api/tests/E2E/seeds.sql @@ -0,0 +1,63 @@ +-- E2E seed data +-- Applied once when the stack starts. Provides enough content for all frontend suites. + +-- OAuth clients and scopes (same as functional seeds) +INSERT INTO `apps` (`id`, `name`, `secret`) VALUES + ('webapp', 'Web App', ''), + ('glpidefault', 'GLPI Default', ''); + +INSERT INTO `scopes` (`identifier`, `description`) VALUES + ('plugins', 'Browse plugin list'), + ('plugins:search', 'Search plugins'), + ('plugin:card', 'Read single plugin details'), + ('plugin:star', 'Rate a plugin'), + ('plugin:submit', 'Submit a new plugin'), + ('plugin:download', 'Download a plugin'), + ('tags', 'Browse tag list'), + ('tag', 'Read single tag'), + ('authors', 'Browse author list'), + ('author', 'Read single author'), + ('version', 'Filter by GLPI version'), + ('user', 'Read/edit own profile'), + ('user:apps', 'Manage own API apps'), + ('user:externalaccounts', 'Manage linked OAuth accounts'), + ('users:search', 'Search users'), + ('message', 'Send contact message'); + +-- Authors +INSERT INTO `author` (`id`, `name`) VALUES + (1, 'Plugin Author'); + +-- Active plugins +INSERT INTO `plugin` + (`id`, `name`, `key`, `xml_url`, `download_url`, `active`, `download_count`, `date_added`, `date_updated`, `xml_state`) +VALUES + (1, 'Fields', 'fields', 'http://example.com/fields.xml', 'http://example.com/fields.zip', 1, 120, NOW(), NOW(), 'passing'), + (2, 'Form Creator', 'formcreator', 'http://example.com/formcreator.xml', 'http://example.com/formcreator.zip', 1, 80, NOW(), NOW(), 'passing'); + +INSERT INTO `plugin_author` (`plugin_id`, `author_id`) VALUES (1, 1), (2, 1); + +INSERT INTO `plugin_description` (`plugin_id`, `lang`, `short_description`, `long_description`) VALUES + (1, 'en', 'Add custom fields to GLPI objects.', '# Fields\n\nAdd custom fields to any GLPI object type.'), + (2, 'en', 'Create custom forms in GLPI.', '# Form Creator\n\nCreate and manage custom forms.'); + +INSERT INTO `plugin_version` (`plugin_id`, `num`, `compatibility`) VALUES + (1, '1.0.0', '9.5'), + (1, '1.1.0', '10.0'), + (2, '2.0.0', '9.5'); + +-- Tags +INSERT INTO `tag` (`id`, `key`, `lang`, `tag`) VALUES + (1, 'inventory', 'en', 'Inventory'), + (2, 'forms', 'en', 'Forms'); + +INSERT INTO `plugin_tags` (`plugin_id`, `tag_id`) VALUES (1, 1), (2, 2); + +-- Test user (password: Password1) +INSERT INTO `user` (`id`, `username`, `email`, `password`, `realname`, `active`, `author_id`) VALUES + (1, 'testuser', 'testuser@example.com', + '$2y$12$1PbuQOawaJYZsLP/39w0juPu6sFG9Vn8aE17PSgBHXHpnWD6eJ.VW', + 'Test User', 1, 1); + +-- testuser has admin rights on the Fields plugin +INSERT INTO `plugin_permission` (`plugin_id`, `user_id`, `admin`) VALUES (1, 1, 1); diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml new file mode 100644 index 0000000..1997040 --- /dev/null +++ b/docker-compose.e2e.yml @@ -0,0 +1,50 @@ +# E2E test stack: Apache/PHP 7.4 API + AngularJS frontend + MySQL 8.0 +# +# Usage: +# docker compose -f docker-compose.e2e.yml up -d +# cd frontend/e2e && npm install && npx playwright install chromium +# E2E_BASE_URL=http://localhost:4200 npx playwright test + +services: + + mysql: + image: mysql:8.0 + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: glpi_plugins_e2e + volumes: + - ./api/tests/Functional/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql + - ./api/tests/E2E/seeds.sql:/docker-entrypoint-initdb.d/02-seeds.sql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 5s + timeout: 5s + retries: 10 + + api: + # Reuses the same Apache/PHP 7.4 image as the functional test stack + build: + context: . + dockerfile: .docker/test/api/Dockerfile + ports: + - "8099:80" + volumes: + - ./api:/var/www/api + environment: + APP_CONFIG_FILE: /var/www/api/config.e2e.php + TEST_DB_HOST: mysql + TEST_DB_NAME: glpi_plugins_e2e + TEST_DB_USER: root + TEST_DB_PASS: root + depends_on: + mysql: + condition: service_healthy + + frontend: + build: + context: . + dockerfile: .docker/e2e/frontend/Dockerfile + ports: + - "4200:80" + depends_on: + - api diff --git a/frontend/e2e/auth.spec.js b/frontend/e2e/auth.spec.js new file mode 100644 index 0000000..2061a56 --- /dev/null +++ b/frontend/e2e/auth.spec.js @@ -0,0 +1,78 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); +const { waitForAngular } = require('./helpers'); + +test.describe('F4 — Authentication', () => { + test('sign up: submitting the form shows a confirmation toast', async ({ page }) => { + const uniqueSuffix = `${Date.now()}${Math.floor(Math.random() * 10000)}`; + const username = `newuser${uniqueSuffix}`; + const email = `${username}@example.com`; + + await page.goto('/#/signup'); + await waitForAngular(page); + + await page.locator('[ng-model="user.username"]').fill(username); + await page.locator('[ng-model="user.email"]').fill(email); + await page.locator('[ng-model="password"]').fill('Passw0rd!'); + await page.locator('[ng-model="user.website"]').fill('https://testwebsite.com'); + await page.locator('[ng-model="password_repeat"]').fill('Passw0rd!'); + + await page.locator('#signup-button').click(); + await page.waitForURL('/#/'); + + // On success the controller shows a toast and redirects to featured + await expect(page.getByText(/check your mailbox/i)).toBeVisible(); + }); + + test('sign in: valid credentials log the user in', async ({ page }) => { + await page.goto('/#/signin'); + await waitForAngular(page); + + await page.locator('[ng-model="login"]').fill('testuser'); + await page.locator('[ng-model="password"]').fill('Password1'); + await page.locator('#signin form button[type="submit"]').click(); + + // Auth service shows this toast on success + await expect(page.getByText('You are now successfully logged in')).toBeVisible(); + }); + + test('sign in: wrong password shows an error toast', async ({ page }) => { + await page.goto('/#/signin'); + await waitForAngular(page); + + await page.locator('[ng-model="login"]').fill('testuser'); + await page.locator('[ng-model="password"]').fill('wrongpassword'); + await page.locator('#signin form button[type="submit"]').click(); + + // The interceptor shows a translated error; INVALID_CREDENTIALS or similar + await expect( + page.locator('md-toast, .md-toast-content').filter({ hasText: /.+/ }) + ).toBeVisible(); + // User stays on the sign-in page + await expect(page).toHaveURL(/#\/signin/); + }); + + test('sign out: user name disappears from the header', async ({ page }) => { + await page.goto('/#/signin'); + await waitForAngular(page); + + await page.locator('[ng-model="login"]').fill('testuser'); + await page.locator('[ng-model="password"]').fill('Password1'); + await page.locator('#signin form button[type="submit"]').click(); + await expect(page.getByText('You are now successfully logged in')).toBeVisible(); + + // Find and click the sign-out control in the user menu + await page.getByText('testuser').click(); + await page.getByText(/sign.?out|log.?out|disconnect/i).click(); + + await expect(page.getByText('You are now disconnected')).toBeVisible(); + }); + + test('GitHub OAuth: the GitHub login button is present', async ({ page }) => { + await page.goto('/#/signin'); + await waitForAngular(page); + + // The button contains a GitHub icon; test its presence only (OAuth flow is external) + await expect(page.locator('.fa-github').first()).toBeVisible(); + }); +}); diff --git a/frontend/e2e/helpers.js b/frontend/e2e/helpers.js new file mode 100644 index 0000000..9b3a83c --- /dev/null +++ b/frontend/e2e/helpers.js @@ -0,0 +1,65 @@ +/** + * Shared helpers for E2E tests. + * + * loginAs() bypasses the UI login form by obtaining a token directly from the + * API and injecting it into localStorage — the same keys the Auth service uses. + * Use the UI sign-in flow in auth.spec.js; use loginAs() everywhere else. + */ + + +/** + * Obtain an access token via the password grant and inject it into the page's + * localStorage so Angular's Auth service treats the session as authenticated. + * + * @param {import('@playwright/test').Page} page + * @param {string} username + * @param {string} password + */ +async function loginAs(page, username, password) { + const baseURL = process.env.E2E_BASE_URL || 'http://localhost:4200'; + + const resp = await page.request.post(`${baseURL}/api/oauth/authorize`, { + form: { + grant_type: 'password', + client_id: 'webapp', + username, + password, + scope: [ + 'plugins', 'plugins:search', 'plugin:card', 'plugin:star', + 'plugin:download', 'tags', 'tag', 'authors', 'author', 'version', + 'message', 'user', 'user:externalaccounts', 'user:apps', + 'plugin:submit', 'users:search', + ].join(' '), + }, + }); + + const { access_token, refresh_token, expires_in } = await resp.json(); + + await page.evaluate( + ([token, refresh, expiresAt]) => { + localStorage.setItem('access_token', token); + localStorage.setItem('refresh_token', refresh); + localStorage.setItem('access_token_expires_at', expiresAt); + localStorage.setItem('authed', 'true'); + }, + [access_token, refresh_token, String(Math.floor(Date.now() / 1000) + expires_in)], + ); + + // Angular's $httpProvider.defaults.headers is set in .config() which runs + // only once at bootstrap. Reload so Angular re-bootstraps and picks up the + // token from localStorage before any test navigation occurs. + await page.reload(); + await waitForAngular(page); +} + +/** + * Wait until the ng-cloak class is removed from , which means Angular + * has bootstrapped and the first digest cycle is complete. + * + * @param {import('@playwright/test').Page} page + */ +async function waitForAngular(page) { + await page.waitForFunction(() => !document.body.classList.contains('ng-cloak')); +} + +module.exports = { loginAs, waitForAngular }; diff --git a/frontend/e2e/homepage.spec.js b/frontend/e2e/homepage.spec.js new file mode 100644 index 0000000..a50fe9a --- /dev/null +++ b/frontend/e2e/homepage.spec.js @@ -0,0 +1,39 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); +const { waitForAngular } = require('./helpers'); + +test.describe('F1 — Homepage', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await waitForAngular(page); + }); + + test('page title identifies the site', async ({ page }) => { + await expect(page).toHaveTitle(/GLPi Plugins/i); + }); + + test('featured sections are visible', async ({ page }) => { + // The featured page shows four lists: Trending, New, Popular, Updated + for (const heading of ['Trending', 'New', 'Popular', 'Updated']) { + await expect(page.getByText(heading, { exact: true }).first()).toBeVisible(); + } + }); + + test('each section contains at least one plugin name', async ({ page }) => { + // Seed contains 2 active plugins — they should appear in the lists + const items = page.locator('md-list-item h4'); + await expect(items.first()).toBeVisible(); + const count = await items.count(); + expect(count).toBeGreaterThan(0); + }); + + test('clicking a plugin name navigates to the plugin detail page', async ({ page }) => { + const firstPlugin = page.locator('md-list-item h4').first(); + const name = await firstPlugin.textContent(); + await firstPlugin.click(); + // AngularJS hash routing: URL changes to #/plugin/ + await expect(page).toHaveURL(/#\/plugin\//); + // The plugin name appears in the detail header + await expect(page.locator('h2').filter({ hasText: name.trim() })).toBeVisible(); + }); +}); diff --git a/frontend/e2e/package-lock.json b/frontend/e2e/package-lock.json new file mode 100644 index 0000000..4521732 --- /dev/null +++ b/frontend/e2e/package-lock.json @@ -0,0 +1,79 @@ +{ + "name": "glpi-plugins-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "glpi-plugins-e2e", + "devDependencies": { + "@playwright/test": "^1.44.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/frontend/e2e/package.json b/frontend/e2e/package.json new file mode 100644 index 0000000..6cc9782 --- /dev/null +++ b/frontend/e2e/package.json @@ -0,0 +1,15 @@ +{ + "name": "glpi-plugins-e2e", + "private": true, + "devDependencies": { + "@playwright/test": "^1.44.0" + }, + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed", + "test:ui": "playwright test --ui" + }, + "engines": { + "node": ">=16" + } +} diff --git a/frontend/e2e/panel.spec.js b/frontend/e2e/panel.spec.js new file mode 100644 index 0000000..4aa2bca --- /dev/null +++ b/frontend/e2e/panel.spec.js @@ -0,0 +1,66 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); +const { loginAs, waitForAngular } = require('./helpers'); + +test.describe('F5 — User Panel', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await loginAs(page, 'testuser', 'Password1'); + await page.goto('/#/panel'); + await waitForAngular(page); + }); + + test('authenticated user sees the panel', async ({ page }) => { + await expect(page.getByText('My informations')).toBeVisible(); + }); + + test('plugin list shows the user\'s plugins', async ({ page }) => { + await expect(page.getByText('My plugins')).toBeVisible(); + await expect(page.locator('h4.plugin-name').filter({ hasText: 'Fields' })).toBeVisible(); + }); + + test('API keys section is reachable', async ({ page }) => { + await page.getByText('Manage API Keys', { exact: false }).click(); + await waitForAngular(page); + await expect(page).toHaveURL(/#\/panel\/apikeys/); + }); +}); + +test.describe('F6 — Plugin Author Panel', () => { + const updatedXmlUrl = 'http://api/tests/E2E/fixtures/fields-updated.xml'; + + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await loginAs(page, 'testuser', 'Password1'); + await page.goto('/#/panel/plugin/fields'); + await waitForAngular(page); + }); + + test('plugin name is shown in the panel header', async ({ page }) => { + await expect(page.locator('h2.plugin-name')).toHaveText('Fields'); + }); + + test('XML URL field is editable and persists on save', async ({ page }) => { + const xmlInput = page.locator('input[ng-model="plugin.card.xml_url"]'); + await xmlInput.click(); + await xmlInput.fill(updatedXmlUrl); + await expect(xmlInput).toHaveValue(updatedXmlUrl); + + await page.locator('form.plugin-settings button[type="submit"]').click(); + + // Reload and verify the new value is still there + await page.reload(); + await waitForAngular(page); + await expect(xmlInput).toHaveValue(updatedXmlUrl); + }); + + test('Refresh XML button is visible for admin', async ({ page }) => { + await expect(page.getByRole('button', { name: /refresh xml file/i })).toBeVisible(); + }); + + test('Refresh XML button triggers a response', async ({ page }) => { + await page.getByRole('button', { name: /refresh xml file/i }).click(); + // After refresh, xml_errors list updates (may be empty or show errors) + await expect(page.locator('.xml-errors')).toBeVisible(); + }); +}); diff --git a/frontend/e2e/playwright.config.js b/frontend/e2e/playwright.config.js new file mode 100644 index 0000000..07d3a3d --- /dev/null +++ b/frontend/e2e/playwright.config.js @@ -0,0 +1,39 @@ +// @ts-check +const { defineConfig, devices } = require('@playwright/test'); + +const path = require('path'); +const COMPOSE_FILE = path.resolve(__dirname, '../../docker-compose.e2e.yml'); + +module.exports = defineConfig({ + testDir: '.', + testMatch: '**/*.spec.js', + + // Start the Docker stack automatically if E2E_BASE_URL is not already set + // (i.e. not pointing at an externally managed server). + webServer: process.env.E2E_BASE_URL ? undefined : { + command: `docker compose -f ${COMPOSE_FILE} up --build`, + url: 'http://localhost:4200', + timeout: 300_000, // frontend image build can take a few minutes + reuseExistingServer: true, + stdout: 'pipe', + stderr: 'pipe', + }, + timeout: 30_000, + expect: { timeout: 8_000 }, + fullyParallel: false, // AngularJS SPA shares a single backend; run serially + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list', + + use: { + baseURL: process.env.E2E_BASE_URL || 'http://localhost:4200', + trace: 'on-first-retry', + // The app uses hash-based routing (#/plugin/fields); no need for JS-aware navigation. + // Increase navigation timeout for the first load (Angular bootstrap + anon token fetch). + navigationTimeout: 15_000, + }, + + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + ], +}); diff --git a/frontend/e2e/plugin.spec.js b/frontend/e2e/plugin.spec.js new file mode 100644 index 0000000..431c0c1 --- /dev/null +++ b/frontend/e2e/plugin.spec.js @@ -0,0 +1,47 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); +const { loginAs, waitForAngular } = require('./helpers'); + +test.describe('F2 — Plugin detail page', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/#/plugin/fields'); + await waitForAngular(page); + }); + + test('plugin name is visible in the header', async ({ page }) => { + await expect(page.locator('.plugin_name h2')).toHaveText('Fields'); + }); + + test('author name is present', async ({ page }) => { + await expect(page.locator('.inline-authors').first()).toContainText('Plugin Author'); + }); + + test('at least one version compatibility badge is shown', async ({ page }) => { + await expect(page.locator('.pill.bg_lightblue').first()).toBeVisible(); + }); + + test('description tab content is rendered', async ({ page }) => { + await expect(page.locator('.description .markdown, .description p').first()).toBeVisible(); + }); + + test('watch button is hidden when not logged in', async ({ page }) => { + await expect(page.locator('.watch-button')).toBeHidden(); + }); + + test('watch button is visible and toggles state when logged in', async ({ page }) => { + await loginAs(page, 'testuser', 'Password1'); + await page.reload(); + await waitForAngular(page); + + const watchBtn = page.locator('.watch-button'); + await expect(watchBtn).toBeVisible(); + + // Click to watch — icon switches from eye to eye-slash + await watchBtn.click(); + await expect(watchBtn.locator('.fa-eye-slash')).toBeVisible(); + + // Click again to unwatch + await watchBtn.click(); + await expect(watchBtn.locator('.fa-eye')).toBeVisible(); + }); +}); diff --git a/frontend/e2e/search.spec.js b/frontend/e2e/search.spec.js new file mode 100644 index 0000000..6926208 --- /dev/null +++ b/frontend/e2e/search.spec.js @@ -0,0 +1,42 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); +const { waitForAngular } = require('./helpers'); + +test.describe('F3 — Search', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await waitForAngular(page); + }); + + test('typing a query shows matching results', async ({ page }) => { + const searchBox = page.getByLabel('Search'); + await searchBox.fill('Fields'); + await searchBox.press('Enter'); + + await waitForAngular(page); + // Results list: each result has a plugin name link + const results = page.locator('.result h2.name a'); + await expect(results.first()).toBeVisible(); + await expect(results.first()).toContainText(/fields/i); + }); + + test('each result shows a plugin name linking to the detail page', async ({ page }) => { + const searchBox = page.getByLabel('Search'); + await searchBox.fill('Fields'); + await searchBox.press('Enter'); + + await waitForAngular(page); + const link = page.locator('.result h2.name a').first(); + await link.click(); + await expect(page).toHaveURL(/#\/plugin\//); + }); + + test('a query with no matches shows the empty-state message', async ({ page }) => { + const searchBox = page.getByLabel('Search'); + await searchBox.fill('xyznonexistentplugin'); + await searchBox.press('Enter'); + + await waitForAngular(page); + await expect(page.getByText('No result')).toBeVisible(); + }); +}); diff --git a/specs/testing/e2e.md b/specs/testing/e2e.md new file mode 100644 index 0000000..d210b4e --- /dev/null +++ b/specs/testing/e2e.md @@ -0,0 +1,152 @@ +# End-to-End Test Specifications + +E2E tests exercise the **full stack** through a real browser. They are the slowest +layer but give the highest confidence before a release. + +## Stack + +| Tool | Purpose | +|------|---------| +| [Playwright](https://playwright.dev/) | Browser automation | +| Docker Compose | Full stack (API + DB + frontend) | + +**Suggested location:** `frontend/e2e/` + +--- + +## Environment Setup + +```yaml +# docker-compose.e2e.yml +services: + mysql: + image: mysql:8.0 + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: glpi_plugins_e2e + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 5s + timeout: 5s + retries: 10 + + api: + # Reuses the same Apache/PHP 7.4 image as the functional test stack + build: + context: . + dockerfile: .docker/test/api/Dockerfile + volumes: + - ./api:/var/www/api + environment: + APP_CONFIG_FILE: /var/www/api/config.e2e.php + TEST_DB_HOST: mysql + TEST_DB_NAME: glpi_plugins_e2e + TEST_DB_USER: root + TEST_DB_PASS: root + depends_on: + mysql: + condition: service_healthy + + frontend: + # Builds the AngularJS SPA (grunt build) and serves dist/ statically + build: + context: . + dockerfile: .docker/e2e/frontend/Dockerfile + ports: + - "4200:80" + depends_on: + - api +``` + +`config.e2e.php` mirrors `config.php` but points to the `glpi_plugins_e2e` database. + +Run: `docker compose -f docker-compose.e2e.yml up -d` + +--- + +## Frontend E2E Test Suites (Playwright) + +### Selector strategy + +Target **content and semantics**, not UI framework internals. +The frontend is currently AngularJS 1 but will be replaced; CSS classes, +`ng-*` attributes, and component structure will change. Tests must not. + +Preferred selectors, in order: + +1. **Visible text / labels** — `getByText('Sign in')`, `getByLabel('Password')` +2. **ARIA roles** — `getByRole('button', { name: 'Watch' })`, `getByRole('navigation')` +3. **Semantic HTML** — `

`, `