diff --git a/.docker/e2e/frontend/Dockerfile b/.docker/e2e/frontend/Dockerfile
new file mode 100644
index 00000000..fcc828e0
--- /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 00000000..a31b8d26
--- /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/.docker/test/Dockerfile b/.docker/test/Dockerfile
new file mode 100644
index 00000000..01ddc5cc
--- /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 00000000..041773d4
--- /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 00000000..f4f4fa7d
--- /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_e2e.yml b/.github/workflows/tests_e2e.yml
new file mode 100644
index 00000000..e356407a
--- /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/.github/workflows/tests_phpunit.yml b/.github/workflows/tests_phpunit.yml
new file mode 100644
index 00000000..8f1c62be
--- /dev/null
+++ b/.github/workflows/tests_phpunit.yml
@@ -0,0 +1,58 @@
+name: Tests
+
+on:
+ push:
+ branches: [ master ]
+ pull_request:
+ branches: [ master ]
+
+jobs:
+ # ---------------------------------------------------------------------------
+ # Unit tests — no database required
+ # ---------------------------------------------------------------------------
+ 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 --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/.gitignore b/.gitignore
index de8d2fa6..99f3876e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,9 @@ 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
+api/.phpunit.result.cache
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 00000000..3a427f9e
--- /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.
diff --git a/api/composer.json b/api/composer.json
index dddd90c8..2a158b17 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 771d2698..b0f5b4de 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/config.e2e.php b/api/config.e2e.php
new file mode 100644
index 00000000..c32144b8
--- /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/phpunit.xml b/api/phpunit.xml
new file mode 100644
index 00000000..ce7b285e
--- /dev/null
+++ b/api/phpunit.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+ tests/Unit
+
+
+ tests/Functional
+
+
+
+
+
+ src
+
+
+
diff --git a/api/src/core/Tool.php b/api/src/core/Tool.php
index 66d8032c..4e1fb0dd 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 af7711e9..3db3b1d3 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 d7f25fd4..1001f9c9 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 45123b85..c00b29ff 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 58c86c28..e3aa716b 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 2ea6ccee..10c9cb99 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 aba840a5..16aa1571 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 60ce03c4..a9367b36 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/E2E/fixtures/fields-updated.xml b/api/tests/E2E/fixtures/fields-updated.xml
new file mode 100644
index 00000000..2d161154
--- /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 00000000..63369412
--- /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/api/tests/Functional/Auth/OAuthTest.php b/api/tests/Functional/Auth/OAuthTest.php
new file mode 100644
index 00000000..d46dea17
--- /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 00000000..0d82f7be
--- /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 00000000..9b2a49dc
--- /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 00000000..644acdb0
--- /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 00000000..0b49f3f9
--- /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 00000000..9e542705
--- /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 00000000..7f11c30b
--- /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 00000000..cd37d15f
--- /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 00000000..e89bbcfc
--- /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 00000000..7a4278ea
--- /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 00000000..105d08ee
--- /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 00000000..8d2d734c
--- /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 00000000..33992b53
--- /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 00000000..5e089f22
--- /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 00000000..279bae4e
--- /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 00000000..3bbfce85
--- /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 00000000..91b55efe
--- /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 00000000..546a85ea
--- /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 00000000..3230f1b3
--- /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 00000000..f2600783
--- /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 00000000..1288a4c5
--- /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 00000000..c45fed62
--- /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 00000000..78f55dea
--- /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/api/tests/Unit/Core/PaginatedCollectionTest.php b/api/tests/Unit/Core/PaginatedCollectionTest.php
new file mode 100644
index 00000000..329199b1
--- /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 00000000..be41398e
--- /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 00000000..109ff7c0
--- /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 00000000..b45c4a79
--- /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 00000000..821f714c
--- /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 00000000..736d22f6
--- /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 00000000..dcde5ae7
--- /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 00000000..fc621118
--- /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 00000000..e44d71db
--- /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 00000000..b5a92512
--- /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/docker-compose.e2e.yml b/docker-compose.e2e.yml
new file mode 100644
index 00000000..19970400
--- /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/docker-compose.test.yml b/docker-compose.test.yml
new file mode 100644
index 00000000..fa0a6371
--- /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/frontend/e2e/auth.spec.js b/frontend/e2e/auth.spec.js
new file mode 100644
index 00000000..2061a564
--- /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 00000000..9b3a83c7
--- /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 00000000..a50fe9a6
--- /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 00000000..45217324
--- /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 00000000..6cc97824
--- /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 00000000..4aa2bca9
--- /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 00000000..07d3a3d0
--- /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 00000000..431c0c19
--- /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 00000000..69262081
--- /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/README.md b/specs/README.md
new file mode 100644
index 00000000..8a527c16
--- /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/api/endpoints.md b/specs/api/endpoints.md
new file mode 100644
index 00000000..f9ee7119
--- /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/e2e.md b/specs/testing/e2e.md
new file mode 100644
index 00000000..d210b4e4
--- /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** — ``, `