Compare commits
5
Commits
5b133f724d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67c685f6c1 | ||
|
|
7aad54e6f2 | ||
|
|
b41820c39d | ||
|
|
fab89dead6 | ||
|
|
443a3ed248 |
Generated
+94
-90
@@ -468,16 +468,16 @@
|
||||
},
|
||||
{
|
||||
"name": "doctrine/doctrine-bundle",
|
||||
"version": "2.19.0",
|
||||
"version": "2.19.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/doctrine/DoctrineBundle.git",
|
||||
"reference": "07b90f707b82981097731c419f546e7ba97fba3c"
|
||||
"reference": "72b53688c30451a7c65addd50b2167d31459724a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/07b90f707b82981097731c419f546e7ba97fba3c",
|
||||
"reference": "07b90f707b82981097731c419f546e7ba97fba3c",
|
||||
"url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/72b53688c30451a7c65addd50b2167d31459724a",
|
||||
"reference": "72b53688c30451a7c65addd50b2167d31459724a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -572,7 +572,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/doctrine/DoctrineBundle/issues",
|
||||
"source": "https://github.com/doctrine/DoctrineBundle/tree/2.19.0"
|
||||
"source": "https://github.com/doctrine/DoctrineBundle/tree/2.19.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -588,7 +588,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-07-23T14:52:05+00:00"
|
||||
"time": "2026-09-08T11:48:41+00:00"
|
||||
},
|
||||
{
|
||||
"name": "doctrine/doctrine-migrations-bundle",
|
||||
@@ -1115,16 +1115,16 @@
|
||||
},
|
||||
{
|
||||
"name": "doctrine/orm",
|
||||
"version": "3.7.0",
|
||||
"version": "3.7.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/doctrine/orm.git",
|
||||
"reference": "f25c5e5385d3a0bca2dc364d556d3b83a06559ed"
|
||||
"reference": "037807a39697890dcede8b766c5efe8a1b4855fe"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/doctrine/orm/zipball/f25c5e5385d3a0bca2dc364d556d3b83a06559ed",
|
||||
"reference": "f25c5e5385d3a0bca2dc364d556d3b83a06559ed",
|
||||
"url": "https://api.github.com/repos/doctrine/orm/zipball/037807a39697890dcede8b766c5efe8a1b4855fe",
|
||||
"reference": "037807a39697890dcede8b766c5efe8a1b4855fe",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -1199,9 +1199,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/doctrine/orm/issues",
|
||||
"source": "https://github.com/doctrine/orm/tree/3.7.0"
|
||||
"source": "https://github.com/doctrine/orm/tree/3.7.1"
|
||||
},
|
||||
"time": "2026-09-07T19:35:17+00:00"
|
||||
"time": "2026-09-14T05:45:59+00:00"
|
||||
},
|
||||
{
|
||||
"name": "doctrine/persistence",
|
||||
@@ -2064,33 +2064,34 @@
|
||||
},
|
||||
{
|
||||
"name": "knplabs/knp-paginator-bundle",
|
||||
"version": "v6.10.0",
|
||||
"version": "v6.11.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/KnpLabs/KnpPaginatorBundle.git",
|
||||
"reference": "8d41f8ed47d880f8fa569389ffa4fecfbc5b8d41"
|
||||
"reference": "a91a854720df2dbfd04cbf8c44c3dad6ce22fdca"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/KnpLabs/KnpPaginatorBundle/zipball/8d41f8ed47d880f8fa569389ffa4fecfbc5b8d41",
|
||||
"reference": "8d41f8ed47d880f8fa569389ffa4fecfbc5b8d41",
|
||||
"url": "https://api.github.com/repos/KnpLabs/KnpPaginatorBundle/zipball/a91a854720df2dbfd04cbf8c44c3dad6ce22fdca",
|
||||
"reference": "a91a854720df2dbfd04cbf8c44c3dad6ce22fdca",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"knplabs/knp-components": "^4.4 || ^5.0",
|
||||
"php": "^8.1",
|
||||
"symfony/config": "^6.4 || ^7.0 || ^8.0",
|
||||
"symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0",
|
||||
"symfony/event-dispatcher": "^6.4 || ^7.0 || ^8.0",
|
||||
"symfony/http-foundation": "^6.4 || ^7.0 || ^8.0",
|
||||
"symfony/http-kernel": "^6.4 || ^7.0 || ^8.0",
|
||||
"symfony/routing": "^6.4 || ^7.0 || ^8.0"
|
||||
"knplabs/knp-components": "^5.0",
|
||||
"php": "^8.2",
|
||||
"symfony/config": "^6.4 || ^7.4 || ^8.0",
|
||||
"symfony/dependency-injection": "^6.4 || ^7.4 || ^8.0",
|
||||
"symfony/event-dispatcher": "^6.4 || ^7.4 || ^8.0",
|
||||
"symfony/http-foundation": "^6.4 || ^7.4 || ^8.0",
|
||||
"symfony/http-kernel": "^6.4 || ^7.4 || ^8.0",
|
||||
"symfony/routing": "^6.4 || ^7.4 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^1.12",
|
||||
"phpunit/phpunit": "^10.5 || ^11.5 || ^12.2",
|
||||
"symfony/templating": "^6.4 || ^7.0 || ^8.0",
|
||||
"symfony/translation": "^6.4 || ^7.0 || ^8.0",
|
||||
"phpstan/phpstan": "^2.2",
|
||||
"phpstan/phpstan-strict-rules": "^2.0",
|
||||
"phpunit/phpunit": "^11.5 || ^12.5 || ^13.2",
|
||||
"symfony/templating": "^6.4 || ^7.4 || ^8.0",
|
||||
"symfony/translation": "^6.4 || ^7.4 || ^8.0",
|
||||
"twig/twig": "^3.0"
|
||||
},
|
||||
"suggest": {
|
||||
@@ -2135,9 +2136,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/KnpLabs/KnpPaginatorBundle/issues",
|
||||
"source": "https://github.com/KnpLabs/KnpPaginatorBundle/tree/v6.10.0"
|
||||
"source": "https://github.com/KnpLabs/KnpPaginatorBundle/tree/v6.11.0"
|
||||
},
|
||||
"time": "2025-11-29T09:14:09+00:00"
|
||||
"time": "2026-09-11T08:30:00+00:00"
|
||||
},
|
||||
{
|
||||
"name": "lcobucci/jwt",
|
||||
@@ -3195,16 +3196,16 @@
|
||||
},
|
||||
{
|
||||
"name": "monolog/monolog",
|
||||
"version": "3.11.0",
|
||||
"version": "3.12.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Seldaek/monolog.git",
|
||||
"reference": "147f303310f06334f03f409e49d7ad1e275ff05a"
|
||||
"reference": "72c534fc0ab181ef52d92a68382318631e301608"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Seldaek/monolog/zipball/147f303310f06334f03f409e49d7ad1e275ff05a",
|
||||
"reference": "147f303310f06334f03f409e49d7ad1e275ff05a",
|
||||
"url": "https://api.github.com/repos/Seldaek/monolog/zipball/72c534fc0ab181ef52d92a68382318631e301608",
|
||||
"reference": "72c534fc0ab181ef52d92a68382318631e301608",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3230,6 +3231,7 @@
|
||||
"phpstan/phpstan-strict-rules": "^2",
|
||||
"phpunit/phpunit": "^10.5.17 || ^11.0.7",
|
||||
"predis/predis": "^1.1 || ^2",
|
||||
"psr/clock": "^1.0",
|
||||
"rollbar/rollbar": "^4.0",
|
||||
"ruflin/elastica": "^7 || ^8",
|
||||
"symfony/mailer": "^5.4 || ^6",
|
||||
@@ -3248,6 +3250,7 @@
|
||||
"graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server",
|
||||
"mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)",
|
||||
"php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib",
|
||||
"psr/clock": "Required to pass a clock to the Logger and control the timestamp of log records",
|
||||
"rollbar/rollbar": "Allow sending log messages to Rollbar",
|
||||
"ruflin/elastica": "Allow sending log messages to an Elastic Search server"
|
||||
},
|
||||
@@ -3282,7 +3285,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Seldaek/monolog/issues",
|
||||
"source": "https://github.com/Seldaek/monolog/tree/3.11.0"
|
||||
"source": "https://github.com/Seldaek/monolog/tree/3.12.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -3294,7 +3297,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-09-02T12:39:56+00:00"
|
||||
"time": "2026-09-09T08:34:20+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nelexa/zip",
|
||||
@@ -3371,16 +3374,16 @@
|
||||
},
|
||||
{
|
||||
"name": "nesbot/carbon",
|
||||
"version": "3.13.2",
|
||||
"version": "3.14.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/CarbonPHP/carbon.git",
|
||||
"reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb"
|
||||
"reference": "0023eaa2c9110e47446dd512a263c69c40cd41f2"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/a1c54919f5fff9800cd03c32bd01defd5a4061cb",
|
||||
"reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb",
|
||||
"url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/0023eaa2c9110e47446dd512a263c69c40cd41f2",
|
||||
"reference": "0023eaa2c9110e47446dd512a263c69c40cd41f2",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3472,7 +3475,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-08-08T11:40:35+00:00"
|
||||
"time": "2026-09-12T20:45:19+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nyholm/psr7",
|
||||
@@ -12292,16 +12295,16 @@
|
||||
},
|
||||
{
|
||||
"name": "friendsofphp/php-cs-fixer",
|
||||
"version": "v3.95.24",
|
||||
"version": "v3.95.25",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
|
||||
"reference": "9a4b8050f8768646bf3693ee7416978ceeca6206"
|
||||
"reference": "2cdfc1f3daf173d83a1ebb6177949b58c95de6ff"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/9a4b8050f8768646bf3693ee7416978ceeca6206",
|
||||
"reference": "9a4b8050f8768646bf3693ee7416978ceeca6206",
|
||||
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/2cdfc1f3daf173d83a1ebb6177949b58c95de6ff",
|
||||
"reference": "2cdfc1f3daf173d83a1ebb6177949b58c95de6ff",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -12384,7 +12387,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues",
|
||||
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.24"
|
||||
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.25"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -12392,7 +12395,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-08-31T15:31:12+00:00"
|
||||
"time": "2026-09-08T11:11:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "myclabs/deep-copy",
|
||||
@@ -12456,16 +12459,16 @@
|
||||
},
|
||||
{
|
||||
"name": "nikic/php-parser",
|
||||
"version": "v5.8.0",
|
||||
"version": "v5.9.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/nikic/PHP-Parser.git",
|
||||
"reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f"
|
||||
"reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
|
||||
"reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
|
||||
"url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def",
|
||||
"reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -12507,9 +12510,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/nikic/PHP-Parser/issues",
|
||||
"source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0"
|
||||
"source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0"
|
||||
},
|
||||
"time": "2026-07-04T14:30:18+00:00"
|
||||
"time": "2026-09-13T18:51:52+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phar-io/manifest",
|
||||
@@ -12631,11 +12634,11 @@
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpstan",
|
||||
"version": "2.2.13",
|
||||
"version": "2.2.14",
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/9ba9ac76ee9c5cf5b56d58eb5deec6315b7a0260",
|
||||
"reference": "9ba9ac76ee9c5cf5b56d58eb5deec6315b7a0260",
|
||||
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac",
|
||||
"reference": "9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -12691,7 +12694,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-09-03T20:38:19+00:00"
|
||||
"time": "2026-09-12T21:39:33+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpstan-doctrine",
|
||||
@@ -13191,16 +13194,16 @@
|
||||
},
|
||||
{
|
||||
"name": "phpunit/phpunit",
|
||||
"version": "12.5.34",
|
||||
"version": "12.5.35",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
||||
"reference": "6cbff63d670de92cb1cb3d2ff9f40327e9da9c7f"
|
||||
"reference": "345ac22e42cb14cdc7aa8428655d8bb8c82a4aab"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/6cbff63d670de92cb1cb3d2ff9f40327e9da9c7f",
|
||||
"reference": "6cbff63d670de92cb1cb3d2ff9f40327e9da9c7f",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/345ac22e42cb14cdc7aa8428655d8bb8c82a4aab",
|
||||
"reference": "345ac22e42cb14cdc7aa8428655d8bb8c82a4aab",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -13269,7 +13272,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.34"
|
||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.35"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -13277,7 +13280,7 @@
|
||||
"type": "other"
|
||||
}
|
||||
],
|
||||
"time": "2026-08-27T08:38:28+00:00"
|
||||
"time": "2026-09-09T04:48:50+00:00"
|
||||
},
|
||||
{
|
||||
"name": "react/cache",
|
||||
@@ -13807,21 +13810,21 @@
|
||||
},
|
||||
{
|
||||
"name": "rector/rector",
|
||||
"version": "2.6.6",
|
||||
"version": "2.6.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/rectorphp/rector.git",
|
||||
"reference": "ca069d6c79feaa6651b423c15d101a27a436093e"
|
||||
"reference": "f4aa224edb9b0630fb43826d4cb6a49b62e48d4c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/rectorphp/rector/zipball/ca069d6c79feaa6651b423c15d101a27a436093e",
|
||||
"reference": "ca069d6c79feaa6651b423c15d101a27a436093e",
|
||||
"url": "https://api.github.com/repos/rectorphp/rector/zipball/f4aa224edb9b0630fb43826d4cb6a49b62e48d4c",
|
||||
"reference": "f4aa224edb9b0630fb43826d4cb6a49b62e48d4c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.4|^8.0",
|
||||
"phpstan/phpstan": "^2.2.10"
|
||||
"phpstan/phpstan": "^2.2.14"
|
||||
},
|
||||
"conflict": {
|
||||
"rector/rector-doctrine": "*",
|
||||
@@ -13852,7 +13855,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/rectorphp/rector/issues",
|
||||
"source": "https://github.com/rectorphp/rector/tree/2.6.6"
|
||||
"source": "https://github.com/rectorphp/rector/tree/2.6.7"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -13860,7 +13863,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-09-02T09:38:46+00:00"
|
||||
"time": "2026-09-13T20:17:44+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/cli-parser",
|
||||
@@ -15054,32 +15057,32 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/maker-bundle",
|
||||
"version": "v1.67.0",
|
||||
"version": "1.68.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/maker-bundle.git",
|
||||
"reference": "6ce8b313845f16bcf385ee3cb31d8b24e30d5516"
|
||||
"reference": "1b0fb459594fdeb1d183065451c5e954babeb16c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/maker-bundle/zipball/6ce8b313845f16bcf385ee3cb31d8b24e30d5516",
|
||||
"reference": "6ce8b313845f16bcf385ee3cb31d8b24e30d5516",
|
||||
"url": "https://api.github.com/repos/symfony/maker-bundle/zipball/1b0fb459594fdeb1d183065451c5e954babeb16c",
|
||||
"reference": "1b0fb459594fdeb1d183065451c5e954babeb16c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"composer-runtime-api": "^2.1",
|
||||
"doctrine/inflector": "^2.0",
|
||||
"nikic/php-parser": "^5.0",
|
||||
"php": ">=8.1",
|
||||
"symfony/config": "^6.4|^7.0|^8.0",
|
||||
"symfony/console": "^6.4|^7.0|^8.0",
|
||||
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
|
||||
"php": ">=8.2",
|
||||
"symfony/config": "^7.4|^8.1",
|
||||
"symfony/console": "^7.4|^8.1",
|
||||
"symfony/dependency-injection": "^7.4|^8.1",
|
||||
"symfony/deprecation-contracts": "^2.2|^3",
|
||||
"symfony/filesystem": "^6.4|^7.0|^8.0",
|
||||
"symfony/finder": "^6.4|^7.0|^8.0",
|
||||
"symfony/framework-bundle": "^6.4|^7.0|^8.0",
|
||||
"symfony/http-kernel": "^6.4|^7.0|^8.0",
|
||||
"symfony/process": "^6.4|^7.0|^8.0"
|
||||
"symfony/filesystem": "^7.4|^8.1",
|
||||
"symfony/finder": "^7.4|^8.1",
|
||||
"symfony/framework-bundle": "^7.4|^8.1",
|
||||
"symfony/http-kernel": "^7.4|^8.1",
|
||||
"symfony/process": "^7.4|^8.1"
|
||||
},
|
||||
"conflict": {
|
||||
"doctrine/doctrine-bundle": "<2.10",
|
||||
@@ -15090,11 +15093,12 @@
|
||||
"doctrine/doctrine-bundle": "^2.10|^3.0",
|
||||
"doctrine/orm": "^2.15|^3",
|
||||
"doctrine/persistence": "^3.1|^4.0",
|
||||
"symfony/http-client": "^6.4|^7.0|^8.0",
|
||||
"symfony/phpunit-bridge": "^6.4.1|^7.0|^8.0",
|
||||
"symfony/security-core": "^6.4|^7.0|^8.0",
|
||||
"symfony/security-http": "^6.4|^7.0|^8.0",
|
||||
"symfony/yaml": "^6.4|^7.0|^8.0",
|
||||
"symfony/doctrine-bridge": "^7.4|^8.1",
|
||||
"symfony/http-client": "^7.4|^8.1",
|
||||
"symfony/phpunit-bridge": "^7.4|^8.1",
|
||||
"symfony/security-core": "^7.4|^8.1",
|
||||
"symfony/security-http": "^7.4|^8.1",
|
||||
"symfony/yaml": "^7.4|^8.1",
|
||||
"twig/twig": "^3.0|^4.x-dev"
|
||||
},
|
||||
"type": "symfony-bundle",
|
||||
@@ -15129,7 +15133,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/symfony/maker-bundle/issues",
|
||||
"source": "https://github.com/symfony/maker-bundle/tree/v1.67.0"
|
||||
"source": "https://github.com/symfony/maker-bundle/tree/1.68.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -15149,7 +15153,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-18T13:39:06+00:00"
|
||||
"time": "2026-09-12T21:13:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/phpunit-bridge",
|
||||
|
||||
@@ -26,6 +26,7 @@ framework:
|
||||
Symfony\Component\Notifier\Message\SmsMessage: async
|
||||
App\Message\MailjetNewsletterEventMessage: async
|
||||
App\Message\CreateClickUpBookingTaskMessage: async
|
||||
App\Message\RoleNominationMessage: async
|
||||
|
||||
# Route your messages to the transports
|
||||
# 'App\Message\YourMessage': async
|
||||
|
||||
@@ -2,9 +2,12 @@ framework:
|
||||
router:
|
||||
utf8: true
|
||||
|
||||
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
|
||||
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
|
||||
#default_uri: http://localhost
|
||||
# How URLs are generated where there is no request to take the host from: scheduled
|
||||
# commands and, notably, the Messenger workers that send mail off the request path.
|
||||
# Without it those links would point at http://localhost. Only ever consulted in a
|
||||
# non-HTTP context, so web requests keep using their own host — which matters here,
|
||||
# since one codebase serves four brands.
|
||||
default_uri: '%env(APP_BASE_URL)%'
|
||||
|
||||
when@prod:
|
||||
framework:
|
||||
|
||||
@@ -31,6 +31,12 @@ parameters:
|
||||
10321389: 'Reisen-Alert Stubaital'
|
||||
10554990: 'Reisen-Alert Ski & Boarderweek'
|
||||
|
||||
# Email domains whose accounts are members of staff. BusPro has no CRM selection expressing
|
||||
# "works here", so ROLE_EMPLOYEE is derived from the address instead. Matched exactly on the
|
||||
# domain part, never as a suffix.
|
||||
employee_email_domains:
|
||||
- 'ep-reisen.de'
|
||||
|
||||
# BusProNet "Hausleitung {CODE}" CRM selections, by selection id.
|
||||
# DEPLOYMENT-CRITICAL: roles and hotel codes are synced on every login, so an id missing
|
||||
# here does not merely fail to nominate a Hausleitung — it revokes the role and the hotel
|
||||
@@ -299,6 +305,10 @@ services:
|
||||
arguments:
|
||||
$houseManagerIds: '%bpn_crm_house_manager_ids%'
|
||||
|
||||
App\Security\EmployeeDomainMatcher:
|
||||
arguments:
|
||||
$domains: '%employee_email_domains%'
|
||||
|
||||
App\Service\DomainConfigProvider:
|
||||
arguments:
|
||||
$domainConfig: '%domain_config%'
|
||||
|
||||
+2
-33
@@ -77,47 +77,16 @@ $rsyncOptions = [
|
||||
];
|
||||
|
||||
host('prod')
|
||||
->setHostname('185.243.135.29')
|
||||
->setRemoteUser('p704161')
|
||||
->setForwardAgent(true)
|
||||
->setSshMultiplexing(true)
|
||||
->setDeployPath('/home/www/p704161/html/myep')
|
||||
->set('writable_mode', 'chmod')
|
||||
->set('http_user', 'p704161')
|
||||
->set('bin/php', '/usr/local/bin/php')
|
||||
->set('rsync_src', __DIR__)
|
||||
->set('rsync', $rsyncOptions)
|
||||
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.de')
|
||||
;
|
||||
|
||||
host('staging')
|
||||
->setHostname('185.243.135.29')
|
||||
->setRemoteUser('p704161')
|
||||
->setForwardAgent(true)
|
||||
->setSshMultiplexing(true)
|
||||
->setDeployPath('/home/www/p704161/html/myep-staging')
|
||||
->set('writable_mode', 'chmod')
|
||||
->set('http_user', 'p704161')
|
||||
->set('bin/php', '/usr/local/bin/php')
|
||||
->set('rsync_src', __DIR__)
|
||||
->set('rsync', $rsyncOptions)
|
||||
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.net --web-basic-auth=myep:staging')
|
||||
->add('shared_files', [
|
||||
'public/.htpasswd',
|
||||
])
|
||||
;
|
||||
|
||||
host('hetzner')
|
||||
->setHostname('dedi10193.your-server.de')
|
||||
->setRemoteUser('myepsf')
|
||||
->setForwardAgent(true)
|
||||
->setSshMultiplexing(true)
|
||||
->setDeployPath('/usr/home/myepsf/public_html/myep')
|
||||
->setDeployPath('/usr/home/myepsf/public_html/prod')
|
||||
->set('bin/php', '/usr/bin/php')
|
||||
->set('http_user', 'myepsf')
|
||||
->set('rsync_src', __DIR__)
|
||||
->set('rsync', $rsyncOptions)
|
||||
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.net --web-basic-auth=myep:staging')
|
||||
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.de')
|
||||
->add('shared_files', [
|
||||
'public/.htpasswd',
|
||||
])
|
||||
|
||||
@@ -6,19 +6,20 @@ namespace App\Dashboard\Widget;
|
||||
|
||||
use App\Dashboard\Contract\DashboardWidgetProviderInterface;
|
||||
use App\Entity\User;
|
||||
use App\Form\Model\Filter\AbstractListFilterDto;
|
||||
use App\Model\DashboardWidget;
|
||||
use App\Model\DashboardWidgetEntry;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Security\Role;
|
||||
use App\Service\RoleApprovalUrlGenerator;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
/**
|
||||
* The accounts waiting on an administrator to act.
|
||||
*
|
||||
* A nomination grants nothing until it is approved, and nothing else in the application
|
||||
* announces that one is waiting — without this widget an administrator only finds out by
|
||||
* opening the user list.
|
||||
* A nomination grants nothing until it is approved. RoleNominationHandler emails the
|
||||
* administrators the moment one appears, but that fires once, on the login that produced it —
|
||||
* this widget is the standing list, and the only thing that still shows a nomination somebody
|
||||
* has left sitting.
|
||||
*/
|
||||
class PendingRoleApprovalsWidgetProvider implements DashboardWidgetProviderInterface
|
||||
{
|
||||
@@ -27,6 +28,7 @@ class PendingRoleApprovalsWidgetProvider implements DashboardWidgetProviderInter
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly UrlGeneratorInterface $urlGenerator,
|
||||
private readonly RoleApprovalUrlGenerator $approvalUrlGenerator,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -46,7 +48,7 @@ class PendingRoleApprovalsWidgetProvider implements DashboardWidgetProviderInter
|
||||
'Offene Rollenfreigaben',
|
||||
array_map(fn (User $user): DashboardWidgetEntry => new DashboardWidgetEntry(
|
||||
$this->label($user),
|
||||
$this->approvalUrl($user),
|
||||
$this->approvalUrlGenerator->forUser($user),
|
||||
'user',
|
||||
), $this->userRepository->findWithPendingRoles(self::LIMIT)),
|
||||
'Keine offenen Rollenfreigaben.',
|
||||
@@ -65,20 +67,4 @@ class PendingRoleApprovalsWidgetProvider implements DashboardWidgetProviderInter
|
||||
|
||||
return sprintf('%s: %s', $user->getDisplayName(), implode(', ', $nominated));
|
||||
}
|
||||
|
||||
/**
|
||||
* The user list, filtered down to this account.
|
||||
*
|
||||
* Approving happens in a modal that app_admin_user_permissions renders as a bare fragment,
|
||||
* so it cannot be linked to directly. The filtered list is one click away from it and shows
|
||||
* the nomination badge on the way. The query string is flat because the list filter forms
|
||||
* declare no block prefix.
|
||||
*/
|
||||
private function approvalUrl(User $user): string
|
||||
{
|
||||
return $this->urlGenerator->generate('app_admin_user', [
|
||||
AbstractListFilterDto::MARKER => 1,
|
||||
'q' => $user->getEmail(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Message;
|
||||
|
||||
/**
|
||||
* Dispatched when the BusPro CRM newly nominates an account for one or more administrative roles,
|
||||
* so that the administrators who can approve it are told there is something waiting in
|
||||
* /admin/user.
|
||||
*
|
||||
* Carries the ids only, never the User: the handler runs in a separate process, where a
|
||||
* serialized entity would be stale by the time it is read.
|
||||
*/
|
||||
final class RoleNominationMessage
|
||||
{
|
||||
/**
|
||||
* @param string[] $roles the nominated roles themselves, not their _PENDING markers
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $userId,
|
||||
public readonly array $roles,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\MessageHandler;
|
||||
|
||||
use App\Email\Mailer;
|
||||
use App\Message\RoleNominationMessage;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Security\Role;
|
||||
use App\Service\RoleApprovalUrlGenerator;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
/**
|
||||
* Tells the administrators that an account is waiting for a role to be approved.
|
||||
*
|
||||
* Runs off the request: mail is routed sync in this application, so sending it inline would put
|
||||
* SMTP latency and SMTP failures into the login path.
|
||||
*/
|
||||
#[AsMessageHandler]
|
||||
final class RoleNominationHandler
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly Mailer $mailer,
|
||||
private readonly RoleApprovalUrlGenerator $approvalUrlGenerator,
|
||||
private readonly LoggerInterface $authLogger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(RoleNominationMessage $message): void
|
||||
{
|
||||
$user = $this->userRepository->find($message->userId);
|
||||
|
||||
if (null === $user) {
|
||||
// The account is gone — a retry cannot bring it back.
|
||||
$this->authLogger->warning('Nominated user not found, skipping the role nomination email', [
|
||||
'userId' => $message->userId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$recipients = array_values(array_filter(array_map(
|
||||
static fn ($admin): ?string => $admin->getEmail(),
|
||||
$this->userRepository->findAdministrators(),
|
||||
)));
|
||||
|
||||
if ([] === $recipients) {
|
||||
// Worth a warning rather than a silent return: nobody can approve the nomination, and
|
||||
// without this line nobody would find out that the notification goes nowhere.
|
||||
$this->authLogger->warning('No administrator to notify about a role nomination', [
|
||||
'userId' => $message->userId,
|
||||
'roles' => $message->roles,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$labels = Role::labels();
|
||||
|
||||
$this->mailer->createAndSendEmail(
|
||||
[
|
||||
'user' => $user,
|
||||
'roles' => array_values(array_map(
|
||||
static fn (string $role): string => $labels[$role] ?? $role,
|
||||
$message->roles,
|
||||
)),
|
||||
// Absolute: generated in a worker, where there is no request to borrow a host
|
||||
// from (see framework.router.default_uri).
|
||||
'approvalUrl' => $this->approvalUrlGenerator->forUser($user, UrlGeneratorInterface::ABSOLUTE_URL),
|
||||
],
|
||||
[
|
||||
'template' => 'email/role_nomination.html.twig',
|
||||
'subject' => 'Neue Rollen-Freischaltung angefordert',
|
||||
'to' => $recipients,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,32 @@ class UserRepository extends ServiceEntityRepository
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* The administrators who can approve a role nomination.
|
||||
*
|
||||
* The quote-anchored needle is load-bearing. ROLE_ADMIN_PENDING lives in the same JSON column,
|
||||
* and an unanchored '%ROLE_ADMIN%' would match it — which would mail the very people whose own
|
||||
* nomination is unapproved about other people's nominations. The result is filtered through
|
||||
* Role::effectiveOnly() as well, so the guarantee does not rest on the LIKE alone.
|
||||
*
|
||||
* @return User[]
|
||||
*/
|
||||
public function findAdministrators(): array
|
||||
{
|
||||
/** @var User[] $candidates */
|
||||
$candidates = $this->createQueryBuilder('u')
|
||||
->andWhere('u.roles LIKE :admin')
|
||||
->setParameter('admin', '%"'.Role::ADMIN.'"%')
|
||||
->orderBy('u.email', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
return array_values(array_filter(
|
||||
$candidates,
|
||||
static fn (User $user): bool => \in_array(Role::ADMIN, Role::effectiveOnly($user->getRoles()), true),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Everyone worth filtering an accommodation booking by: current groups staff, plus whoever
|
||||
* a booking is still assigned to even after losing the role — otherwise a booking assigned
|
||||
|
||||
@@ -12,12 +12,14 @@ use App\BusProNet\Model\CrmAttributes;
|
||||
use App\BusProNet\Model\PersonalData;
|
||||
use App\Entity\User;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use App\Message\RoleNominationMessage;
|
||||
use App\Service\ProfileCompletenessChecker;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
|
||||
@@ -34,8 +36,10 @@ use Symfony\Component\Security\Http\Util\TargetPathTrait;
|
||||
*
|
||||
* The password is kept, RSA-encrypted, because every later BPN call needs it again.
|
||||
*
|
||||
* BusPro owns the whole role set and the hotel codes: both are synced on every login, in both
|
||||
* directions, so anything the CRM no longer reports is withdrawn here. What the CRM claims is
|
||||
* BusPro owns the whole role set bar one, and the hotel codes: both are synced on every login, in
|
||||
* both directions, so anything the CRM no longer reports is withdrawn here. The exception is
|
||||
* ROLE_EMPLOYEE, which BusPro has no selection for and which is derived from the account's email
|
||||
* domain — passed to Role::sync() as a claim, so it is granted and revoked by the same machinery. What the CRM claims is
|
||||
* not automatically granted, though — Role::sync() turns an administrative claim into a
|
||||
* nomination that an administrator has to approve in /admin/user, because BusPro backend users
|
||||
* can edit their own CRM selections and would otherwise make themselves administrators.
|
||||
@@ -51,6 +55,8 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
|
||||
private readonly Crypt $crypt,
|
||||
private readonly ProfileCompletenessChecker $completenessChecker,
|
||||
private readonly LoggerInterface $authLogger,
|
||||
private readonly EmployeeDomainMatcher $employeeDomainMatcher,
|
||||
private readonly MessageBusInterface $messageBus,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -117,7 +123,7 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
|
||||
->setProfileComplete($this->completenessChecker->isComplete($personalData))
|
||||
;
|
||||
|
||||
$this->syncFromCrm($user, $crmAttributes);
|
||||
$nominated = $this->syncFromCrm($user, $crmAttributes);
|
||||
|
||||
// Registered only once it is fully populated: syncFromCrm() logs on a channel that writes
|
||||
// to the database, and an account already managed at that point would be flushed
|
||||
@@ -126,14 +132,24 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
|
||||
$this->entityManager->persist($user);
|
||||
$this->entityManager->flush();
|
||||
|
||||
// After the flush, deliberately: a first login has no id before it, and the transport is
|
||||
// Doctrine-backed, so a message queued ahead of a failing flush would announce a
|
||||
// nomination that was never stored.
|
||||
if ([] !== $nominated) {
|
||||
$this->messageBus->dispatch(new RoleNominationMessage((int) $user->getId(), $nominated));
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes back what the CRM currently claims: the roles per Role::sync() and the hotel codes
|
||||
* verbatim. Both replace what is stored, which is what makes BusPro the source of truth.
|
||||
*
|
||||
* @return string[] the roles this login newly nominated the account for — the roles
|
||||
* themselves, not their markers, and empty whenever nothing changed
|
||||
*/
|
||||
private function syncFromCrm(User $user, CrmAttributes $crmAttributes): void
|
||||
private function syncFromCrm(User $user, CrmAttributes $crmAttributes): array
|
||||
{
|
||||
$previousRoles = $user->getRoles();
|
||||
|
||||
@@ -148,25 +164,43 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
|
||||
// An existing account keeps everything it has. A brand new one still needs a role,
|
||||
// and an empty claim set is exactly what Role::sync() answers with the fallback.
|
||||
if ([] !== Role::assignedOnly($previousRoles)) {
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
$claimedRoles = $crmAttributes->roles;
|
||||
|
||||
// Not a CRM claim: BusPro has no selection for it, so the account's own address decides.
|
||||
// Passing it in as a claim rather than setting it afterwards is what makes it revocable —
|
||||
// Role::sync() strips every stored role the claim set does not contain.
|
||||
if ($this->employeeDomainMatcher->isEmployee($user->getEmail())) {
|
||||
$claimedRoles[] = Role::EMPLOYEE;
|
||||
}
|
||||
|
||||
$user
|
||||
->setRoles(Role::sync($previousRoles, $crmAttributes->roles))
|
||||
->setRoles(Role::sync($previousRoles, $claimedRoles))
|
||||
->setHotelCodes(array_values(array_unique($crmAttributes->hotelCodes)))
|
||||
;
|
||||
|
||||
$nominated = array_diff(Role::pendingOnly($user->getRoles()), Role::pendingOnly($previousRoles));
|
||||
$nominated = array_values(array_diff(
|
||||
Role::pendingOnly($user->getRoles()),
|
||||
Role::pendingOnly($previousRoles),
|
||||
));
|
||||
|
||||
if ([] !== $nominated) {
|
||||
// The CRM claims an administrative role for somebody who does not hold it. It grants
|
||||
// nothing until an administrator approves it in /admin/user.
|
||||
$this->authLogger->info('Nominated for administrative roles by the BPN CRM', [
|
||||
'email' => $user->getEmail(),
|
||||
'roles' => array_values($nominated),
|
||||
]);
|
||||
if ([] === $nominated) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// The CRM claims an administrative role for somebody who does not hold it. It grants
|
||||
// nothing until an administrator approves it in /admin/user.
|
||||
$this->authLogger->info('Nominated for administrative roles by the BPN CRM', [
|
||||
'email' => $user->getEmail(),
|
||||
'roles' => $nominated,
|
||||
]);
|
||||
|
||||
// Only the newly appeared markers reach this point, so a repeat login with a nomination
|
||||
// still standing announces nothing. That difference is the whole de-duplication.
|
||||
return array_keys(Role::nominatedFrom($nominated));
|
||||
}
|
||||
|
||||
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
/**
|
||||
* Decides whether an account belongs to a member of staff, by its email domain.
|
||||
*
|
||||
* BusPro has no CRM selection expressing "works here", so the email address is the only signal
|
||||
* available. The domains are configuration (%employee_email_domains%) rather than a constant
|
||||
* because they are deployment-specific, in the same way the brand hosts and the CRM selection ids
|
||||
* are.
|
||||
*
|
||||
* Matching is exact on the domain part and never on a suffix: "mail.ep-reisen.de" and
|
||||
* "notep-reisen.de" are not "ep-reisen.de". A suffix match here would hand ROLE_EMPLOYEE to
|
||||
* anybody able to register a domain ending in the configured one.
|
||||
*/
|
||||
final class EmployeeDomainMatcher
|
||||
{
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
private readonly array $domains;
|
||||
|
||||
/**
|
||||
* @param string[] $domains
|
||||
*/
|
||||
public function __construct(array $domains)
|
||||
{
|
||||
$this->domains = array_values(array_filter(array_map(
|
||||
static fn (string $domain): string => strtolower(trim($domain, " \t\n\r\0\x0B.@")),
|
||||
$domains,
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* A malformed or missing identifier is simply not an employee. It must not throw: this runs
|
||||
* inside the authentication path, where an exception would turn a bad address into a failed
|
||||
* login rather than a login without the role.
|
||||
*/
|
||||
public function isEmployee(?string $email): bool
|
||||
{
|
||||
if (null === $email || [] === $this->domains) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$position = strrpos($email, '@');
|
||||
|
||||
if (false === $position) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$domain = strtolower(substr($email, $position + 1));
|
||||
|
||||
return \in_array($domain, $this->domains, true);
|
||||
}
|
||||
}
|
||||
+17
-3
@@ -16,6 +16,13 @@ namespace App\Security;
|
||||
* nothing until an administrator approves it in /admin/user. The CRM's word alone is enough to
|
||||
* take a role away, never to hand it out, and an administrator's word alone is enough for
|
||||
* neither.
|
||||
*
|
||||
* One role is not claimed by the CRM at all: ROLE_EMPLOYEE is derived from the account's own
|
||||
* email domain, because BusPro has no selection expressing "works here". It is passed to sync()
|
||||
* as a claim alongside the CRM's, so it is granted and revoked by exactly the same machinery.
|
||||
* That widens a claim from "what the CRM reports" to "what the CRM reports plus what the account
|
||||
* itself implies", and nothing more: ROLE_EMPLOYEE is not administrative, so it cannot reach the
|
||||
* nomination path, and the CRM remains the only source for every role that grants privileges.
|
||||
*/
|
||||
final class Role
|
||||
{
|
||||
@@ -32,6 +39,7 @@ final class Role
|
||||
public const HOUSE_MANAGER = 'ROLE_HOUSE_MANAGER';
|
||||
public const GROUPS_ADMIN = 'ROLE_GROUPS_ADMIN';
|
||||
public const GROUPS_MANAGER = 'ROLE_GROUPS_MANAGER';
|
||||
public const EMPLOYEE = 'ROLE_EMPLOYEE';
|
||||
|
||||
/**
|
||||
* Appended to an administrative role to mark it as claimed by the CRM but not yet approved.
|
||||
@@ -55,17 +63,21 @@ final class Role
|
||||
self::HOUSE_MANAGER,
|
||||
self::GROUPS_ADMIN,
|
||||
self::GROUPS_MANAGER,
|
||||
self::EMPLOYEE,
|
||||
];
|
||||
|
||||
/**
|
||||
* Roles the CRM grants outright. ROLE_CUSTOMER is never claimed by BusPro — it is the
|
||||
* fallback for an account left without any effective role, and exclusive with the others.
|
||||
* Roles granted outright, without an approval step. ROLE_CUSTOMER is never claimed by BusPro —
|
||||
* it is the fallback for an account left without any effective role, and exclusive with the
|
||||
* others. ROLE_EMPLOYEE is not claimed by BusPro either: it is derived from the account's own
|
||||
* email domain (see EmployeeDomainMatcher) and, being effective, displaces that fallback.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
public const UNCONDITIONAL = [
|
||||
self::TEAMER,
|
||||
self::CUSTOMER,
|
||||
self::EMPLOYEE,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -180,7 +192,8 @@ final class Role
|
||||
* Nothing here can raise a privilege: step 3 only ever produces markers.
|
||||
*
|
||||
* @param string[] $storedRoles
|
||||
* @param string[] $claimedRoles what the CRM reports
|
||||
* @param string[] $claimedRoles what the CRM reports, plus the roles derived from the account
|
||||
* itself (ROLE_EMPLOYEE); anything outside self::ALL is ignored
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
@@ -274,6 +287,7 @@ final class Role
|
||||
self::HOUSE_MANAGER => 'Hausleitung',
|
||||
self::GROUPS_ADMIN => 'Preisrechner Admin',
|
||||
self::GROUPS_MANAGER => 'Preisrechner',
|
||||
self::EMPLOYEE => 'Mitarbeiter:in',
|
||||
];
|
||||
|
||||
foreach (self::ADMINISTRATIVE as $role) {
|
||||
|
||||
@@ -18,8 +18,9 @@ use App\Form\Model\ParticipantDto;
|
||||
*
|
||||
* Service selections are gated by travel-level mutability flags (additionalServicesMutable,
|
||||
* transportationServicesMutable, pickupsMutable). Insurance is always applied regardless
|
||||
* of mutability. Merge strategy (only apply if resolves to a valid service) is used for
|
||||
* single-select fields; overwrite strategy is used for multi-select and boolean fields.
|
||||
* of mutability. Merge strategy (only apply if it resolves against the travel data) is used
|
||||
* for single-select fields, room assignment included; overwrite strategy is used for
|
||||
* multi-select, boolean and free-text fields.
|
||||
*/
|
||||
class BookingEditDraftMerger
|
||||
{
|
||||
@@ -52,7 +53,7 @@ class BookingEditDraftMerger
|
||||
|
||||
// Room assignment
|
||||
if (true === isset($data['roomAssignment']) && true === is_array($data['roomAssignment'])) {
|
||||
$this->applyRoomAssignment($participant, $data['roomAssignment']);
|
||||
$this->applyRoomAssignment($participant, $data['roomAssignment'], $travel);
|
||||
}
|
||||
|
||||
// License plate
|
||||
@@ -156,12 +157,27 @@ class BookingEditDraftMerger
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $data */
|
||||
private function applyRoomAssignment(ParticipantDto $participant, array $data): void
|
||||
/**
|
||||
* Applies the drafted room assignment to the participant.
|
||||
*
|
||||
* The room id uses the same merge strategy as single-select services: it is only
|
||||
* applied when it resolves to a room of the current travel. A draft may therefore move
|
||||
* a participant to another room, but never unassign one. Edit mode renders the room as
|
||||
* static text and never submits assignedRoomId, so a null in the draft is only ever an
|
||||
* artefact of a snapshot taken before BusPro reported the assignment - replaying it
|
||||
* would drop the participant's room, both in the UI and in the outbound zuordnung.
|
||||
*
|
||||
* remarksRoom keeps overwrite semantics: it is free text the user can deliberately clear.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function applyRoomAssignment(ParticipantDto $participant, array $data, Travel $travel): void
|
||||
{
|
||||
if (true === array_key_exists('assignedRoomId', $data)) {
|
||||
$participant->assignedRoomId = $data['assignedRoomId'];
|
||||
$assignedRoomId = $data['assignedRoomId'] ?? null;
|
||||
if (null !== $assignedRoomId && null !== $travel->getRoomById((int) $assignedRoomId)) {
|
||||
$participant->assignedRoomId = (int) $assignedRoomId;
|
||||
}
|
||||
|
||||
if (true === array_key_exists('remarksRoom', $data)) {
|
||||
$participant->remarksRoom = $data['remarksRoom'];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Form\Model\Filter\AbstractListFilterDto;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
/**
|
||||
* Where to send somebody who should act on a role nomination.
|
||||
*
|
||||
* Deliberately not app_admin_user_permissions. That route renders the approval modal, which
|
||||
* extends htmx_modal_admin.html.twig — a bare <div> with no page chrome, meant to be swapped
|
||||
* into a page that is already open. Following it as a normal navigation, which is what a link
|
||||
* in an email or a dashboard does, yields an unstyled fragment.
|
||||
*
|
||||
* The user list filtered down to the one account is one click away from the modal and shows the
|
||||
* nomination badge on the way. The query string is flat because the list filter forms declare no
|
||||
* block prefix.
|
||||
*/
|
||||
class RoleApprovalUrlGenerator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UrlGeneratorInterface $urlGenerator,
|
||||
) {
|
||||
}
|
||||
|
||||
public function forUser(User $user, int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string
|
||||
{
|
||||
return $this->urlGenerator->generate(
|
||||
'app_admin_user',
|
||||
[
|
||||
AbstractListFilterDto::MARKER => 1,
|
||||
'q' => $user->getEmail(),
|
||||
],
|
||||
$referenceType,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{% extends 'email/layout.html.twig' %}
|
||||
|
||||
{% block body %}
|
||||
<h1>
|
||||
Neue Rollen-Freischaltung
|
||||
</h1>
|
||||
<p>
|
||||
Für ein MyE&P-Konto wurde im BusPro-CRM eine administrative Rolle hinterlegt. Sie muss
|
||||
freigeschaltet werden, bevor sie gilt.
|
||||
</p>
|
||||
<p>
|
||||
<strong>{{ user.displayName }}</strong>
|
||||
<br>
|
||||
{{ user.email }}
|
||||
</p>
|
||||
<p>
|
||||
Beantragt {{ roles|length > 1 ? 'wurden folgende Rollen' : 'wurde folgende Rolle' }}:
|
||||
{% for role in roles %}
|
||||
<br>
|
||||
- {{ role }}
|
||||
{% endfor %}
|
||||
</p>
|
||||
<p>
|
||||
<a href="{{ approvalUrl }}" class="button">
|
||||
Freischaltung prüfen
|
||||
</a>
|
||||
</p>
|
||||
<p class="small">
|
||||
Solange die Freischaltung aussteht, hat die Rolle keinerlei Wirkung — das Konto kann damit
|
||||
nichts tun. Möchtest du sie nicht erteilen, ignoriere diese E-Mail einfach. Die Rolle wird
|
||||
erst entfernt, wenn die Auswahl im CRM entfernt wird.
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -8,6 +8,7 @@ use App\Dashboard\Widget\PendingRoleApprovalsWidgetProvider;
|
||||
use App\Entity\User;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Security\Role;
|
||||
use App\Service\RoleApprovalUrlGenerator;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
@@ -76,6 +77,10 @@ class PendingRoleApprovalsWidgetProviderTest extends TestCase
|
||||
},
|
||||
);
|
||||
|
||||
return new PendingRoleApprovalsWidgetProvider($repository, $urlGenerator);
|
||||
return new PendingRoleApprovalsWidgetProvider(
|
||||
$repository,
|
||||
$urlGenerator,
|
||||
new RoleApprovalUrlGenerator($urlGenerator),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\MessageHandler;
|
||||
|
||||
use App\Email\Mailer;
|
||||
use App\Entity\User;
|
||||
use App\Message\RoleNominationMessage;
|
||||
use App\MessageHandler\RoleNominationHandler;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Security\Role;
|
||||
use App\Service\RoleApprovalUrlGenerator;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
/**
|
||||
* The notification goes to the people who can act on it, and to nobody else: an account merely
|
||||
* nominated for ROLE_ADMIN must not be told about other people's nominations.
|
||||
*/
|
||||
class RoleNominationHandlerTest extends TestCase
|
||||
{
|
||||
/** @var array<string, mixed>|null */
|
||||
private ?array $sentOptions = null;
|
||||
|
||||
/** @var array<string, mixed>|null */
|
||||
private ?array $sentContext = null;
|
||||
|
||||
private ?string $generatedRoute = null;
|
||||
|
||||
private ?int $generatedReferenceType = null;
|
||||
|
||||
public function testMailsEveryAdministrator(): void
|
||||
{
|
||||
$handler = $this->handler(
|
||||
$this->user(7, '[email protected]'),
|
||||
[
|
||||
$this->user(1, '[email protected]', [Role::ADMIN]),
|
||||
$this->user(2, '[email protected]', [Role::ADMIN]),
|
||||
],
|
||||
);
|
||||
|
||||
$handler(new RoleNominationMessage(7, [Role::ADMIN]));
|
||||
|
||||
self::assertSame(['[email protected]', '[email protected]'], $this->sentOptions['to']);
|
||||
self::assertSame('email/role_nomination.html.twig', $this->sentOptions['template']);
|
||||
// Labelled for a human, not the raw role string.
|
||||
self::assertSame(['Administration'], $this->sentContext['roles']);
|
||||
// The filtered user list, not app_admin_user_permissions: that route renders a bare htmx
|
||||
// fragment, which a mail client following the link would show unstyled.
|
||||
self::assertSame('app_admin_user', $this->generatedRoute);
|
||||
// Absolute, because a worker has no request to borrow a host from.
|
||||
self::assertSame(UrlGeneratorInterface::ABSOLUTE_URL, $this->generatedReferenceType);
|
||||
self::assertSame('https://my.ep-reisen.de/generated', $this->sentContext['approvalUrl']);
|
||||
}
|
||||
|
||||
public function testAMissingAccountIsANoOp(): void
|
||||
{
|
||||
$handler = $this->handler(null, [$this->user(1, '[email protected]', [Role::ADMIN])]);
|
||||
|
||||
$handler(new RoleNominationMessage(7, [Role::ADMIN]));
|
||||
|
||||
self::assertNull($this->sentOptions);
|
||||
}
|
||||
|
||||
public function testWithoutAnAdministratorNothingIsSent(): void
|
||||
{
|
||||
$handler = $this->handler($this->user(7, '[email protected]'), []);
|
||||
|
||||
$handler(new RoleNominationMessage(7, [Role::ADMIN]));
|
||||
|
||||
self::assertNull($this->sentOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $roles
|
||||
*/
|
||||
private function user(int $id, string $email, array $roles = []): User
|
||||
{
|
||||
$user = (new User($email))->setRoles($roles);
|
||||
|
||||
// The id is generated by Doctrine and has no setter, but the notification links to it.
|
||||
$property = new \ReflectionProperty(User::class, 'id');
|
||||
$property->setValue($user, $id);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param User[] $administrators
|
||||
*/
|
||||
private function handler(?User $nominee, array $administrators): RoleNominationHandler
|
||||
{
|
||||
$userRepository = $this->createStub(UserRepository::class);
|
||||
$userRepository->method('find')->willReturn($nominee);
|
||||
$userRepository->method('findAdministrators')->willReturn($administrators);
|
||||
|
||||
$mailer = $this->createStub(Mailer::class);
|
||||
$mailer
|
||||
->method('createAndSendEmail')
|
||||
->willReturnCallback(function (array $context, array $options): void {
|
||||
$this->sentContext = $context;
|
||||
$this->sentOptions = $options;
|
||||
})
|
||||
;
|
||||
|
||||
// Records what was asked of the router rather than imitating its output: what matters is
|
||||
// which route the mail points at and that it is absolute, not how a query string is escaped.
|
||||
$urlGenerator = $this->createStub(UrlGeneratorInterface::class);
|
||||
$urlGenerator
|
||||
->method('generate')
|
||||
->willReturnCallback(function (string $route, array $parameters, int $referenceType): string {
|
||||
$this->generatedRoute = $route;
|
||||
$this->generatedReferenceType = $referenceType;
|
||||
|
||||
return 'https://my.ep-reisen.de/generated';
|
||||
})
|
||||
;
|
||||
|
||||
return new RoleNominationHandler(
|
||||
$userRepository,
|
||||
$mailer,
|
||||
new RoleApprovalUrlGenerator($urlGenerator),
|
||||
$this->createStub(LoggerInterface::class),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,10 @@ use App\BusProNet\Model\CrmAttributes;
|
||||
use App\BusProNet\Model\CrmSelectionGroup;
|
||||
use App\BusProNet\Model\PersonalData;
|
||||
use App\Entity\User;
|
||||
use App\Message\RoleNominationMessage;
|
||||
use App\Security\BpnAuthenticator;
|
||||
use App\Security\Crypt;
|
||||
use App\Security\EmployeeDomainMatcher;
|
||||
use App\Security\Role;
|
||||
use App\Service\ProfileCompletenessChecker;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -18,6 +20,8 @@ use Doctrine\ORM\EntityRepository;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||
|
||||
@@ -27,6 +31,14 @@ use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||
*/
|
||||
class BpnAuthenticatorTest extends TestCase
|
||||
{
|
||||
/** @var object[] messages the authenticator dispatched during the login under test */
|
||||
private array $dispatched = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->dispatched = [];
|
||||
}
|
||||
|
||||
public function testNewAccountIsSeededFromTheCrm(): void
|
||||
{
|
||||
$persisted = null;
|
||||
@@ -196,6 +208,86 @@ class BpnAuthenticatorTest extends TestCase
|
||||
return $attributes;
|
||||
}
|
||||
|
||||
public function testStaffEmailDomainGrantsTheEmployeeRole(): void
|
||||
{
|
||||
$persisted = null;
|
||||
$authenticator = $this->authenticator($this->crmAttributes([], []), null, $persisted);
|
||||
|
||||
$user = $this->loadUser($authenticator, '[email protected]');
|
||||
|
||||
// Effective, so it displaces the customer fallback the same account would get otherwise.
|
||||
self::assertSame(['ROLE_USER', Role::EMPLOYEE], $user->getRoles());
|
||||
}
|
||||
|
||||
public function testAnotherEmailDomainStillFallsBackToCustomer(): void
|
||||
{
|
||||
$persisted = null;
|
||||
$authenticator = $this->authenticator($this->crmAttributes([], []), null, $persisted);
|
||||
|
||||
$user = $this->loadUser($authenticator, '[email protected]');
|
||||
|
||||
self::assertSame(['ROLE_USER', Role::CUSTOMER], $user->getRoles());
|
||||
}
|
||||
|
||||
public function testEmployeeRoleIsWithdrawnWhenTheAddressIsNoLongerStaff(): void
|
||||
{
|
||||
$existing = (new User('[email protected]'))->setRoles([Role::EMPLOYEE]);
|
||||
$persisted = null;
|
||||
$authenticator = $this->authenticator($this->crmAttributes([], []), $existing, $persisted);
|
||||
|
||||
$user = $this->loadUser($authenticator, '[email protected]');
|
||||
|
||||
self::assertSame(['ROLE_USER', Role::CUSTOMER], $user->getRoles());
|
||||
}
|
||||
|
||||
public function testANewNominationIsAnnouncedOnce(): void
|
||||
{
|
||||
$persisted = null;
|
||||
$authenticator = $this->authenticator($this->crmAttributes([Role::ADMIN], []), null, $persisted);
|
||||
|
||||
$user = $this->loadUser($authenticator);
|
||||
|
||||
self::assertCount(1, $this->dispatched);
|
||||
$message = $this->dispatched[0];
|
||||
self::assertInstanceOf(RoleNominationMessage::class, $message);
|
||||
// The role itself, not its marker: the marker is an internal bookkeeping detail.
|
||||
self::assertSame([Role::ADMIN], $message->roles);
|
||||
self::assertContains(Role::pending(Role::ADMIN), $user->getRoles());
|
||||
}
|
||||
|
||||
public function testAStandingNominationIsNotAnnouncedAgain(): void
|
||||
{
|
||||
$existing = (new User('[email protected]'))->setRoles([Role::pending(Role::ADMIN)]);
|
||||
$persisted = null;
|
||||
$authenticator = $this->authenticator($this->crmAttributes([Role::ADMIN], []), $existing, $persisted);
|
||||
|
||||
$this->loadUser($authenticator);
|
||||
|
||||
// The nomination has not changed, so there is nothing new to tell an administrator about.
|
||||
self::assertSame([], $this->dispatched);
|
||||
}
|
||||
|
||||
public function testAnApprovedRoleIsNotAnnouncedAsANomination(): void
|
||||
{
|
||||
$existing = (new User('[email protected]'))->setRoles([Role::ADMIN]);
|
||||
$persisted = null;
|
||||
$authenticator = $this->authenticator($this->crmAttributes([Role::ADMIN], []), $existing, $persisted);
|
||||
|
||||
$this->loadUser($authenticator);
|
||||
|
||||
self::assertSame([], $this->dispatched);
|
||||
}
|
||||
|
||||
public function testALoginWithoutANominationAnnouncesNothing(): void
|
||||
{
|
||||
$persisted = null;
|
||||
$authenticator = $this->authenticator($this->crmAttributes([Role::TEAMER], []), null, $persisted);
|
||||
|
||||
$this->loadUser($authenticator);
|
||||
|
||||
self::assertSame([], $this->dispatched);
|
||||
}
|
||||
|
||||
private function authenticator(
|
||||
CrmAttributes $crmAttributes,
|
||||
?User $existing,
|
||||
@@ -231,6 +323,16 @@ class BpnAuthenticatorTest extends TestCase
|
||||
$completenessChecker = $this->createStub(ProfileCompletenessChecker::class);
|
||||
$completenessChecker->method('isComplete')->willReturn(true);
|
||||
|
||||
$messageBus = $this->createStub(MessageBusInterface::class);
|
||||
$messageBus
|
||||
->method('dispatch')
|
||||
->willReturnCallback(function (object $message): Envelope {
|
||||
$this->dispatched[] = $message;
|
||||
|
||||
return new Envelope($message);
|
||||
})
|
||||
;
|
||||
|
||||
return new BpnAuthenticator(
|
||||
$this->createStub(UrlGeneratorInterface::class),
|
||||
$apiClient,
|
||||
@@ -238,13 +340,15 @@ class BpnAuthenticatorTest extends TestCase
|
||||
$crypt,
|
||||
$completenessChecker,
|
||||
$this->createStub(LoggerInterface::class),
|
||||
new EmployeeDomainMatcher(['ep-reisen.de']),
|
||||
$messageBus,
|
||||
);
|
||||
}
|
||||
|
||||
private function loadUser(BpnAuthenticator $authenticator): User
|
||||
private function loadUser(BpnAuthenticator $authenticator, string $email = '[email protected]'): User
|
||||
{
|
||||
$request = new Request();
|
||||
$request->request->set('_username', '[email protected]');
|
||||
$request->request->set('_username', $email);
|
||||
$request->request->set('_password', 'secret');
|
||||
|
||||
$badge = $authenticator->authenticate($request)->getBadge(UserBadge::class);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Security;
|
||||
|
||||
use App\Security\EmployeeDomainMatcher;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* The domain is matched exactly, never as a suffix: a suffix match would hand ROLE_EMPLOYEE to
|
||||
* anybody able to register a domain ending in the configured one.
|
||||
*/
|
||||
class EmployeeDomainMatcherTest extends TestCase
|
||||
{
|
||||
#[DataProvider('addresses')]
|
||||
public function testRecognisesAnEmployeeAddress(?string $email, bool $expected): void
|
||||
{
|
||||
$matcher = new EmployeeDomainMatcher(['ep-reisen.de']);
|
||||
|
||||
self::assertSame($expected, $matcher->isEmployee($email));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{0: ?string, 1: bool}>
|
||||
*/
|
||||
public static function addresses(): iterable
|
||||
{
|
||||
yield 'the configured domain' => ['[email protected]', true];
|
||||
yield 'mixed case is still the same domain' => ['[email protected]', true];
|
||||
yield 'plus addressing does not touch the domain' => ['[email protected]', true];
|
||||
yield 'an at sign in the local part' => ['"odd@name"@ep-reisen.de', true];
|
||||
|
||||
yield 'another domain' => ['[email protected]', false];
|
||||
yield 'a subdomain is not the domain' => ['[email protected]', false];
|
||||
yield 'a domain merely ending in it' => ['[email protected]', false];
|
||||
yield 'the domain as a prefix' => ['[email protected]', false];
|
||||
yield 'no at sign at all' => ['ep-reisen.de', false];
|
||||
yield 'empty' => ['', false];
|
||||
yield 'null' => [null, false];
|
||||
}
|
||||
|
||||
public function testWithoutConfiguredDomainsNobodyIsAnEmployee(): void
|
||||
{
|
||||
self::assertFalse((new EmployeeDomainMatcher([]))->isEmployee('[email protected]'));
|
||||
}
|
||||
|
||||
public function testConfiguredDomainsAreNormalised(): void
|
||||
{
|
||||
$matcher = new EmployeeDomainMatcher([' EP-Reisen.DE ', '@example.org']);
|
||||
|
||||
self::assertTrue($matcher->isEmployee('[email protected]'));
|
||||
self::assertTrue($matcher->isEmployee('[email protected]'));
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,35 @@ class RoleTest extends TestCase
|
||||
self::assertSame([Role::ADMIN => 'Administration'], Role::nominatedFrom($roles));
|
||||
}
|
||||
|
||||
public function testEmployeeIsGrantedOutrightAndDisplacesTheCustomerFallback(): void
|
||||
{
|
||||
// The claim does not come from the CRM, but it travels the same path as one.
|
||||
self::assertSame([Role::EMPLOYEE], Role::sync([], [Role::EMPLOYEE]));
|
||||
}
|
||||
|
||||
public function testEmployeeIsRevokedOnceItIsNoLongerClaimed(): void
|
||||
{
|
||||
// Somebody whose address left the staff domain: no claim, so the role goes, and with no
|
||||
// effective role left the fallback returns.
|
||||
self::assertSame([Role::CUSTOMER], Role::sync([Role::EMPLOYEE], []));
|
||||
}
|
||||
|
||||
public function testEmployeeDoesNotShortCircuitTheNominationOfAnAdministrativeRole(): void
|
||||
{
|
||||
self::assertSame(
|
||||
[Role::EMPLOYEE, Role::pending(Role::ADMIN)],
|
||||
Role::sync([], [Role::EMPLOYEE, Role::ADMIN]),
|
||||
);
|
||||
}
|
||||
|
||||
public function testEmployeeAndTeamerCoexist(): void
|
||||
{
|
||||
self::assertSame(
|
||||
[Role::TEAMER, Role::EMPLOYEE],
|
||||
Role::sync([], [Role::TEAMER, Role::EMPLOYEE]),
|
||||
);
|
||||
}
|
||||
|
||||
public function testEveryRoleAndNominationHasALabel(): void
|
||||
{
|
||||
$labels = Role::labels();
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\BusProNet\Model\Room;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use App\Service\BookingEditDraftMerger;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests that draft restoration never unassigns a room.
|
||||
*
|
||||
* Edit mode renders the room as static text and never submits assignedRoomId, so a null in
|
||||
* the draft payload is always an artefact of a snapshot taken before BusPro reported the
|
||||
* assignment - typically for seats added to a group booking shortly before the draft was
|
||||
* saved. Replaying such a null would strip the room from the UI and from the outbound
|
||||
* zuordnung, so the room id uses the same merge strategy as single-select services.
|
||||
*/
|
||||
class BookingEditDraftMergerRoomAssignmentTest extends TestCase
|
||||
{
|
||||
private BookingEditDraftMerger $merger;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->merger = new BookingEditDraftMerger();
|
||||
}
|
||||
|
||||
public function testDraftDoesNotUnassignRoomWhenDraftValueIsNull(): void
|
||||
{
|
||||
$participant = new ParticipantDto();
|
||||
$participant->mutable = true;
|
||||
$participant->assignedRoomId = 74;
|
||||
|
||||
$travel = $this->createTravel([74 => $this->createRoom(74, 'Bett im Mehrbettzimmer')]);
|
||||
|
||||
$this->apply($travel, $participant, ['assignedRoomId' => null]);
|
||||
|
||||
$this->assertSame(74, $participant->assignedRoomId);
|
||||
}
|
||||
|
||||
public function testDraftAppliesRoomThatResolvesAgainstTravel(): void
|
||||
{
|
||||
$participant = new ParticipantDto();
|
||||
$participant->mutable = true;
|
||||
$participant->assignedRoomId = 74;
|
||||
|
||||
$travel = $this->createTravel([
|
||||
74 => $this->createRoom(74, 'Bett im Mehrbettzimmer'),
|
||||
75 => $this->createRoom(75, 'Doppelzimmer'),
|
||||
]);
|
||||
|
||||
$this->apply($travel, $participant, ['assignedRoomId' => 75]);
|
||||
|
||||
$this->assertSame(75, $participant->assignedRoomId);
|
||||
}
|
||||
|
||||
public function testDraftSkipsRoomThatIsUnknownToTravel(): void
|
||||
{
|
||||
$participant = new ParticipantDto();
|
||||
$participant->mutable = true;
|
||||
$participant->assignedRoomId = 74;
|
||||
|
||||
$travel = $this->createTravel([74 => $this->createRoom(74, 'Bett im Mehrbettzimmer')]);
|
||||
|
||||
$this->apply($travel, $participant, ['assignedRoomId' => 999]);
|
||||
|
||||
$this->assertSame(74, $participant->assignedRoomId);
|
||||
}
|
||||
|
||||
public function testDraftClearsRoomRemarks(): void
|
||||
{
|
||||
$participant = new ParticipantDto();
|
||||
$participant->mutable = true;
|
||||
$participant->assignedRoomId = 74;
|
||||
$participant->remarksRoom = 'Bitte mit Lisa';
|
||||
|
||||
$travel = $this->createTravel([74 => $this->createRoom(74, 'Bett im Mehrbettzimmer')]);
|
||||
|
||||
$this->apply($travel, $participant, ['assignedRoomId' => null, 'remarksRoom' => null]);
|
||||
|
||||
$this->assertNull($participant->remarksRoom);
|
||||
$this->assertSame(74, $participant->assignedRoomId);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $roomAssignment */
|
||||
private function apply(Travel $travel, ParticipantDto $participant, array $roomAssignment): void
|
||||
{
|
||||
$dto = new BookingDto($travel, 1);
|
||||
$dto->participants = [1 => $participant];
|
||||
|
||||
$this->merger->apply($dto, 1, $participant, ['roomAssignment' => $roomAssignment], $travel);
|
||||
}
|
||||
|
||||
/** @param array<int, Room> $rooms */
|
||||
private function createTravel(array $rooms): Travel
|
||||
{
|
||||
$travel = new Travel();
|
||||
$travel->rooms = $rooms;
|
||||
|
||||
return $travel;
|
||||
}
|
||||
|
||||
private function createRoom(int $id, string $label): Room
|
||||
{
|
||||
$room = new Room();
|
||||
$room->id = $id;
|
||||
$room->label = $label;
|
||||
|
||||
return $room;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user