diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..31f5f69 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,3 @@ +{ + "MD014": false +} diff --git a/composer.json b/composer.json index 6946796..39c4f29 100644 --- a/composer.json +++ b/composer.json @@ -13,13 +13,15 @@ "laminas/laminas-authentication": "^2.13", "psr/http-server-handler": "^1.0", "psr/http-server-middleware": "^1.0", - "psr/container": "^1.1 || ^2.0" + "psr/container": "^1.1 || ^2.0", + "laminas/laminas-permissions-acl": "^2.18", + "mezzio/mezzio-router": "^3.19" }, "require-dev": { "laminas/laminas-coding-standard": "^3.1", - "phpunit/phpunit": "^12.5", + "phpunit/phpunit": "^12.5.14", "psalm/plugin-phpunit": "^0.19.5", - "vimeo/psalm": "6.14.3", + "vimeo/psalm": "6.15.1", "amphp/parallel": "^2.3.3", "amphp/dns": "^2.4.0", "amphp/socket": "^2.3.1" @@ -57,6 +59,7 @@ "cs-check": "phpcs", "cs-fix": "phpcbf", "test-coverage-html": "phpunit --coverage-html ./build/html", - "static-analysis": "psalm --shepherd --stats" + "static-analysis": "psalm --shepherd --stats", + "static-analysis-update-baseline": "psalm --shepherd --stats --update-baseline" } } diff --git a/composer.lock b/composer.lock index 5364541..65fb829 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,64 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f350a828c68de7d7d2603d2e2b5ce95a", + "content-hash": "2b31e66d17a297c0f61ae01480e00199", "packages": [ + { + "name": "fig/http-message-util", + "version": "1.1.5", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message-util.git", + "reference": "9d94dc0154230ac39e5bf89398b324a86f63f765" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message-util/zipball/9d94dc0154230ac39e5bf89398b324a86f63f765", + "reference": "9d94dc0154230ac39e5bf89398b324a86f63f765", + "shasum": "" + }, + "require": { + "php": "^5.3 || ^7.0 || ^8.0" + }, + "suggest": { + "psr/http-message": "The package containing the PSR-7 interfaces" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Fig\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Utility classes and constants for use with PSR-7 (psr/http-message)", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-message-util/issues", + "source": "https://github.com/php-fig/http-message-util/tree/1.1.5" + }, + "time": "2020-11-24T22:02:12+00:00" + }, { "name": "laminas/laminas-authentication", "version": "2.19.0", @@ -80,6 +136,70 @@ ], "time": "2025-10-17T15:08:49+00:00" }, + { + "name": "laminas/laminas-permissions-acl", + "version": "2.18.0", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-permissions-acl.git", + "reference": "5940f6e7b9e2e3eba671f13dd26e610d2fe9acc3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-permissions-acl/zipball/5940f6e7b9e2e3eba671f13dd26e610d2fe9acc3", + "reference": "5940f6e7b9e2e3eba671f13dd26e610d2fe9acc3", + "shasum": "" + }, + "require": { + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "conflict": { + "laminas/laminas-servicemanager": "<3.0", + "zendframework/zend-permissions-acl": "*" + }, + "require-dev": { + "laminas/laminas-coding-standard": "^3.0.1", + "laminas/laminas-servicemanager": "^3.21", + "phpbench/phpbench": "^1.2.10", + "phpunit/phpunit": "^10.5.58", + "psalm/plugin-phpunit": "^0.19.0", + "vimeo/psalm": "^6.13.1" + }, + "suggest": { + "laminas/laminas-servicemanager": "To support Laminas\\Permissions\\Acl\\Assertion\\AssertionManager plugin manager usage" + }, + "type": "library", + "autoload": { + "psr-4": { + "Laminas\\Permissions\\Acl\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Provides a lightweight and flexible access control list (ACL) implementation for privileges management", + "homepage": "https://laminas.dev", + "keywords": [ + "acl", + "laminas" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-permissions-acl/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-permissions-acl/issues", + "rss": "https://github.com/laminas/laminas-permissions-acl/releases.atom", + "source": "https://github.com/laminas/laminas-permissions-acl" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2025-11-03T09:15:20+00:00" + }, { "name": "laminas/laminas-stdlib", "version": "3.21.0", @@ -139,6 +259,88 @@ ], "time": "2025-10-11T18:13:12+00:00" }, + { + "name": "mezzio/mezzio-router", + "version": "3.20.0", + "source": { + "type": "git", + "url": "https://github.com/mezzio/mezzio-router.git", + "reference": "be4de58dc8822b4af653dc554f963b4cb1e23355" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mezzio/mezzio-router/zipball/be4de58dc8822b4af653dc554f963b4cb1e23355", + "reference": "be4de58dc8822b4af653dc554f963b4cb1e23355", + "shasum": "" + }, + "require": { + "fig/http-message-util": "^1.1.5", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/container": "^1.1.2 || ^2.0", + "psr/http-factory": "^1.0.2", + "psr/http-message": "^1.0.1 || ^2.0.0", + "psr/http-server-middleware": "^1.0.2", + "webmozart/assert": "^1.11 || ^2.0" + }, + "conflict": { + "mezzio/mezzio": "<3.5", + "zendframework/zend-expressive-router": "*" + }, + "require-dev": { + "laminas/laminas-coding-standard": "~3.1.0", + "laminas/laminas-diactoros": "^3.6.0", + "laminas/laminas-servicemanager": "^4.4.0", + "laminas/laminas-stratigility": "^4.2.0", + "phpunit/phpunit": "^11.5.42", + "psalm/plugin-phpunit": "^0.19.0", + "vimeo/psalm": "^6.13.1" + }, + "suggest": { + "mezzio/mezzio-aurarouter": "^3.0 to use the Aura.Router routing adapter", + "mezzio/mezzio-fastroute": "^3.0 to use the FastRoute routing adapter", + "mezzio/mezzio-laminasrouter": "^3.0 to use the laminas-router routing adapter" + }, + "type": "library", + "extra": { + "laminas": { + "config-provider": "Mezzio\\Router\\ConfigProvider" + } + }, + "autoload": { + "psr-4": { + "Mezzio\\Router\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Router subcomponent for Mezzio", + "homepage": "https://mezzio.dev", + "keywords": [ + "http", + "laminas", + "mezzio", + "middleware", + "psr", + "psr-7" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.mezzio.dev/mezzio/features/router/intro/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/mezzio/mezzio-router/issues", + "rss": "https://github.com/mezzio/mezzio-router/releases.atom", + "source": "https://github.com/mezzio/mezzio-router" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2026-02-19T15:49:48+00:00" + }, { "name": "psr/container", "version": "2.0.2", @@ -192,6 +394,61 @@ }, "time": "2021-11-05T16:47:00+00:00" }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, { "name": "psr/http-message", "version": "2.0", @@ -357,6 +614,68 @@ "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2" }, "time": "2023-04-11T06:14:47+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.1.5", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "79155f94852fa27e2f73b459f6503f5e87e2c188" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/79155f94852fa27e2f73b459f6503f5e87e2c188", + "reference": "79155f94852fa27e2f73b459f6503f5e87e2c188", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-feature/2-0": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.1.5" + }, + "time": "2026-02-18T14:09:36+00:00" } ], "packages-dev": [ @@ -1621,29 +1940,29 @@ }, { "name": "doctrine/deprecations", - "version": "1.1.5", + "version": "1.1.6", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, "conflict": { - "phpunit/phpunit": "<=7.5 || >=13" + "phpunit/phpunit": "<=7.5 || >=14" }, "require-dev": { - "doctrine/coding-standard": "^9 || ^12 || ^13", - "phpstan/phpstan": "1.4.10 || 2.1.11", + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", "psr/log": "^1 || ^2 || ^3" }, "suggest": { @@ -1663,9 +1982,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.5" + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" }, - "time": "2025-04-07T20:06:18+00:00" + "time": "2026-02-07T07:09:04+00:00" }, { "name": "felixfbecker/language-server-protocol", @@ -2139,16 +2458,16 @@ }, { "name": "netresearch/jsonmapper", - "version": "v5.0.0", + "version": "v5.0.1", "source": { "type": "git", "url": "https://github.com/cweiske/jsonmapper.git", - "reference": "8c64d8d444a5d764c641ebe97e0e3bc72b25bf6c" + "reference": "980674efdda65913492d29a8fd51c82270dd37bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cweiske/jsonmapper/zipball/8c64d8d444a5d764c641ebe97e0e3bc72b25bf6c", - "reference": "8c64d8d444a5d764c641ebe97e0e3bc72b25bf6c", + "url": "https://api.github.com/repos/cweiske/jsonmapper/zipball/980674efdda65913492d29a8fd51c82270dd37bb", + "reference": "980674efdda65913492d29a8fd51c82270dd37bb", "shasum": "" }, "require": { @@ -2184,9 +2503,9 @@ "support": { "email": "cweiske@cweiske.de", "issues": "https://github.com/cweiske/jsonmapper/issues", - "source": "https://github.com/cweiske/jsonmapper/tree/v5.0.0" + "source": "https://github.com/cweiske/jsonmapper/tree/v5.0.1" }, - "time": "2024-09-08T10:20:00+00:00" + "time": "2026-02-22T16:28:03+00:00" }, { "name": "nikic/php-parser", @@ -2589,16 +2908,16 @@ }, { "name": "phpunit/php-code-coverage", - "version": "12.5.2", + "version": "12.5.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b" + "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4a9739b51cbcb355f6e95659612f92e282a7077b", - "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b015312f28dd75b75d3422ca37dff2cd1a565e8d", + "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d", "shasum": "" }, "require": { @@ -2654,7 +2973,7 @@ "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/12.5.2" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.3" }, "funding": [ { @@ -2674,20 +2993,20 @@ "type": "tidelift" } ], - "time": "2025-12-24T07:03:04+00:00" + "time": "2026-02-06T06:01:44+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "6.0.0", + "version": "6.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "961bc913d42fe24a257bfff826a5068079ac7782" + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/961bc913d42fe24a257bfff826a5068079ac7782", - "reference": "961bc913d42fe24a257bfff826a5068079ac7782", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", "shasum": "" }, "require": { @@ -2727,15 +3046,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.0" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" } ], - "time": "2025-02-07T04:58:37+00:00" + "time": "2026-02-02T14:04:18+00:00" }, { "name": "phpunit/php-invoker", @@ -2923,16 +3254,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.8", + "version": "12.5.14", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "37ddb96c14bfee10304825edbb7e66d341ec6889" + "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/37ddb96c14bfee10304825edbb7e66d341ec6889", - "reference": "37ddb96c14bfee10304825edbb7e66d341ec6889", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/47283cfd98d553edcb1353591f4e255dc1bb61f0", + "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0", "shasum": "" }, "require": { @@ -2946,8 +3277,8 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", - "phpunit/php-code-coverage": "^12.5.2", - "phpunit/php-file-iterator": "^6.0.0", + "phpunit/php-code-coverage": "^12.5.3", + "phpunit/php-file-iterator": "^6.0.1", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", "phpunit/php-timer": "^8.0.0", @@ -2958,6 +3289,7 @@ "sebastian/exporter": "^7.0.2", "sebastian/global-state": "^8.0.2", "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", "sebastian/type": "^6.0.3", "sebastian/version": "^6.0.0", "staabm/side-effects-detector": "^1.0.5" @@ -3000,7 +3332,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.8" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.14" }, "funding": [ { @@ -3024,7 +3356,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T06:12:29+00:00" + "time": "2026-02-18T12:38:40+00:00" }, { "name": "psalm/plugin-phpunit", @@ -3084,61 +3416,6 @@ }, "time": "2025-03-31T18:49:55+00:00" }, - { - "name": "psr/http-factory", - "version": "1.1.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/http-factory.git", - "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", - "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", - "shasum": "" - }, - "require": { - "php": ">=7.1", - "psr/http-message": "^1.0 || ^2.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Http\\Message\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", - "keywords": [ - "factory", - "http", - "message", - "psr", - "psr-17", - "psr-7", - "request", - "response" - ], - "support": { - "source": "https://github.com/php-fig/http-factory" - }, - "time": "2024-04-15T12:06:14+00:00" - }, { "name": "psr/log", "version": "3.0.2", @@ -5302,16 +5579,16 @@ }, { "name": "vimeo/psalm", - "version": "6.14.3", + "version": "6.15.1", "source": { "type": "git", "url": "https://github.com/vimeo/psalm.git", - "reference": "d0b040a91f280f071c1abcb1b77ce3822058725a" + "reference": "28dc127af1b5aecd52314f6f645bafc10d0e11f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vimeo/psalm/zipball/d0b040a91f280f071c1abcb1b77ce3822058725a", - "reference": "d0b040a91f280f071c1abcb1b77ce3822058725a", + "url": "https://api.github.com/repos/vimeo/psalm/zipball/28dc127af1b5aecd52314f6f645bafc10d0e11f9", + "reference": "28dc127af1b5aecd52314f6f645bafc10d0e11f9", "shasum": "" }, "require": { @@ -5335,7 +5612,7 @@ "netresearch/jsonmapper": "^5.0", "nikic/php-parser": "^5.0.0", "php": "~8.1.31 || ~8.2.27 || ~8.3.16 || ~8.4.3 || ~8.5.0", - "sebastian/diff": "^4.0 || ^5.0 || ^6.0 || ^7.0", + "sebastian/diff": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0", "spatie/array-to-xml": "^2.17.0 || ^3.0", "symfony/console": "^6.0 || ^7.0 || ^8.0", "symfony/filesystem": "~6.3.12 || ~6.4.3 || ^7.0.3 || ^8.0", @@ -5416,7 +5693,7 @@ "issues": "https://github.com/vimeo/psalm/issues", "source": "https://github.com/vimeo/psalm" }, - "time": "2025-12-23T15:36:48+00:00" + "time": "2026-02-07T19:27:16+00:00" }, { "name": "webimpress/coding-standard", @@ -5472,68 +5749,6 @@ } ], "time": "2024-10-16T06:55:17+00:00" - }, - { - "name": "webmozart/assert", - "version": "2.1.2", - "source": { - "type": "git", - "url": "https://github.com/webmozarts/assert.git", - "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", - "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", - "shasum": "" - }, - "require": { - "ext-ctype": "*", - "ext-date": "*", - "ext-filter": "*", - "php": "^8.2" - }, - "suggest": { - "ext-intl": "", - "ext-simplexml": "", - "ext-spl": "" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-feature/2-0": "2.0-dev" - } - }, - "autoload": { - "psr-4": { - "Webmozart\\Assert\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" - }, - { - "name": "Woody Gilk", - "email": "woody.gilk@gmail.com" - } - ], - "description": "Assertions to validate method input/output with nice error messages.", - "keywords": [ - "assert", - "check", - "validate" - ], - "support": { - "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.1.2" - }, - "time": "2026-01-13T14:02:24+00:00" } ], "aliases": [], diff --git a/psalm.baseline.xml b/psalm.baseline.xml index 5ace72f..cd6695e 100644 --- a/psalm.baseline.xml +++ b/psalm.baseline.xml @@ -1,5 +1,5 @@ - + @@ -12,6 +12,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -28,4 +52,17 @@ + + + + + + + + + + + + + diff --git a/src/ApiAccessKeyAuthenticationMiddleware.php b/src/ApiAccessKeyAuthenticationMiddleware.php index d74fe46..5b4841c 100644 --- a/src/ApiAccessKeyAuthenticationMiddleware.php +++ b/src/ApiAccessKeyAuthenticationMiddleware.php @@ -29,7 +29,8 @@ public function __construct( public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { // if there is already an identity, do nothing - if ($request->getAttribute(IdentityInterface::class) !== null) { + $identity = $request->getAttribute(IdentityInterface::class); + if ($identity !== null && ! $identity instanceof GuestIdentity) { return $handler->handle($request); } diff --git a/src/Authorization/AclAuthorization.php b/src/Authorization/AclAuthorization.php new file mode 100644 index 0000000..47cac6b --- /dev/null +++ b/src/Authorization/AclAuthorization.php @@ -0,0 +1,26 @@ +hasResource($resource)) { + $this->addResource($resource); + } + + if (! $this->hasRole($identity)) { + $this->addRole($identity); + } + + return $this->isAllowed($identity, $resource, $privilege); + } +} diff --git a/src/Authorization/AclAuthorizationFactory.php b/src/Authorization/AclAuthorizationFactory.php new file mode 100644 index 0000000..2dee22a --- /dev/null +++ b/src/Authorization/AclAuthorizationFactory.php @@ -0,0 +1,187 @@ + true, + 'GET' => true, + 'PATCH' => true, + 'POST' => true, + 'PUT' => true, + ]; + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function __invoke(ContainerInterface $container): AclAuthorization + { + /** @var array $config */ + $config = $container->get('config'); + /** @var array $aclConfig */ + $aclConfig = $config['lmc_api']['authentication']['authorization']; + return $this->createAclFromConfig($aclConfig); + } + + private function createAclFromConfig(array $config): AclAuthorization + { + $aclConfig = []; + $denyByDefault = false; + + if (array_key_exists('deny_by_default', $config)) { + $denyByDefault = $aclConfig['deny_by_default'] = (bool) $config['deny_by_default']; + unset($config['deny_by_default']); + } + + /** + * @var string $routeName + * @var array> $privileges + */ + foreach ($config as $routeName => $privileges) { + $this->createAclConfigFromPrivileges($routeName, $privileges, $aclConfig, $denyByDefault); + } + + return $this->createAclInstance($aclConfig); + } + + /** + * @param array> $privileges + */ + private function createAclConfigFromPrivileges( + string $routeName, + array $privileges, + array &$aclConfig, + bool $denyByDefault + ): void { + if (isset($privileges['actions'])) { + $aclConfig[] = [ + 'resource' => sprintf('%s', $routeName), + 'privileges' => $this->createPrivilegesFromMethods($privileges['actions'], $denyByDefault), + ]; + } + + if (isset($privileges['collection'])) { + $aclConfig[] = [ + 'resource' => sprintf('%s::collection', $routeName), + 'privileges' => $this->createPrivilegesFromMethods($privileges['collection'], $denyByDefault), + ]; + } + + if (isset($privileges['entity'])) { + $aclConfig[] = [ + 'resource' => sprintf('%s::entity', $routeName), + 'privileges' => $this->createPrivilegesFromMethods($privileges['entity'], $denyByDefault), + ]; + } + } + + private function createPrivilegesFromMethods(array $methods, bool $denyByDefault): array|null + { + $privileges = []; + + if (isset($methods['default']) && $methods['default']) { + $privileges = $this->httpMethods; + unset($methods['default']); + } + + /** + * @var string $method + * @var boolean $flag + */ + foreach ($methods as $method => $flag) { + // If the flag evaluates true, and we're denying by default, OR + // if the flag evaluates false, and we're allowing by default, + // THEN no rule needs to be added + if ( + ( $denyByDefault && $flag) + || (! $denyByDefault && ! $flag) + ) { + if (isset($privileges[$method])) { + unset($privileges[$method]); + } + continue; + } + + // Otherwise, we need to add a rule + $privileges[$method] = true; + } + + if (empty($privileges)) { + return null; + } + + return array_keys($privileges); + } + + private function createAclInstance(array $config): AclAuthorization + { + // Determine whether we are whitelisting or blacklisting + $denyByDefault = false; + if (array_key_exists('deny_by_default', $config)) { + $denyByDefault = (bool) $config['deny_by_default']; + unset($config['deny_by_default']); + } + + // By default, create an open ACL + $acl = new AclAuthorization(); + $acl->addRole('guest'); + $acl->allow(); + + $grant = 'deny'; + if ($denyByDefault) { + $acl->deny('guest', null, null); + $grant = 'allow'; + } + + if (! empty($config)) { + return $this->injectGrants($acl, $grant, $config); + } + + return $acl; + } + + private function injectGrants(AclAuthorization $acl, string $grantType, array $rules): AclAuthorization + { + foreach ($rules as $set) { + // todo check if there is a case where this is executed + if (! is_array($set) || ! isset($set['resource'])) { + continue; + } + + $this->injectGrant($acl, $grantType, $set); + } + + return $acl; + } + + private function injectGrant(AclAuthorization $acl, string $grantType, array $ruleSet): void + { + // Add new resource to ACL + /** @var string $resource */ + $resource = $ruleSet['resource']; + $acl->addResource($resource); + + // Deny guest specified privileges to resource + /** @var ?string $privileges */ + $privileges = $ruleSet['privileges'] ?? null; + + // null privileges means no permissions were setup; nothing to do + if (null === $privileges) { + return; + } + $acl->$grantType('guest', $resource, $privileges); + } +} diff --git a/src/Authorization/AuthorizationInterface.php b/src/Authorization/AuthorizationInterface.php new file mode 100644 index 0000000..08fe0ce --- /dev/null +++ b/src/Authorization/AuthorizationInterface.php @@ -0,0 +1,12 @@ +getAttribute(IdentityInterface::class); + if (! $identity instanceof IdentityInterface) { + return $handler->handle($request); + } + + /** @var ?RouteResult $routeResult */ + $routeResult = $request->getAttribute(RouteResult::class); + if (null === $routeResult) { + return $handler->handle($request); + } + $routeMatchName = $routeResult->getMatchedRouteName(); + + if (false === $routeMatchName) { + return $handler->handle($request); + } + + $resource = $this->buildResource($routeMatchName, $request); + + if ($this->authorization->isAuthorized($identity, $resource, $request->getMethod())) { + return $handler->handle($request); + } + + return $this->responseFactory->createResponse(403, 'Forbidden'); + } + + private function buildResource(string $routeMatchName, ServerRequestInterface $request): string + { + /** @var array|null $restConfig */ + $restConfig = $this->restConfig[$routeMatchName] ?? null; + if (null === $restConfig) { + // Return a resource name as if it was a RPC call + return sprintf('%s', $routeMatchName); + } + /** @var ?string $identifier */ + $identifier = $restConfig['route_identifier_name'] ?? null; + if (null === $identifier) { + // assume collection resource + return sprintf('%s::collection', $routeMatchName); + } + if ($request->getAttribute($identifier) !== null) { + return sprintf('%s::entity', $routeMatchName); + } + return sprintf('%s::collection', $routeMatchName); + } +} diff --git a/src/AuthorizationRestMiddlewareFactory.php b/src/AuthorizationRestMiddlewareFactory.php new file mode 100644 index 0000000..2522791 --- /dev/null +++ b/src/AuthorizationRestMiddlewareFactory.php @@ -0,0 +1,34 @@ +get('config'); + /** @var array $config */ + $config = $config['lmc_api'] ?? []; + /** @var array $config */ + $config = $config['rest'] ?? []; + /** @psalm-suppress MixedArgument */ + return new AuthorizationRestMiddleware( + $container->get(AuthorizationInterface::class), + $container->get(ResponseFactoryInterface::class), + $config, + ); + } +} diff --git a/src/AuthorizationRpcMiddleware.php b/src/AuthorizationRpcMiddleware.php new file mode 100644 index 0000000..26114b0 --- /dev/null +++ b/src/AuthorizationRpcMiddleware.php @@ -0,0 +1,54 @@ +getAttribute(IdentityInterface::class); + if (! $identity instanceof IdentityInterface) { + return $handler->handle($request); + } + + /** @var ?RouteResult $routeResult */ + $routeResult = $request->getAttribute(RouteResult::class); + if (null === $routeResult) { + return $handler->handle($request); + } + $routeMatchName = $routeResult->getMatchedRouteName(); + + if (false === $routeMatchName) { + return $handler->handle($request); + } + + $resource = sprintf('%s', $routeMatchName); + + if ($this->authorization->isAuthorized($identity, $resource, $request->getMethod())) { + return $handler->handle($request); + } + + return $this->responseFactory->createResponse(403, 'Forbidden'); + } +} diff --git a/src/AuthorizationRpcMiddlewareFactory.php b/src/AuthorizationRpcMiddlewareFactory.php new file mode 100644 index 0000000..6c3f8af --- /dev/null +++ b/src/AuthorizationRpcMiddlewareFactory.php @@ -0,0 +1,27 @@ +get(AuthorizationInterface::class), + $container->get(ResponseFactoryInterface::class), + ); + } +} diff --git a/src/ConfigProvider.php b/src/ConfigProvider.php index d60005c..cc8575c 100644 --- a/src/ConfigProvider.php +++ b/src/ConfigProvider.php @@ -4,6 +4,10 @@ namespace Lmc\Api\Auth; +use Lmc\Api\Auth\Authorization\AclAuthorization; +use Lmc\Api\Auth\Authorization\AclAuthorizationFactory; +use Lmc\Api\Auth\Authorization\AuthorizationInterface; + final class ConfigProvider { public function __invoke(): array @@ -17,15 +21,69 @@ public function __invoke(): array private function getDependencies(): array { return [ + 'aliases' => [ + AuthorizationInterface::class => AclAuthorization::class, + ], 'factories' => [ + AclAuthorization::class => AclAuthorizationFactory::class, ApiAccessKeyAuthenticationMiddleware::class => ApiAccessKeyAuthenticationMiddlewareFactory::class, LaminasAuthenticationMiddleware::class => LaminasAuthenticationMiddlewareFactory::class, + AuthorizationRpcMiddleware::class => AuthorizationRpcMiddlewareFactory::class, + AuthorizationRestMiddleware::class => AuthorizationRestMiddlewareFactory::class, ], ]; } private function getConfig(): array { - return []; + return [ + 'authentication' => [ + 'authorization' => [ + // Toggle the following to true to change the ACL creation to + // require an authenticated user by default, and thus selectively + // allow unauthenticated users based on the rules. + 'deny_by_default' => false, + + /* + * Rules indicating what routes are behind authentication. + * + * Keys are route names. + * + * Values are arrays with either the key "actions" and/or one or + * more of the keys "collection" and "entity". + * + * The "actions" key will be a set of action name/method pairs. + * The "collection" and "entity" keys will have method values. + * + * Method values are arrays of HTTP method/boolean pairs. By + * default, if an HTTP method is not present in the list, it is + * assumed to be open (i.e., not require authentication). The + * special key "default" can be used to set the default flag for + * all HTTP methods. + * + 'route_name' => [ + 'actions' => [ + 'default' => boolean, + 'GET' => boolean, + 'POST' => boolean, + // etc. + ], + 'collection' => [ + 'default' => boolean, + 'GET' => boolean, + 'POST' => boolean, + // etc. + ], + 'entity' => [ + 'default' => boolean, + 'GET' => boolean, + 'POST' => boolean, + // etc. + ], + ], + */ + ], + ], + ]; } } diff --git a/src/Identity/AuthenticatedIdentity.php b/src/Identity/AuthenticatedIdentity.php index 136f807..194ca9a 100644 --- a/src/Identity/AuthenticatedIdentity.php +++ b/src/Identity/AuthenticatedIdentity.php @@ -6,13 +6,19 @@ use Override; +use function method_exists; + final class AuthenticatedIdentity implements IdentityInterface { protected mixed $identity; + protected string $name = ''; public function __construct(mixed $identity) { $this->identity = $identity; + if (method_exists($identity, 'getId')) { + $this->name = (string) $this->identity->getId(); + } } #[Override] @@ -20,4 +26,10 @@ public function getAuthenticationIdentity(): mixed { return $this->identity; } + + #[Override] + public function getRoleId(): string + { + return $this->name; + } } diff --git a/src/Identity/GuestIdentity.php b/src/Identity/GuestIdentity.php index 9a27dab..ab46ee2 100644 --- a/src/Identity/GuestIdentity.php +++ b/src/Identity/GuestIdentity.php @@ -14,9 +14,10 @@ public function __construct() { } + #[Override] public function getRoleId(): string { - return static::$identity; + return self::$identity; } #[Override] diff --git a/src/Identity/IdentityInterface.php b/src/Identity/IdentityInterface.php index c17d098..dfe4844 100644 --- a/src/Identity/IdentityInterface.php +++ b/src/Identity/IdentityInterface.php @@ -4,7 +4,9 @@ namespace Lmc\Api\Auth\Identity; -interface IdentityInterface +use Laminas\Permissions\Acl\Role\RoleInterface; + +interface IdentityInterface extends RoleInterface { public function getAuthenticationIdentity(): mixed; } diff --git a/src/LaminasAuthenticationMiddleware.php b/src/LaminasAuthenticationMiddleware.php index 88629a8..56e2809 100644 --- a/src/LaminasAuthenticationMiddleware.php +++ b/src/LaminasAuthenticationMiddleware.php @@ -27,6 +27,12 @@ public function __construct( #[Override] public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { + // if there is already an identity, do nothing + $identity = $request->getAttribute(IdentityInterface::class); + if ($identity !== null && ! $identity instanceof GuestIdentity) { + return $handler->handle($request); + } + if ($this->authenticationService->hasIdentity()) { /** @var mixed $identity */ $identity = $this->authenticationService->getIdentity(); diff --git a/src/Repository/UserRepositoryInterface.php b/src/Repository/UserRepositoryInterface.php new file mode 100644 index 0000000..af893c6 --- /dev/null +++ b/src/Repository/UserRepositoryInterface.php @@ -0,0 +1,10 @@ +request->expects($this->once())->method('getAttribute') ->with(IdentityInterface::class) - ->willReturn(new GuestIdentity()); + ->willReturn(new AuthenticatedIdentity('foo')); $this->handler->expects($this->once())->method('handle')->with($this->request); $middleware = new ApiAccessKeyAuthenticationMiddleware( $this->createMock(ApiAccessKeyRepositoryInterface::class), @@ -109,7 +109,7 @@ public function testWithNotMatchingSecret(): void $middleware->process($this->request, $this->handler); } - public function testWithValidIdentity(): void + public function testWithValidCredentials(): void { $apiAccessKey = $this->createMock(ApiAccessKeyInterface::class); $apiAccessKey->expects($this->once())->method('getClientSecret') @@ -135,4 +135,31 @@ public function testWithValidIdentity(): void $middleware = new ApiAccessKeyAuthenticationMiddleware($apiRepository); $middleware->process($this->request, $this->handler); } + + public function testWithValidCredentialsGuestIdentity(): void + { + $apiAccessKey = $this->createMock(ApiAccessKeyInterface::class); + $apiAccessKey->expects($this->once())->method('getClientSecret') + ->willReturn('bar'); + $this->request->expects($this->once())->method('getAttribute') + ->with(IdentityInterface::class) + ->willReturn(new GuestIdentity()); + $this->request->expects($this->exactly(2))->method('getQueryParams') + ->willReturn([ + 'client_id' => 'foo', + 'client_secret' => 'bar', + ]); + $apiRepository = $this->createMock(ApiAccessKeyRepositoryInterface::class); + $apiRepository->expects($this->once())->method('getByClientId') + ->with('foo')->willReturn($apiAccessKey); + $this->request->expects($this->once())->method('withAttribute') + ->with( + IdentityInterface::class, + $this->isInstanceOf(AuthenticatedIdentity::class), + ) + ->willReturnSelf(); + $this->handler->expects($this->once())->method('handle'); + $middleware = new ApiAccessKeyAuthenticationMiddleware($apiRepository); + $middleware->process($this->request, $this->handler); + } } diff --git a/test/Assets/IdentityGetId.php b/test/Assets/IdentityGetId.php new file mode 100644 index 0000000..ccbde90 --- /dev/null +++ b/test/Assets/IdentityGetId.php @@ -0,0 +1,28 @@ +getId(); + } +} diff --git a/test/Assets/IdentityNoGetId.php b/test/Assets/IdentityNoGetId.php new file mode 100644 index 0000000..4ac36d3 --- /dev/null +++ b/test/Assets/IdentityNoGetId.php @@ -0,0 +1,9 @@ +container = $this->createMock(ContainerInterface::class); + parent::setUp(); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testEmptyConfig(): void + { + $config = [ + 'lmc_api' => [ + 'authentication' => [ + 'authorization' => [], + ], + ], + ]; + $this->container->expects($this->once())->method('get') + ->with('config') + ->willReturn($config); + $factory = new AclAuthorizationFactory(); + $aclAuthorization = $factory($this->container); + $this->assertFalse($aclAuthorization->hasResource('foo')); + $this->assertFalse($aclAuthorization->hasRole('foo')); + $this->assertTrue($aclAuthorization->isAuthorized(new IdentityGetId(), 'bar', 'foo')); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + #[DataProvider('configProvider')] + public function testConfig(array $config, string $resource, string $privilege, bool $isAuthorized): void + { + $this->container->expects($this->once())->method('get') + ->with('config') + ->willReturn($config); + $factory = new AclAuthorizationFactory(); + $aclAuthorization = $factory($this->container); + $this->assertEquals($isAuthorized, $aclAuthorization->isAuthorized(new GuestIdentity(), $resource, $privilege)); + } + + /** @psalm-return iterable> */ + public static function configProvider(): array + { + return [ + 'empty config' => [ + 'config' => [ + 'lmc_api' => [ + 'authentication' => [ + 'authorization' => [], + ], + ], + ], + 'resource' => 'bar', + 'privilege' => 'for', + 'isAuthorized' => true, + ], + 'deny_by_default true' => [ + 'config' => [ + 'lmc_api' => [ + 'authentication' => [ + 'authorization' => [ + 'deny_by_default' => true, + ], + ], + ], + ], + 'resource' => 'bar', + 'privilege' => 'for', + 'isAuthorized' => false, + ], + 'actions denied' => [ + 'config' => [ + 'lmc_api' => [ + 'authentication' => [ + 'authorization' => [ + 'deny_by_default' => false, + 'route1' => [ + 'actions' => [ + 'GET' => true, + ], + ], + ], + ], + ], + ], + 'resource' => 'route1', + 'privilege' => 'GET', + 'isAuthorized' => false, + ], + 'actions allowed' => [ + 'config' => [ + 'lmc_api' => [ + 'authentication' => [ + 'authorization' => [ + 'deny_by_default' => false, + 'route1' => [ + 'actions' => [ + 'POST' => false, + ], + ], + ], + ], + ], + ], + 'resource' => 'route1', + 'privilege' => 'POST', + 'isAuthorized' => true, + ], + 'actions denied by default' => [ + 'config' => [ + 'lmc_api' => [ + 'authentication' => [ + 'authorization' => [ + 'deny_by_default' => true, + 'route1' => [ + 'actions' => [ + 'POST' => true, + ], + ], + ], + ], + ], + ], + 'resource' => 'route1', + 'privilege' => 'GET', + 'isAuthorized' => false, + ], + 'collection allowed' => [ + 'config' => [ + 'lmc_api' => [ + 'authentication' => [ + 'authorization' => [ + 'deny_by_default' => true, + 'route1' => [ + 'collection' => [ + 'POST' => true, + ], + ], + ], + ], + ], + ], + 'resource' => 'route1::collection', + 'privilege' => 'POST', + 'isAuthorized' => false, + ], + 'entity allowed' => [ + 'config' => [ + 'lmc_api' => [ + 'authentication' => [ + 'authorization' => [ + 'deny_by_default' => true, + 'route1' => [ + 'entity' => [ + 'POST' => true, + ], + ], + ], + ], + ], + ], + 'resource' => 'route1::entity', + 'privilege' => 'POST', + 'isAuthorized' => false, + ], + 'entity default denied' => [ + 'config' => [ + 'lmc_api' => [ + 'authentication' => [ + 'authorization' => [ + 'deny_by_default' => true, + 'route1' => [ + 'entity' => [ + 'default' => true, + 'POST' => true, + ], + ], + ], + ], + ], + ], + 'resource' => 'route1::entity', + 'privilege' => 'POST', + 'isAuthorized' => false, + ], + 'entity default allowed' => [ + 'config' => [ + 'lmc_api' => [ + 'authentication' => [ + 'authorization' => [ + 'deny_by_default' => false, + 'route1' => [ + 'entity' => [ + 'default' => true, + 'POST' => false, + ], + ], + ], + ], + ], + ], + 'resource' => 'route1::entity', + 'privilege' => 'POST', + 'isAuthorized' => true, + ], + 'entity empty rules' => [ + 'config' => [ + 'lmc_api' => [ + 'authentication' => [ + 'authorization' => [ + 'deny_by_default' => false, + 'route1' => [ + 'actions' => [], + ], + ], + ], + ], + ], + 'resource' => 'route1::entity', + 'privilege' => 'POST', + 'isAuthorized' => true, + ], + ]; + } +} diff --git a/test/AuthorizationRestMiddlewareFactoryTest.php b/test/AuthorizationRestMiddlewareFactoryTest.php new file mode 100644 index 0000000..72d8d00 --- /dev/null +++ b/test/AuthorizationRestMiddlewareFactoryTest.php @@ -0,0 +1,45 @@ +createMock(ContainerInterface::class); + $container->expects($this->exactly(3))->method('get') + ->willReturnMap([ + [AuthorizationInterface::class, $this->createMock(AuthorizationInterface::class)], + [ResponseFactoryInterface::class, $this->createMock(ResponseFactoryInterface::class)], + [ + 'config', + [ + 'lmc_api' => [ + 'rest' => [], + ], + ], + ], + ]); + $factory = new AuthorizationRestMiddlewareFactory(); + $this->assertInstanceOf(AuthorizationRestMiddleware::class, $factory($container)); + } +} diff --git a/test/AuthorizationRestMiddlewareTest.php b/test/AuthorizationRestMiddlewareTest.php new file mode 100644 index 0000000..0dcfd4b --- /dev/null +++ b/test/AuthorizationRestMiddlewareTest.php @@ -0,0 +1,77 @@ +request = $this->createMock(ServerRequestInterface::class); + $this->handler = $this->createMock(RequestHandlerInterface::class); + $this->responseFactory = $this->createMock(ResponseFactoryInterface::class); + $this->authorization = $this->createMock(AuthorizationInterface::class); + parent::setUp(); + } + + #[AllowMockObjectsWithoutExpectations] + public function testNoIdentity(): void + { + $this->request->expects($this->once())->method('getAttribute') + ->with(IdentityInterface::class) + ->willReturn(null); + $this->handler->expects($this->once())->method('handle')->with($this->request); + $middleware = new AuthorizationRestMiddleware($this->authorization, $this->responseFactory, []); + $middleware->process($this->request, $this->handler); + } + + #[AllowMockObjectsWithoutExpectations] + public function testNoRoute(): void + { + $this->request->expects($this->exactly(2))->method('getAttribute') + ->willReturnMap([ + [IdentityInterface::class, new IdentityGetId()], + [RouteResult::class, null], + ]); + $this->handler->expects($this->once())->method('handle')->with($this->request); + $middleware = new AuthorizationRestMiddleware($this->authorization, $this->responseFactory, []); + $middleware->process($this->request, $this->handler); + } + + #[AllowMockObjectsWithoutExpectations] + public function testNoRouteMatchName(): void + { + $routeResult = $this->createMock(RouteResult::class); + $routeResult->expects($this->once())->method('getMatchedRouteName')->willReturn(false); + $this->request->expects($this->exactly(2))->method('getAttribute') + ->willReturnMap([ + [IdentityInterface::class, new IdentityGetId()], + [RouteResult::class, $routeResult], + ]); + $this->handler->expects($this->once())->method('handle')->with($this->request); + $middleware = new AuthorizationRestMiddleware($this->authorization, $this->responseFactory, []); + $middleware->process($this->request, $this->handler); + } +} diff --git a/test/AuthorizationRpcMiddlewareFactoryTest.php b/test/AuthorizationRpcMiddlewareFactoryTest.php new file mode 100644 index 0000000..16095e9 --- /dev/null +++ b/test/AuthorizationRpcMiddlewareFactoryTest.php @@ -0,0 +1,37 @@ +createMock(ContainerInterface::class); + $container->expects($this->exactly(2))->method('get') + ->willReturnMap([ + [AuthorizationInterface::class, $this->createMock(AuthorizationInterface::class)], + [ResponseFactoryInterface::class, $this->createMock(ResponseFactoryInterface::class)], + ]); + $factory = new AuthorizationRpcMiddlewareFactory(); + $this->assertInstanceOf(AuthorizationRpcMiddleware::class, $factory($container)); + } +} diff --git a/test/AuthorizationRpcMiddlewareTest.php b/test/AuthorizationRpcMiddlewareTest.php new file mode 100644 index 0000000..73fff00 --- /dev/null +++ b/test/AuthorizationRpcMiddlewareTest.php @@ -0,0 +1,118 @@ +request = $this->createMock(ServerRequestInterface::class); + $this->handler = $this->createMock(RequestHandlerInterface::class); + $this->responseFactory = $this->createMock(ResponseFactoryInterface::class); + $this->authorization = $this->createMock(AuthorizationInterface::class); + parent::setUp(); + } + + #[AllowMockObjectsWithoutExpectations] + public function testNoIdentity(): void + { + $this->request->expects($this->once())->method('getAttribute') + ->with(IdentityInterface::class) + ->willReturn(null); + $this->handler->expects($this->once())->method('handle')->with($this->request); + $middleware = new AuthorizationRpcMiddleware($this->authorization, $this->responseFactory); + $middleware->process($this->request, $this->handler); + } + + #[AllowMockObjectsWithoutExpectations] + public function testNoRoute(): void + { + $this->request->expects($this->exactly(2))->method('getAttribute') + ->willReturnMap([ + [IdentityInterface::class, new IdentityGetId()], + [RouteResult::class, null], + ]); + $this->handler->expects($this->once())->method('handle')->with($this->request); + $middleware = new AuthorizationRpcMiddleware($this->authorization, $this->responseFactory); + $middleware->process($this->request, $this->handler); + } + + #[AllowMockObjectsWithoutExpectations] + public function testNoRouteMatchName(): void + { + $routeResult = $this->createMock(RouteResult::class); + $routeResult->expects($this->once())->method('getMatchedRouteName')->willReturn(false); + $this->request->expects($this->exactly(2))->method('getAttribute') + ->willReturnMap([ + [IdentityInterface::class, new IdentityGetId()], + [RouteResult::class, $routeResult], + ]); + $this->handler->expects($this->once())->method('handle')->with($this->request); + $middleware = new AuthorizationRpcMiddleware($this->authorization, $this->responseFactory); + $middleware->process($this->request, $this->handler); + } + + #[AllowMockObjectsWithoutExpectations] + public function testAuthorized(): void + { + $routeResult = $this->createMock(RouteResult::class); + $routeResult->expects($this->once())->method('getMatchedRouteName')->willReturn('foo'); + $identity = new IdentityGetId(); + $this->request->expects($this->exactly(2))->method('getAttribute') + ->willReturnMap([ + [IdentityInterface::class, $identity], + [RouteResult::class, $routeResult], + ]); + $this->request->expects($this->once())->method('getMethod')->willReturn('GET'); + $this->authorization->expects($this->once())->method('isAuthorized') + ->with($identity, 'foo', 'GET') + ->willReturn(true); + $this->handler->expects($this->once())->method('handle')->with($this->request); + $middleware = new AuthorizationRpcMiddleware($this->authorization, $this->responseFactory); + $middleware->process($this->request, $this->handler); + } + + #[AllowMockObjectsWithoutExpectations] + public function testNotAuthorized(): void + { + $routeResult = $this->createMock(RouteResult::class); + $routeResult->expects($this->once())->method('getMatchedRouteName')->willReturn('foo'); + $identity = new IdentityGetId(); + $this->request->expects($this->exactly(2))->method('getAttribute') + ->willReturnMap([ + [IdentityInterface::class, $identity], + [RouteResult::class, $routeResult], + ]); + $this->request->expects($this->once())->method('getMethod')->willReturn('GET'); + $this->authorization->expects($this->once())->method('isAuthorized') + ->with($identity, 'foo', 'GET') + ->willReturn(false); + $this->responseFactory->expects($this->once())->method('createResponse') + ->with(403, 'Forbidden'); + $middleware = new AuthorizationRpcMiddleware($this->authorization, $this->responseFactory); + $middleware->process($this->request, $this->handler); + } +} diff --git a/test/Identity/AuthenticatedIdentityTest.php b/test/Identity/AuthenticatedIdentityTest.php index f0912e0..eeabc72 100644 --- a/test/Identity/AuthenticatedIdentityTest.php +++ b/test/Identity/AuthenticatedIdentityTest.php @@ -5,14 +5,24 @@ namespace LmcTest\Api\Auth\Identity; use Lmc\Api\Auth\Identity\AuthenticatedIdentity; +use LmcTest\Api\Auth\Assets\IdentityGetId; +use LmcTest\Api\Auth\Assets\IdentityNoGetId; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -use stdClass; +#[CoversClass(AuthenticatedIdentity::class)] final class AuthenticatedIdentityTest extends TestCase { public function testConstruct(): void { - $a = new AuthenticatedIdentity(new stdClass()); - $this->assertInstanceOf(stdClass::class, $a->getAuthenticationIdentity()); + $a = new AuthenticatedIdentity(new IdentityNoGetId()); + $this->assertInstanceOf(IdentityNoGetId::class, $a->getAuthenticationIdentity()); + } + + public function testConstructGetId(): void + { + $a = new AuthenticatedIdentity(new IdentityGetId()); + $this->assertInstanceOf(IdentityGetId::class, $a->getAuthenticationIdentity()); + $this->assertEquals('foo', $a->getRoleId()); } } diff --git a/test/LaminasAuthenticationMiddlewareTest.php b/test/LaminasAuthenticationMiddlewareTest.php index 428cc1a..2863abe 100644 --- a/test/LaminasAuthenticationMiddlewareTest.php +++ b/test/LaminasAuthenticationMiddlewareTest.php @@ -31,6 +31,17 @@ protected function setUp(): void parent::setUp(); } + #[AllowMockObjectsWithoutExpectations] + public function testWhenAlreadyIdentityPresentInAttributes(): void + { + $this->request->expects($this->once())->method('getAttribute') + ->with(IdentityInterface::class) + ->willReturn(new AuthenticatedIdentity('foo')); + $this->handler->expects($this->once())->method('handle')->with($this->request); + $middleware = new LaminasAuthenticationMiddleware($this->authenticationService); + $middleware->process($this->request, $this->handler); + } + #[AllowMockObjectsWithoutExpectations] public function testWithoutIdentity(): void {