Compare commits
18
Commits
12fe4d8e15
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96b7b3f451 | ||
|
|
e751eed4a0 | ||
|
|
bad916cfb7 | ||
|
|
cc3d32fcbb | ||
|
|
79709e3a3e | ||
|
|
ab7d00b35d | ||
|
|
679a864906 | ||
|
|
f080d05dd6 | ||
|
|
00637042e2 | ||
|
|
fbb3ebb8c1 | ||
|
|
ec83ad598f | ||
|
|
2cb4871268 | ||
|
|
c1415dee41 | ||
|
|
540cd8eeb7 | ||
|
|
725e32daab | ||
|
|
160ebef39e | ||
|
|
672fc30d7e | ||
|
|
ba4f66f117 |
Generated
+85
-230
@@ -935,27 +935,25 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "doctrine/lexer",
|
"name": "doctrine/lexer",
|
||||||
"version": "3.0.1",
|
"version": "3.0.2",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/doctrine/lexer.git",
|
"url": "https://github.com/doctrine/lexer.git",
|
||||||
"reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd"
|
"reference": "e96fe45e92a54233726014a7cc7340abf29bb14c"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd",
|
"url": "https://api.github.com/repos/doctrine/lexer/zipball/e96fe45e92a54233726014a7cc7340abf29bb14c",
|
||||||
"reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd",
|
"reference": "e96fe45e92a54233726014a7cc7340abf29bb14c",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.1"
|
"php": "^8.1"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"doctrine/coding-standard": "^12",
|
"doctrine/coding-standard": "^14",
|
||||||
"phpstan/phpstan": "^1.10",
|
"phpstan/phpstan": "^2",
|
||||||
"phpunit/phpunit": "^10.5",
|
"phpunit/phpunit": "^10.5.58 || ^12.5.4"
|
||||||
"psalm/plugin-phpunit": "^0.18.3",
|
|
||||||
"vimeo/psalm": "^5.21"
|
|
||||||
},
|
},
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"autoload": {
|
"autoload": {
|
||||||
@@ -992,7 +990,7 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/doctrine/lexer/issues",
|
"issues": "https://github.com/doctrine/lexer/issues",
|
||||||
"source": "https://github.com/doctrine/lexer/tree/3.0.1"
|
"source": "https://github.com/doctrine/lexer/tree/3.0.2"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1008,7 +1006,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2024-02-05T11:56:58+00:00"
|
"time": "2026-06-14T20:44:06+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "doctrine/migrations",
|
"name": "doctrine/migrations",
|
||||||
@@ -1115,16 +1113,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "doctrine/orm",
|
"name": "doctrine/orm",
|
||||||
"version": "3.7.1",
|
"version": "3.7.2",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/doctrine/orm.git",
|
"url": "https://github.com/doctrine/orm.git",
|
||||||
"reference": "037807a39697890dcede8b766c5efe8a1b4855fe"
|
"reference": "6b47a02cd1d7119ef4d8f90ca6d49651accef1a3"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/doctrine/orm/zipball/037807a39697890dcede8b766c5efe8a1b4855fe",
|
"url": "https://api.github.com/repos/doctrine/orm/zipball/6b47a02cd1d7119ef4d8f90ca6d49651accef1a3",
|
||||||
"reference": "037807a39697890dcede8b766c5efe8a1b4855fe",
|
"reference": "6b47a02cd1d7119ef4d8f90ca6d49651accef1a3",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -1148,7 +1146,7 @@
|
|||||||
"doctrine/coding-standard": "^14.0",
|
"doctrine/coding-standard": "^14.0",
|
||||||
"phpbench/phpbench": "^1.0",
|
"phpbench/phpbench": "^1.0",
|
||||||
"phpstan/extension-installer": "^1.4",
|
"phpstan/extension-installer": "^1.4",
|
||||||
"phpstan/phpstan": "2.1.23",
|
"phpstan/phpstan": "2.2.14",
|
||||||
"phpstan/phpstan-deprecation-rules": "^2",
|
"phpstan/phpstan-deprecation-rules": "^2",
|
||||||
"phpunit/phpunit": "^10.5.0 || ^11.5",
|
"phpunit/phpunit": "^10.5.0 || ^11.5",
|
||||||
"psr/log": "^1 || ^2 || ^3",
|
"psr/log": "^1 || ^2 || ^3",
|
||||||
@@ -1199,9 +1197,9 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/doctrine/orm/issues",
|
"issues": "https://github.com/doctrine/orm/issues",
|
||||||
"source": "https://github.com/doctrine/orm/tree/3.7.1"
|
"source": "https://github.com/doctrine/orm/tree/3.7.2"
|
||||||
},
|
},
|
||||||
"time": "2026-09-14T05:45:59+00:00"
|
"time": "2026-09-23T05:28:00+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "doctrine/persistence",
|
"name": "doctrine/persistence",
|
||||||
@@ -3851,16 +3849,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpoffice/phpspreadsheet",
|
"name": "phpoffice/phpspreadsheet",
|
||||||
"version": "5.9.0",
|
"version": "5.10.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
|
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
|
||||||
"reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339"
|
"reference": "eb18727acf6b1f4cc67145a52ab04f9fd4c28d53"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/05e99ebf61238a70227b4d9cc02d0030d34f6339",
|
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/eb18727acf6b1f4cc67145a52ab04f9fd4c28d53",
|
||||||
"reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339",
|
"reference": "eb18727acf6b1f4cc67145a52ab04f9fd4c28d53",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -3889,6 +3887,7 @@
|
|||||||
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
|
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
|
||||||
"dompdf/dompdf": "^2.0 || ^3.0",
|
"dompdf/dompdf": "^2.0 || ^3.0",
|
||||||
"ext-intl": "*",
|
"ext-intl": "*",
|
||||||
|
"ext-openssl": "*",
|
||||||
"friendsofphp/php-cs-fixer": "^3.2",
|
"friendsofphp/php-cs-fixer": "^3.2",
|
||||||
"mitoteam/jpgraph": "^10.5",
|
"mitoteam/jpgraph": "^10.5",
|
||||||
"mpdf/mpdf": "^8.1.1",
|
"mpdf/mpdf": "^8.1.1",
|
||||||
@@ -3898,11 +3897,12 @@
|
|||||||
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
|
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
|
||||||
"phpunit/phpunit": "^10.5 || ^11.0",
|
"phpunit/phpunit": "^10.5 || ^11.0",
|
||||||
"squizlabs/php_codesniffer": "^3.7",
|
"squizlabs/php_codesniffer": "^3.7",
|
||||||
"tecnickcom/tcpdf": "^6.5"
|
"tecnickcom/tcpdf": ">=6.8.0 <7.0.0"
|
||||||
},
|
},
|
||||||
"suggest": {
|
"suggest": {
|
||||||
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
|
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
|
||||||
"ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()",
|
"ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()",
|
||||||
|
"ext-openssl": "Handline Agile-encrypted Xlsx spreadsheets",
|
||||||
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
|
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
|
||||||
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
|
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
|
||||||
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
|
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
|
||||||
@@ -3954,9 +3954,9 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
|
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
|
||||||
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.9.0"
|
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.10.0"
|
||||||
},
|
},
|
||||||
"time": "2026-07-12T19:17:39+00:00"
|
"time": "2026-09-17T03:54:50+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpseclib/phpseclib",
|
"name": "phpseclib/phpseclib",
|
||||||
@@ -4695,35 +4695,33 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "sabberworm/php-css-parser",
|
"name": "sabberworm/php-css-parser",
|
||||||
"version": "v9.4.0",
|
"version": "v9.5.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
|
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
|
||||||
"reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f"
|
"reference": "f284e63b6e891e0c28631e54ba06c3ed102a9ef3"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f",
|
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/f284e63b6e891e0c28631e54ba06c3ed102a9ef3",
|
||||||
"reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f",
|
"reference": "f284e63b6e891e0c28631e54ba06c3ed102a9ef3",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"ext-iconv": "*",
|
"ext-iconv": "*",
|
||||||
"php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
|
"php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0"
|
||||||
"thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4"
|
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"php-parallel-lint/php-parallel-lint": "1.4.0",
|
"php-parallel-lint/php-parallel-lint": "1.4.0",
|
||||||
"phpstan/extension-installer": "1.4.3",
|
"phpstan/extension-installer": "1.4.3",
|
||||||
"phpstan/phpstan": "1.12.33 || 2.2.2",
|
"phpstan/phpstan": "1.12.33 || 2.2.9",
|
||||||
"phpstan/phpstan-phpunit": "1.4.2 || 2.0.16",
|
"phpstan/phpstan-phpunit": "1.4.2 || 2.0.18",
|
||||||
"phpstan/phpstan-strict-rules": "1.6.2 || 2.0.11",
|
"phpstan/phpstan-strict-rules": "1.6.2 || 2.0.12",
|
||||||
"phpunit/phpunit": "8.5.52",
|
"phpunit/phpunit": "8.5.54",
|
||||||
"rawr/phpunit-data-provider": "3.3.1",
|
"rawr/phpunit-data-provider": "3.3.1",
|
||||||
"rector/rector": "1.2.10 || 2.4.6",
|
"rector/rector": "1.2.10 || 2.6.2",
|
||||||
"rector/type-perfect": "1.0.0 || 2.1.3",
|
"rector/type-perfect": "1.0.0 || 2.1.4",
|
||||||
"squizlabs/php_codesniffer": "4.0.1",
|
"squizlabs/php_codesniffer": "4.0.4"
|
||||||
"thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.3"
|
|
||||||
},
|
},
|
||||||
"suggest": {
|
"suggest": {
|
||||||
"ext-mbstring": "for parsing UTF-8 CSS"
|
"ext-mbstring": "for parsing UTF-8 CSS"
|
||||||
@@ -4731,7 +4729,7 @@
|
|||||||
"type": "library",
|
"type": "library",
|
||||||
"extra": {
|
"extra": {
|
||||||
"branch-alias": {
|
"branch-alias": {
|
||||||
"dev-main": "9.5.x-dev"
|
"dev-main": "9.6.x-dev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
@@ -4769,9 +4767,9 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
|
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
|
||||||
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.4.0"
|
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.5.0"
|
||||||
},
|
},
|
||||||
"time": "2026-06-18T15:10:53+00:00"
|
"time": "2026-09-20T15:02:00+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "spatie/crypto",
|
"name": "spatie/crypto",
|
||||||
@@ -11058,16 +11056,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/webpack-encore-bundle",
|
"name": "symfony/webpack-encore-bundle",
|
||||||
"version": "v2.4.1",
|
"version": "2.4.2",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/webpack-encore-bundle.git",
|
"url": "https://github.com/symfony/webpack-encore-bundle.git",
|
||||||
"reference": "cac8d6c722999c8add9272f9de6e8079628df4f5"
|
"reference": "0cbc3485f127cd9f85e395f8177607ef00535634"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/webpack-encore-bundle/zipball/cac8d6c722999c8add9272f9de6e8079628df4f5",
|
"url": "https://api.github.com/repos/symfony/webpack-encore-bundle/zipball/0cbc3485f127cd9f85e395f8177607ef00535634",
|
||||||
"reference": "cac8d6c722999c8add9272f9de6e8079628df4f5",
|
"reference": "0cbc3485f127cd9f85e395f8177607ef00535634",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -11110,7 +11108,7 @@
|
|||||||
"description": "Integration of your Symfony app with Webpack Encore",
|
"description": "Integration of your Symfony app with Webpack Encore",
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/symfony/webpack-encore-bundle/issues",
|
"issues": "https://github.com/symfony/webpack-encore-bundle/issues",
|
||||||
"source": "https://github.com/symfony/webpack-encore-bundle/tree/v2.4.1"
|
"source": "https://github.com/symfony/webpack-encore-bundle/tree/2.4.2"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -11130,7 +11128,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-06-24T07:21:58+00:00"
|
"time": "2026-09-17T12:45:02+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/yaml",
|
"name": "symfony/yaml",
|
||||||
@@ -11208,161 +11206,18 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-08-30T00:47:26+00:00"
|
"time": "2026-08-30T00:47:26+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "thecodingmachine/safe",
|
|
||||||
"version": "v3.4.0",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/thecodingmachine/safe.git",
|
|
||||||
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19",
|
|
||||||
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": "^8.1"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"php-parallel-lint/php-parallel-lint": "^1.4",
|
|
||||||
"phpstan/phpstan": "^2",
|
|
||||||
"phpunit/phpunit": "^10",
|
|
||||||
"squizlabs/php_codesniffer": "^3.2"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"autoload": {
|
|
||||||
"files": [
|
|
||||||
"lib/special_cases.php",
|
|
||||||
"generated/apache.php",
|
|
||||||
"generated/apcu.php",
|
|
||||||
"generated/array.php",
|
|
||||||
"generated/bzip2.php",
|
|
||||||
"generated/calendar.php",
|
|
||||||
"generated/classobj.php",
|
|
||||||
"generated/com.php",
|
|
||||||
"generated/cubrid.php",
|
|
||||||
"generated/curl.php",
|
|
||||||
"generated/datetime.php",
|
|
||||||
"generated/dir.php",
|
|
||||||
"generated/eio.php",
|
|
||||||
"generated/errorfunc.php",
|
|
||||||
"generated/exec.php",
|
|
||||||
"generated/fileinfo.php",
|
|
||||||
"generated/filesystem.php",
|
|
||||||
"generated/filter.php",
|
|
||||||
"generated/fpm.php",
|
|
||||||
"generated/ftp.php",
|
|
||||||
"generated/funchand.php",
|
|
||||||
"generated/gettext.php",
|
|
||||||
"generated/gmp.php",
|
|
||||||
"generated/gnupg.php",
|
|
||||||
"generated/hash.php",
|
|
||||||
"generated/ibase.php",
|
|
||||||
"generated/ibmDb2.php",
|
|
||||||
"generated/iconv.php",
|
|
||||||
"generated/image.php",
|
|
||||||
"generated/imap.php",
|
|
||||||
"generated/info.php",
|
|
||||||
"generated/inotify.php",
|
|
||||||
"generated/json.php",
|
|
||||||
"generated/ldap.php",
|
|
||||||
"generated/libxml.php",
|
|
||||||
"generated/lzf.php",
|
|
||||||
"generated/mailparse.php",
|
|
||||||
"generated/mbstring.php",
|
|
||||||
"generated/misc.php",
|
|
||||||
"generated/mysql.php",
|
|
||||||
"generated/mysqli.php",
|
|
||||||
"generated/network.php",
|
|
||||||
"generated/oci8.php",
|
|
||||||
"generated/opcache.php",
|
|
||||||
"generated/openssl.php",
|
|
||||||
"generated/outcontrol.php",
|
|
||||||
"generated/pcntl.php",
|
|
||||||
"generated/pcre.php",
|
|
||||||
"generated/pgsql.php",
|
|
||||||
"generated/posix.php",
|
|
||||||
"generated/ps.php",
|
|
||||||
"generated/pspell.php",
|
|
||||||
"generated/readline.php",
|
|
||||||
"generated/rnp.php",
|
|
||||||
"generated/rpminfo.php",
|
|
||||||
"generated/rrd.php",
|
|
||||||
"generated/sem.php",
|
|
||||||
"generated/session.php",
|
|
||||||
"generated/shmop.php",
|
|
||||||
"generated/sockets.php",
|
|
||||||
"generated/sodium.php",
|
|
||||||
"generated/solr.php",
|
|
||||||
"generated/spl.php",
|
|
||||||
"generated/sqlsrv.php",
|
|
||||||
"generated/ssdeep.php",
|
|
||||||
"generated/ssh2.php",
|
|
||||||
"generated/stream.php",
|
|
||||||
"generated/strings.php",
|
|
||||||
"generated/swoole.php",
|
|
||||||
"generated/uodbc.php",
|
|
||||||
"generated/uopz.php",
|
|
||||||
"generated/url.php",
|
|
||||||
"generated/var.php",
|
|
||||||
"generated/xdiff.php",
|
|
||||||
"generated/xml.php",
|
|
||||||
"generated/xmlrpc.php",
|
|
||||||
"generated/yaml.php",
|
|
||||||
"generated/yaz.php",
|
|
||||||
"generated/zip.php",
|
|
||||||
"generated/zlib.php"
|
|
||||||
],
|
|
||||||
"classmap": [
|
|
||||||
"lib/DateTime.php",
|
|
||||||
"lib/DateTimeImmutable.php",
|
|
||||||
"lib/Exceptions/",
|
|
||||||
"generated/Exceptions/"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"description": "PHP core functions that throw exceptions instead of returning FALSE on error",
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/thecodingmachine/safe/issues",
|
|
||||||
"source": "https://github.com/thecodingmachine/safe/tree/v3.4.0"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://github.com/OskarStark",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/shish",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/silasjoisten",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/staabm",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2026-02-04T18:08:13+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "twig/extra-bundle",
|
"name": "twig/extra-bundle",
|
||||||
"version": "v3.24.0",
|
"version": "v3.29.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/twigphp/twig-extra-bundle.git",
|
"url": "https://github.com/twigphp/twig-extra-bundle.git",
|
||||||
"reference": "6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9"
|
"reference": "aaa2993e19293a99240c4c61aa461d743b0dd569"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9",
|
"url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/aaa2993e19293a99240c4c61aa461d743b0dd569",
|
||||||
"reference": "6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9",
|
"reference": "aaa2993e19293a99240c4c61aa461d743b0dd569",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -11411,7 +11266,7 @@
|
|||||||
"twig"
|
"twig"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/twigphp/twig-extra-bundle/tree/v3.24.0"
|
"source": "https://github.com/twigphp/twig-extra-bundle/tree/v3.29.0"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -11423,20 +11278,20 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-02-07T08:07:38+00:00"
|
"time": "2026-09-11T08:59:50+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "twig/html-extra",
|
"name": "twig/html-extra",
|
||||||
"version": "v3.28.0",
|
"version": "v3.29.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/twigphp/html-extra.git",
|
"url": "https://github.com/twigphp/html-extra.git",
|
||||||
"reference": "760893ed7bdd0a381e4e00004c6f6e26ad3881d7"
|
"reference": "7147611979df81edb78baf7d003860b72e335c3f"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/twigphp/html-extra/zipball/760893ed7bdd0a381e4e00004c6f6e26ad3881d7",
|
"url": "https://api.github.com/repos/twigphp/html-extra/zipball/7147611979df81edb78baf7d003860b72e335c3f",
|
||||||
"reference": "760893ed7bdd0a381e4e00004c6f6e26ad3881d7",
|
"reference": "7147611979df81edb78baf7d003860b72e335c3f",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -11479,7 +11334,7 @@
|
|||||||
"twig"
|
"twig"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/twigphp/html-extra/tree/v3.28.0"
|
"source": "https://github.com/twigphp/html-extra/tree/v3.29.0"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -11491,20 +11346,20 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-06-25T06:50:01+00:00"
|
"time": "2026-09-06T20:35:36+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "twig/intl-extra",
|
"name": "twig/intl-extra",
|
||||||
"version": "v3.26.0",
|
"version": "v3.29.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/twigphp/intl-extra.git",
|
"url": "https://github.com/twigphp/intl-extra.git",
|
||||||
"reference": "98f5ad5bff13230fcd2d834d9e79b50adf3ccda9"
|
"reference": "51c52470aca59f3a9715c88f6fe505133b88c752"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/twigphp/intl-extra/zipball/98f5ad5bff13230fcd2d834d9e79b50adf3ccda9",
|
"url": "https://api.github.com/repos/twigphp/intl-extra/zipball/51c52470aca59f3a9715c88f6fe505133b88c752",
|
||||||
"reference": "98f5ad5bff13230fcd2d834d9e79b50adf3ccda9",
|
"reference": "51c52470aca59f3a9715c88f6fe505133b88c752",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -11543,7 +11398,7 @@
|
|||||||
"twig"
|
"twig"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/twigphp/intl-extra/tree/v3.26.0"
|
"source": "https://github.com/twigphp/intl-extra/tree/v3.29.0"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -11555,7 +11410,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-05-19T20:44:48+00:00"
|
"time": "2026-09-06T20:35:36+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "twig/string-extra",
|
"name": "twig/string-extra",
|
||||||
@@ -11626,16 +11481,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "twig/twig",
|
"name": "twig/twig",
|
||||||
"version": "v3.28.0",
|
"version": "v3.29.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/twigphp/Twig.git",
|
"url": "https://github.com/twigphp/Twig.git",
|
||||||
"reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b"
|
"reference": "45a3c6e9224c3377a39c7b150bb29d5d97d2c75d"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/twigphp/Twig/zipball/597c12ed286fb9d1701a36684ce6e0cbe28ebc8b",
|
"url": "https://api.github.com/repos/twigphp/Twig/zipball/45a3c6e9224c3377a39c7b150bb29d5d97d2c75d",
|
||||||
"reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b",
|
"reference": "45a3c6e9224c3377a39c7b150bb29d5d97d2c75d",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -11690,7 +11545,7 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/twigphp/Twig/issues",
|
"issues": "https://github.com/twigphp/Twig/issues",
|
||||||
"source": "https://github.com/twigphp/Twig/tree/v3.28.0"
|
"source": "https://github.com/twigphp/Twig/tree/v3.29.0"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -11702,7 +11557,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-07-03T20:44:34+00:00"
|
"time": "2026-09-18T09:10:14+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "webmozart/assert",
|
"name": "webmozart/assert",
|
||||||
@@ -12295,16 +12150,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "friendsofphp/php-cs-fixer",
|
"name": "friendsofphp/php-cs-fixer",
|
||||||
"version": "v3.95.25",
|
"version": "v3.95.27",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
|
"url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
|
||||||
"reference": "2cdfc1f3daf173d83a1ebb6177949b58c95de6ff"
|
"reference": "1d0c68ee5f37446d8ad5970b82384cd1bb0a3317"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/2cdfc1f3daf173d83a1ebb6177949b58c95de6ff",
|
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/1d0c68ee5f37446d8ad5970b82384cd1bb0a3317",
|
||||||
"reference": "2cdfc1f3daf173d83a1ebb6177949b58c95de6ff",
|
"reference": "1d0c68ee5f37446d8ad5970b82384cd1bb0a3317",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -12338,15 +12193,15 @@
|
|||||||
"require-dev": {
|
"require-dev": {
|
||||||
"facile-it/paraunit": "^1.3.1 || ^2.11.0",
|
"facile-it/paraunit": "^1.3.1 || ^2.11.0",
|
||||||
"infection/infection": "^0.32.7",
|
"infection/infection": "^0.32.7",
|
||||||
"justinrainbow/json-schema": "^6.10.0",
|
"justinrainbow/json-schema": "^6.12.0",
|
||||||
"keradus/cli-executor": "^2.3",
|
"keradus/cli-executor": "^2.3",
|
||||||
"php-coveralls/php-coveralls": "^2.9.1",
|
"php-coveralls/php-coveralls": "^2.9.1",
|
||||||
"php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8",
|
"php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8",
|
||||||
"php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8",
|
"php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8",
|
||||||
"phpunit/phpunit": "^9.6.35 || ^10.5.64 || ^11.5.56 || ^12.5.31 || ^13.0.6",
|
"phpunit/phpunit": "^9.6.36 || ^10.5.64 || ^11.5.56 || ^12.5.35 || ^13.3.3",
|
||||||
"symfony/polyfill-php85": "^1.38",
|
"symfony/polyfill-php85": "^1.41",
|
||||||
"symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.1",
|
"symfony/var-dumper": "^5.4.48 || ^6.4.45 || ^7.4.18 || ^8.1.7",
|
||||||
"symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.1"
|
"symfony/yaml": "^5.4.53 || ^6.4.45 || ^7.4.18 || ^8.1.6"
|
||||||
},
|
},
|
||||||
"suggest": {
|
"suggest": {
|
||||||
"ext-dom": "For handling output formats in XML",
|
"ext-dom": "For handling output formats in XML",
|
||||||
@@ -12387,7 +12242,7 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues",
|
"issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues",
|
||||||
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.25"
|
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.27"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -12395,7 +12250,7 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-09-08T11:11:37+00:00"
|
"time": "2026-09-22T09:54:55+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "myclabs/deep-copy",
|
"name": "myclabs/deep-copy",
|
||||||
@@ -12634,11 +12489,11 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpstan/phpstan",
|
"name": "phpstan/phpstan",
|
||||||
"version": "2.2.14",
|
"version": "2.2.15",
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac",
|
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/b158556ffd26825cf615a1c1f72fb5157a2301b7",
|
||||||
"reference": "9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac",
|
"reference": "b158556ffd26825cf615a1c1f72fb5157a2301b7",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -12694,7 +12549,7 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-09-12T21:39:33+00:00"
|
"time": "2026-09-23T12:23:07+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpstan/phpstan-doctrine",
|
"name": "phpstan/phpstan-doctrine",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ league_oauth2_server:
|
|||||||
private_key_passphrase: null
|
private_key_passphrase: null
|
||||||
encryption_key: '%env(resolve:OAUTH_ENCRYPTION_KEY)%'
|
encryption_key: '%env(resolve:OAUTH_ENCRYPTION_KEY)%'
|
||||||
require_code_challenge_for_public_clients: true
|
require_code_challenge_for_public_clients: true
|
||||||
enable_client_credentials_grant: true
|
enable_client_credentials_grant: false
|
||||||
enable_auth_code_grant: true
|
enable_auth_code_grant: true
|
||||||
enable_refresh_token_grant: true
|
enable_refresh_token_grant: true
|
||||||
enable_password_grant: false
|
enable_password_grant: false
|
||||||
@@ -19,6 +19,9 @@ league_oauth2_server:
|
|||||||
doctrine: null
|
doctrine: null
|
||||||
client:
|
client:
|
||||||
allow_plaintext_secrets: false
|
allow_plaintext_secrets: false
|
||||||
|
# App\Entity\OAuth2Client adds the required_roles column that
|
||||||
|
# App\EventListener\AuthorizationCodeListener gates authorization on.
|
||||||
|
classname: App\Entity\OAuth2Client
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
league_oauth2_server:
|
league_oauth2_server:
|
||||||
|
|||||||
@@ -382,3 +382,8 @@ services:
|
|||||||
whitelisted_ips: '%env(csv:MAINTENANCE_MODE_IP_WHITELIST)%'
|
whitelisted_ips: '%env(csv:MAINTENANCE_MODE_IP_WHITELIST)%'
|
||||||
whitelisted_routes: '%env(csv:MAINTENANCE_MODE_ROUTE_WHITELIST)%'
|
whitelisted_routes: '%env(csv:MAINTENANCE_MODE_ROUTE_WHITELIST)%'
|
||||||
whitelisted_paths: '%env(csv:MAINTENANCE_MODE_PATH_WHITELIST)%'
|
whitelisted_paths: '%env(csv:MAINTENANCE_MODE_PATH_WHITELIST)%'
|
||||||
|
|
||||||
|
league.oauth2_server.grant.client_credentials:
|
||||||
|
class: League\OAuth2\Server\Grant\ClientCredentialsGrant
|
||||||
|
tags:
|
||||||
|
- { name: league.oauth2_server.authorization_server.grant, accessTokenTTL: 'PT1H' }
|
||||||
|
|||||||
+6
-8
@@ -77,33 +77,31 @@ $rsyncOptions = [
|
|||||||
'timeout' => 300,
|
'timeout' => 300,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Shared by all hosts. The deploy path only differs by the host alias.
|
// Shared by all hosts.
|
||||||
set('deploy_path', '/usr/home/myepsf/public_html/{{alias}}');
|
|
||||||
set('bin/php', '/usr/bin/php');
|
set('bin/php', '/usr/bin/php');
|
||||||
set('http_user', 'myepsf');
|
set('http_user', 'myepsf');
|
||||||
// Must stay here: contrib/rsync.php sets the same key, but its __DIR__ is the vendor dir.
|
// Must stay here: contrib/rsync.php sets the same key, but its __DIR__ is the vendor dir.
|
||||||
set('rsync_src', __DIR__);
|
set('rsync_src', __DIR__);
|
||||||
set('rsync', $rsyncOptions);
|
set('rsync', $rsyncOptions);
|
||||||
// The web adapter fetches its probe file back through the live docroot, so --web-path must
|
|
||||||
// be the current symlink, never {{release_or_current_path}}: that one resolves (and memoizes)
|
|
||||||
// to the new release dir, which the FPM worker's realpath cache does not see yet.
|
|
||||||
set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url={{web_url}}');
|
|
||||||
|
|
||||||
host('prod')
|
host('prod')
|
||||||
->setHostname('dedi10193.your-server.de')
|
->setHostname('dedi10193.your-server.de')
|
||||||
|
->setDeployPath('/usr/www/users/myepsf/prod')
|
||||||
->setRemoteUser('myepsf')
|
->setRemoteUser('myepsf')
|
||||||
->setForwardAgent(true)
|
->setForwardAgent(true)
|
||||||
->setSshMultiplexing(true)
|
->setSshMultiplexing(true)
|
||||||
->set('web_url', 'https://my.ep-reisen.de')
|
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.de')
|
||||||
;
|
;
|
||||||
|
|
||||||
host('staging')
|
host('staging')
|
||||||
->setHostname('dedi10193.your-server.de')
|
->setHostname('dedi10193.your-server.de')
|
||||||
|
->setDeployPath('/usr/www/users/myepsf/staging')
|
||||||
->setRemoteUser('myepsf')
|
->setRemoteUser('myepsf')
|
||||||
->setForwardAgent(true)
|
->setForwardAgent(true)
|
||||||
->setSshMultiplexing(true)
|
->setSshMultiplexing(true)
|
||||||
->set('web_url', 'https://my.ep-reisen.net')
|
->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', [
|
->add('shared_files', [
|
||||||
|
'public/.htaccess',
|
||||||
'public/.htpasswd',
|
'public/.htpasswd',
|
||||||
])
|
])
|
||||||
;
|
;
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ A Symfony application exposing a read-mostly JSON API under the `/api` prefix. A
|
|||||||
|
|
||||||
- Token endpoint: `POST /token` (unauthenticated).
|
- Token endpoint: `POST /token` (unauthenticated).
|
||||||
- Enabled grants: **client_credentials**, **authorization_code** (PKCE required for public clients), **refresh_token**. Password and implicit grants are disabled.
|
- Enabled grants: **client_credentials**, **authorization_code** (PKCE required for public clients), **refresh_token**. Password and implicit grants are disabled.
|
||||||
- Access token TTL: **10 minutes** — clients must refresh/re-request frequently.
|
- Access token TTL depends on the grant: **1 hour** for client_credentials, **10 minutes** for authorization_code.
|
||||||
|
Do not hardcode either value — read `expires_in` from the token response, it is authoritative and may be tuned.
|
||||||
- Available scopes: `email`, `id`, `profile`, `roles`, `api`. Default scope if none requested: `email`.
|
- Available scopes: `email`, `id`, `profile`, `roles`, `api`. Default scope if none requested: `email`.
|
||||||
- Each granted scope maps to a role `ROLE_OAUTH2_<SCOPE_UPPERCASE>` (e.g. scope `api` → `ROLE_OAUTH2_API`).
|
- Each granted scope maps to a role `ROLE_OAUTH2_<SCOPE_UPPERCASE>` (e.g. scope `api` → `ROLE_OAUTH2_API`).
|
||||||
- Send the token as `Authorization: Bearer <access_token>`.
|
- Send the token as `Authorization: Bearer <access_token>`.
|
||||||
@@ -379,9 +380,9 @@ Responses:
|
|||||||
|
|
||||||
OIDC-style claims for the authenticated user. The response contains only the claims covered by the granted scopes:
|
OIDC-style claims for the authenticated user. The response contains only the claims covered by the granted scopes:
|
||||||
|
|
||||||
- always: `email`
|
- always: `sub` (UUID string, see below), `email`
|
||||||
- scope `id`: `person_id`, `address_id`
|
- scope `id`: `person_id`, `address_id`
|
||||||
- scope `roles`: `roles` (array; only the roles that actually grant something are exported — the implicit baseline role is stripped, and so are the `*_PENDING` markers of roles the BusPro CRM claims but nobody has approved yet, see `docs/user-roles.md`)
|
- scope `roles`: `roles` (array; only the roles that actually grant something are exported — the implicit baseline role is stripped, and so are the `*_PENDING` markers of roles the BusPro CRM claims but nobody has approved yet, see `docs/buspronet-schema/crm-selection-queries.md#from-claim-to-role`)
|
||||||
- scope `profile`: `profile` object:
|
- scope `profile`: `profile` object:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -394,7 +395,30 @@ OIDC-style claims for the authenticated user. The response contains only the cla
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: `profile.communication.email` comes from the CRM address record and is **not necessarily** the login email — a single address can hold several contacts. Use the top-level `email` claim for identity.
|
##### Identifying the account: match on `sub`
|
||||||
|
|
||||||
|
A BusPro address record holds several email addresses (private and business, say), and BusPro
|
||||||
|
accepts **any** of them as a login. Each one is a **separate MyE&P account with its own roles** —
|
||||||
|
that is intended, not a duplicate: administrative roles are reserved for the company address, so
|
||||||
|
the same person signing in privately is a Teamer:in or Kund:in and signing in with their
|
||||||
|
`@ep-reisen.de` address is an administrator.
|
||||||
|
|
||||||
|
All of those accounts report the **same** `person_id` and `address_id`, and the same
|
||||||
|
`profile.communication.email` — that value is the first contact address on the BusPro record and
|
||||||
|
is **not necessarily** the one signed in with. So:
|
||||||
|
|
||||||
|
- **`sub`** — a **UUID (v4, RFC 4122, 36 characters)**, stable for the life of the account and
|
||||||
|
unique across accounts. The only claim that identifies an account. Match your local user on it
|
||||||
|
and store it. Size the column at 36 characters and treat the value as opaque: it is random, it
|
||||||
|
carries no timestamp and no ordering, and nothing about it should be parsed or derived from.
|
||||||
|
- **`email`** — the address this session actually authenticated with. Distinct per account, but
|
||||||
|
treat it as a display and contact value; it is not the account key.
|
||||||
|
- **`person_id` / `address_id`** — the BusPro person behind the account. Shared between that
|
||||||
|
person's accounts, so they identify a *human*, never an account. Do not match on them.
|
||||||
|
|
||||||
|
Do not derive staff status from an email domain. MyE&P decides who is a member of staff and
|
||||||
|
exports the result as `ROLE_EMPLOYEE` in the `roles` claim; re-deriving it from `email` or from
|
||||||
|
`profile.communication.email` will disagree with MyE&P in both directions.
|
||||||
|
|
||||||
`400 {"message": "...", "code": "..."}` when the upstream lookup fails or rejects the credentials.
|
`400 {"message": "...", "code": "..."}` when the upstream lookup fails or rejects the credentials.
|
||||||
|
|
||||||
@@ -406,7 +430,7 @@ CRM selection attributes/segments for the authenticated user. `400 {"message": "
|
|||||||
|
|
||||||
## Client implementation notes
|
## Client implementation notes
|
||||||
|
|
||||||
1. **Token lifetime is 10 minutes** — implement refresh/re-fetch with a safety margin and retry once on 401.
|
1. **Token lifetime comes from `expires_in`** (currently 1 hour for client_credentials, 10 minutes for authorization_code) — cache the token for that long, refresh with a safety margin, and retry once on 401.
|
||||||
2. **Check for an `error` key even on 200 responses** for `/api/products` and the travel availability endpoint.
|
2. **Check for an `error` key even on 200 responses** for `/api/products` and the travel availability endpoint.
|
||||||
3. **Money units are inconsistent across endpoints**: contingent endpoints return decimal major units; accommodation-booking fields return integer cents. Normalize at the client boundary.
|
3. **Money units are inconsistent across endpoints**: contingent endpoints return decimal major units; accommodation-booking fields return integer cents. Normalize at the client boundary.
|
||||||
4. **Dates**: `Y-m-d` everywhere except `acceptedAt` (ISO-8601 datetime) and pickup planning (`Y-m-d H:i`). Contingent `dateFrom`/`dateTo` are inclusive night boundaries.
|
4. **Dates**: `Y-m-d` everywhere except `acceptedAt` (ISO-8601 datetime) and pickup planning (`Y-m-d H:i`). Contingent `dateFrom`/`dateTo` are inclusive night boundaries.
|
||||||
|
|||||||
@@ -577,6 +577,10 @@ Known role mapping from the XML parser:
|
|||||||
- admin users also receive default hotel code `SSL`
|
- admin users also receive default hotel code `SSL`
|
||||||
- no matched role means `ROLE_CUSTOMER`
|
- no matched role means `ROLE_CUSTOMER`
|
||||||
|
|
||||||
|
These are the CRM's claims. The portal filters them before storing anything (staff-only
|
||||||
|
roles, approval markers), so a proxy must return the claims unfiltered — see
|
||||||
|
`docs/buspronet-schema/crm-selection-queries.md#from-claim-to-role`.
|
||||||
|
|
||||||
Candidate SQL for selected customer selections:
|
Candidate SQL for selected customer selections:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
|
|||||||
@@ -166,6 +166,18 @@ genuinely missing, which is a useful way to audit an address: the placeholder ac
|
|||||||
fails on `Telefon-Mobil` and `Nationalität` because its `Vorgang_Details` record has only
|
fails on `Telefon-Mobil` and `Nationalität` because its `Vorgang_Details` record has only
|
||||||
`telefonprivat` and no `nationalitaet`.
|
`telefonprivat` and no `nationalitaet`.
|
||||||
|
|
||||||
|
**Participants are held to less than the Anmelder** (measured 2026-09-16 on 2036, travel 12500, from a
|
||||||
|
real step-3 `Anfrage`). Once a room is present, every `teilnehmer` needs a `Mail-Adresse`, children
|
||||||
|
included: without `kommunikation` the answer is `650 … 1.Teilnehmer-Mail-Adresse, 2.Teilnehmer-Mail-Adresse`.
|
||||||
|
`telefonmobil` and an empty `anschrift` pass for participants. An email identical to the Anmelder's is
|
||||||
|
accepted. The field list above (Strasse, Plz, Ort, Telefon-Mobil for every participant) was measured on
|
||||||
|
the placeholder records and is stricter than what BusPro applies to ordinary participants.
|
||||||
|
|
||||||
|
**The Anmelder does not have to travel.** An `<anmelder>` that matches no `teilnehmer` (a parent booking
|
||||||
|
for their children) returns `möglich` with the same price as the control where the Anmelder is
|
||||||
|
`teilnehmer 1`. Whether the committed booking binds to the Anmelder's existing customer record has not
|
||||||
|
been measured yet.
|
||||||
|
|
||||||
Note `650` again: both of those are hard rejections delivered under the code
|
Note `650` again: both of those are hard rejections delivered under the code
|
||||||
`Notification::isError()` treats as success.
|
`Notification::isError()` treats as success.
|
||||||
|
|
||||||
@@ -196,7 +208,7 @@ the same way. Ask `MOEGLICHEAENDERUNGEN` first (`<idreise>`), which answers:
|
|||||||
| `buchungsart` | yes | yes | Edit **hardcodes `Buchung`** (`ApiClient.php:233`) |
|
| `buchungsart` | yes | yes | Edit **hardcodes `Buchung`** (`ApiClient.php:233`) |
|
||||||
| `status` | yes | yes | Edit replays the value read back from BusPro — see the caveat below |
|
| `status` | yes | yes | Edit replays the value read back from BusPro — see the caveat below |
|
||||||
| `idreise`, `idpartner`, `idagentur` | yes | yes | `idpartner` sent unconditionally, even with no room |
|
| `idreise`, `idpartner`, `idagentur` | yes | yes | `idpartner` sent unconditionally, even with no room |
|
||||||
| `anmelder` | yes | yes | Create builds it from participant[0]; edit uses `PersonalData::toPayload()` |
|
| `anmelder` | yes | yes | Create builds it from participant[0]; edit uses `PersonalData::toPayload()`. BusPro accepts an Anmelder who is not a participant |
|
||||||
| `anmelder/bemerkung` | — | yes | Via `PersonalData::toPayload()` |
|
| `anmelder/bemerkung` | — | yes | Via `PersonalData::toPayload()` |
|
||||||
| `teilnehmerliste` | yes | yes | |
|
| `teilnehmerliste` | yes | yes | |
|
||||||
| `wünsche` (`unterbringungswunsch`, `beförderungswunsch`) | yes | yes | |
|
| `wünsche` (`unterbringungswunsch`, `beförderungswunsch`) | yes | yes | |
|
||||||
|
|||||||
@@ -18,17 +18,47 @@ Symfony roles:
|
|||||||
| `IDSelektionsstamm` | Role |
|
| `IDSelektionsstamm` | Role |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `1070` | `ROLE_TEAMER` |
|
| `1070` | `ROLE_TEAMER` |
|
||||||
| `1292` | `ROLE_ADMIN` (+ `ROLE_HOUSE_MANAGER`, + hotel code `SSL`) |
|
| `1292` | `ROLE_ADMIN` |
|
||||||
| `1293` | `ROLE_MANAGER` |
|
| `1293` | `ROLE_MANAGER` |
|
||||||
| `1477` | `ROLE_GROUPS_MANAGER` |
|
| `1477` | `ROLE_GROUPS_MANAGER` |
|
||||||
| `1478` | `ROLE_GROUPS_ADMIN` |
|
| `1478` | `ROLE_GROUPS_ADMIN` |
|
||||||
| `1483` | `ROLE_CUSTOMER_EXPERT` |
|
| `1483` | `ROLE_CUSTOMER_EXPERT` |
|
||||||
|
| `1484` | `ROLE_TEAM_ADMIN` (not consumed here — exported through `/api/userinfo` for myep-team) |
|
||||||
| label `Hausleitung {CODE}` | `ROLE_HOUSE_MANAGER` + hotel code `{CODE}` |
|
| label `Hausleitung {CODE}` | `ROLE_HOUSE_MANAGER` + hotel code `{CODE}` |
|
||||||
| nothing matched | `ROLE_CUSTOMER` |
|
| nothing matched | `ROLE_CUSTOMER` |
|
||||||
|
|
||||||
Only selections with `auswahl="True"` count. Every `Admin/*` controller is gated on
|
Only selections with `auswahl="True"` count. Every `Admin/*` controller is gated on
|
||||||
one of these roles.
|
one of these roles.
|
||||||
|
|
||||||
|
These are *claims*, not grants. What an account ends up holding is decided by
|
||||||
|
`Role::sync()` (`src/Security/Role.php`) on every login — see
|
||||||
|
[From claim to role](#from-claim-to-role).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## From claim to role
|
||||||
|
|
||||||
|
BusPro backend users can edit their own CRM selections, so the claims above are
|
||||||
|
filtered before anything is stored:
|
||||||
|
|
||||||
|
| Role | Claimed without a staff address | Claimed with a staff address |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ROLE_TEAMER` | granted | granted |
|
||||||
|
| `ROLE_HOUSE_MANAGER` | nominated | nominated |
|
||||||
|
| `ROLE_ADMIN`, `ROLE_TEAM_ADMIN`, `ROLE_MANAGER`, `ROLE_GROUPS_ADMIN`, `ROLE_GROUPS_MANAGER`, `ROLE_CUSTOMER_EXPERT` (`Role::EMPLOYEE_ONLY`) | **ignored** | nominated |
|
||||||
|
|
||||||
|
- **Staff address**: the login email's domain is listed in `employee_email_domains`
|
||||||
|
(`config/services.yaml`, exact match, never a suffix). Such an account also gets
|
||||||
|
`ROLE_EMPLOYEE`, which is derived from the address and not from the CRM.
|
||||||
|
- **Nominated**: stored as the marker `ROLE_X_PENDING`, which grants nothing until an
|
||||||
|
administrator approves it in `/admin/user`. Nominations are announced to
|
||||||
|
`role_nomination_notification_emails`.
|
||||||
|
- **Ignored**: treated exactly like a selection that is not set. No marker is stored,
|
||||||
|
and a role approved earlier is **revoked** on the next login. A leftover marker from
|
||||||
|
before the rule cannot be approved and disappears on that login too.
|
||||||
|
- Anything the CRM stops claiming is revoked, approved or not.
|
||||||
|
- An account left without any effective role gets `ROLE_CUSTOMER`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## The four tables
|
## The four tables
|
||||||
@@ -398,11 +428,11 @@ BusProNet host is the fix, and no schema change is needed.
|
|||||||
|
|
||||||
## Portal side: a correct XML is not enough
|
## Portal side: a correct XML is not enough
|
||||||
|
|
||||||
`BpnAuthenticator::createOrUpdateLocalUser()` snapshots the roles onto the local `User`
|
`BpnAuthenticator::syncFromCrm()` writes the roles onto the local `User` entity at login,
|
||||||
entity at login (`setRoles($crmAttributes->roles)` /
|
through `Role::sync()` (see [From claim to role](#from-claim-to-role)), and the hotel codes
|
||||||
`setHotelCodes($crmAttributes->hotelCodes)`). Roles are **not** re-read per request. So
|
verbatim. Roles are **not** re-read per request. So after fixing BusPro the affected user
|
||||||
after fixing BusPro the affected user must **log out and log in again** before
|
must **log out and log in again**, and even then `ROLE_GROUPS_ADMIN` only takes effect once
|
||||||
`ROLE_GROUPS_ADMIN` takes effect — an active session keeps the stale role set.
|
the account has a staff address and an administrator has approved the nomination.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the per-client role requirement that App\EventListener\AuthorizationCodeListener
|
||||||
|
* gates the authorization code grant on.
|
||||||
|
*
|
||||||
|
* The column defaults to an empty list and an empty list denies, so every client is
|
||||||
|
* refused until its roles are set. The two clients in actual use are therefore set here
|
||||||
|
* rather than by hand after the deploy: it closes the window in which both staff tools
|
||||||
|
* would be live but unable to authorize anyone, and it keeps the role strings somewhere
|
||||||
|
* review can see them.
|
||||||
|
*
|
||||||
|
* Matched on name, not identifier: client identifiers are generated per environment and
|
||||||
|
* differ between local, staging and production, while the names do not.
|
||||||
|
*
|
||||||
|
* Deliberately left empty, and so denied: myepapp (the mobile app) and myep (the Keycloak
|
||||||
|
* broker), both R&D only and not in use. The client_credentials clients never reach the
|
||||||
|
* authorization endpoint and are unaffected either way.
|
||||||
|
*/
|
||||||
|
final class Version20260923120000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Adds oauth2_client.required_roles and sets it for the two staff clients.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Added nullable and backfilled before being tightened: a NOT NULL column with no
|
||||||
|
// default leaves existing rows holding an empty string, which is not valid JSON and
|
||||||
|
// fails to hydrate -- the row cannot even reach the listener that would deny it.
|
||||||
|
$this->addSql("ALTER TABLE oauth2_client ADD required_roles JSON DEFAULT NULL COMMENT '(DC2Type:json)'");
|
||||||
|
$this->addSql("UPDATE oauth2_client SET required_roles = '[]'");
|
||||||
|
$this->addSql("ALTER TABLE oauth2_client MODIFY required_roles JSON NOT NULL COMMENT '(DC2Type:json)'");
|
||||||
|
|
||||||
|
// mirrors ephub's App\Security\Role::ELIGIBLE
|
||||||
|
$this->addSql('UPDATE oauth2_client SET required_roles = :roles WHERE name = :name', [
|
||||||
|
'roles' => json_encode(['ROLE_EMPLOYEE', 'ROLE_ADMIN'], JSON_THROW_ON_ERROR),
|
||||||
|
'name' => 'EPHub',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// mirrors myep-team's App\Security\MyEpAuthenticator::ELIGIBLE_ROLES
|
||||||
|
$this->addSql('UPDATE oauth2_client SET required_roles = :roles WHERE name = :name', [
|
||||||
|
'roles' => json_encode(['ROLE_TEAM_ADMIN', 'ROLE_TEAMER', 'ROLE_MANAGER', 'ROLE_HOUSE_MANAGER'], JSON_THROW_ON_ERROR),
|
||||||
|
'name' => 'MyEPTeam',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE oauth2_client DROP required_roles');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gives every account a uuid, which replaces the primary key as the OAuth2 `sub` claim.
|
||||||
|
*
|
||||||
|
* The UUIDs are generated here in PHP rather than with MariaDB's UUID(): that function
|
||||||
|
* returns v1, which encodes a timestamp and the server's node id, so a whole backfill
|
||||||
|
* would come out time-ordered and share a suffix. Guessing one account's identifier from
|
||||||
|
* another's is the property this column exists to remove, so the backfill has to be as
|
||||||
|
* random as the ones App\Entity\User generates from now on.
|
||||||
|
*
|
||||||
|
* One statement per row, which is why it is emitted rather than written out: the values
|
||||||
|
* cannot exist before the migration runs. Added nullable and tightened afterwards, since
|
||||||
|
* there is no single default that could satisfy a unique column.
|
||||||
|
*/
|
||||||
|
final class Version20260923150000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Adds user.uuid and backfills it; the OAuth2 sub claim moves off the primary key.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE user ADD uuid VARCHAR(36) DEFAULT NULL');
|
||||||
|
|
||||||
|
foreach ($this->connection->fetchFirstColumn('SELECT id FROM user') as $id) {
|
||||||
|
$this->addSql('UPDATE user SET uuid = :uuid WHERE id = :id', [
|
||||||
|
'uuid' => Uuid::v4()->toRfc4122(),
|
||||||
|
'id' => $id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addSql('ALTER TABLE user MODIFY uuid VARCHAR(36) NOT NULL');
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D649D17F50A6 ON user (uuid)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP INDEX UNIQ_8D93D649D17F50A6 ON user');
|
||||||
|
$this->addSql('ALTER TABLE user DROP uuid');
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
-24
@@ -602,10 +602,12 @@ class ApiClient
|
|||||||
'port' => $this->selectedPort,
|
'port' => $this->selectedPort,
|
||||||
]);
|
]);
|
||||||
$this->send($socket, $body);
|
$this->send($socket, $body);
|
||||||
$response = $this->receive($socket);
|
|
||||||
$this->disconnect($socket);
|
|
||||||
|
|
||||||
$responseXml = substr($response, 10);
|
try {
|
||||||
|
$responseXml = $this->receive($socket);
|
||||||
|
} finally {
|
||||||
|
$this->disconnect($socket);
|
||||||
|
}
|
||||||
|
|
||||||
if (true === $debug || true === $this->config['debug']) {
|
if (true === $debug || true === $this->config['debug']) {
|
||||||
$this->dumpXmlToFile('response', $requestId, $responseXml);
|
$this->dumpXmlToFile('response', $requestId, $responseXml);
|
||||||
@@ -714,18 +716,12 @@ class ApiClient
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->send($socket, $body);
|
$this->send($socket, $body);
|
||||||
$response = $this->receive($socket);
|
|
||||||
$this->disconnect($socket);
|
|
||||||
|
|
||||||
// message length (10 bytes) is prepended to actual message
|
try {
|
||||||
$xml = substr($response, 10);
|
// the length header is consumed and verified by receive(), this is the payload
|
||||||
|
$xml = $this->receive($socket);
|
||||||
if ('' === $xml) {
|
} finally {
|
||||||
$this->logger->error('Empty response body received from API (header-only response)', [
|
$this->disconnect($socket);
|
||||||
'request_id' => $requestId,
|
|
||||||
'raw_length' => strlen($response),
|
|
||||||
]);
|
|
||||||
throw new ApiClientException('Empty response body received from API');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (true === $debug || true === $this->config['debug']) {
|
if (true === $debug || true === $this->config['debug']) {
|
||||||
@@ -735,14 +731,19 @@ class ApiClient
|
|||||||
try {
|
try {
|
||||||
return $this->responseParser->parseXmlString($type, $xml, $additionalArgs);
|
return $this->responseParser->parseXmlString($type, $xml, $additionalArgs);
|
||||||
} catch (ResponseParserException $e) {
|
} catch (ResponseParserException $e) {
|
||||||
|
// Keep the evidence even outside debug mode: without the raw response a parse
|
||||||
|
// failure is not diagnosable after the fact. app:cleanup:xml-dumps prunes it.
|
||||||
$this->dumpXmlToFile('response', $requestId, $xml);
|
$this->dumpXmlToFile('response', $requestId, $xml);
|
||||||
|
|
||||||
|
$this->logger->error('Unable to parse response received from API', [
|
||||||
|
'request_id' => $requestId,
|
||||||
|
'type' => $type,
|
||||||
|
'response_length' => strlen($xml),
|
||||||
|
'error_message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw new ApiClientException('Unable to parse response received from API: '.$e->getMessage(), 0, $e);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->logger->error('Unexpected response received from API', [
|
|
||||||
'request_id' => $requestId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
throw new ApiClientException('Unexpected response received from API');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -773,10 +774,12 @@ class ApiClient
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->send($socket, $body);
|
$this->send($socket, $body);
|
||||||
$response = $this->receive($socket);
|
|
||||||
$this->disconnect($socket);
|
|
||||||
|
|
||||||
$xml = substr($response, 10);
|
try {
|
||||||
|
$xml = $this->receive($socket);
|
||||||
|
} finally {
|
||||||
|
$this->disconnect($socket);
|
||||||
|
}
|
||||||
|
|
||||||
if (true === $this->config['debug']) {
|
if (true === $this->config['debug']) {
|
||||||
$this->dumpXmlToFile('response', $requestId, $xml);
|
$this->dumpXmlToFile('response', $requestId, $xml);
|
||||||
@@ -898,8 +901,14 @@ class ApiClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Reads a single response message and returns its payload. The protocol prepends the
|
||||||
|
* payload length as a 10 byte header, so the announced length is compared against what
|
||||||
|
* actually arrived: a peer closing mid-stream would otherwise yield a silently
|
||||||
|
* truncated body that only surfaces later as an unexplained parse failure.
|
||||||
|
*
|
||||||
* @param resource $socket
|
* @param resource $socket
|
||||||
*
|
*
|
||||||
|
* @throws ApiClientException
|
||||||
* @throws TimeoutException
|
* @throws TimeoutException
|
||||||
* @throws ImmediateConnectionCloseException
|
* @throws ImmediateConnectionCloseException
|
||||||
*/
|
*/
|
||||||
@@ -963,7 +972,47 @@ class ApiClient
|
|||||||
throw new ImmediateConnectionCloseException('Server closed connection without sending data');
|
throw new ImmediateConnectionCloseException('Server closed connection without sending data');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $response;
|
return $this->extractPayload($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ApiClientException
|
||||||
|
*/
|
||||||
|
private function extractPayload(string $response): string
|
||||||
|
{
|
||||||
|
if (10 > strlen($response)) {
|
||||||
|
$this->logger->error('Incomplete response header received from API', [
|
||||||
|
'bytes_received' => strlen($response),
|
||||||
|
'port' => $this->selectedPort,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw new ApiClientException(sprintf('Incomplete response header, got %d of 10 bytes', strlen($response)));
|
||||||
|
}
|
||||||
|
|
||||||
|
$header = substr($response, 0, 10);
|
||||||
|
$announcedLength = (int) trim($header);
|
||||||
|
$payload = substr($response, 10);
|
||||||
|
|
||||||
|
if (1 > $announcedLength) {
|
||||||
|
$this->logger->error('Empty response body announced by API', [
|
||||||
|
'header' => trim($header),
|
||||||
|
'port' => $this->selectedPort,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw new ApiClientException(sprintf('Response announced an empty body (header "%s")', trim($header)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($payload) !== $announcedLength) {
|
||||||
|
$this->logger->error('Truncated response received from API', [
|
||||||
|
'bytes_received' => strlen($payload),
|
||||||
|
'announced_length' => $announcedLength,
|
||||||
|
'port' => $this->selectedPort,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw new ApiClientException(sprintf('Truncated response, got %d of %d announced bytes', strlen($payload), $announcedLength));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param resource $socket */
|
/** @param resource $socket */
|
||||||
|
|||||||
@@ -143,6 +143,31 @@ class Booking
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a participant already holds a service in this booking.
|
||||||
|
*
|
||||||
|
* This is the BusPro-side baseline, not the working edit selection: it answers
|
||||||
|
* "did the participant have this service when the booking was last read back".
|
||||||
|
* Services already held may be kept regardless of their current status or
|
||||||
|
* contingent - withdrawing one produces "Anzahl Leistung stimmt nicht mit
|
||||||
|
* Teilnehmerzuordnung überein" on the next update.
|
||||||
|
*
|
||||||
|
* @param int $participantIndex The participant index to search for
|
||||||
|
* @param int $serviceId The service ID to look for
|
||||||
|
*
|
||||||
|
* @return bool True if the participant already holds the service
|
||||||
|
*/
|
||||||
|
public function hasServiceForParticipant(int $participantIndex, int $serviceId): bool
|
||||||
|
{
|
||||||
|
foreach ([...$this->additionalServices, ...$this->transportationServices] as $service) {
|
||||||
|
if ($service->id === $serviceId && in_array($participantIndex, $service->mapping)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves transportation service for a specific participant and direction.
|
* Retrieves transportation service for a specific participant and direction.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -61,6 +61,13 @@ class PersonalData
|
|||||||
/** @var list<string> */
|
/** @var list<string> */
|
||||||
public array $hotelCodes = [];
|
public array $hotelCodes = [];
|
||||||
|
|
||||||
|
// The authenticated account, not BusPro's idea of this person. One BusPro person may sign in
|
||||||
|
// under any of the addresses on its record, and each of those is a separate local account with
|
||||||
|
// its own roles, so neither the BusPro ids nor the communication email identifies one. Patched
|
||||||
|
// on by the caller in the same way as the roles and hotel codes above.
|
||||||
|
public ?string $subject = null;
|
||||||
|
public ?string $loginEmail = null;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->address = new Address();
|
$this->address = new Address();
|
||||||
@@ -132,14 +139,21 @@ class PersonalData
|
|||||||
* Creates a structured array containing user profile information
|
* Creates a structured array containing user profile information
|
||||||
* suitable for JWT claims or user session data.
|
* suitable for JWT claims or user session data.
|
||||||
*
|
*
|
||||||
|
* `sub` and `email` describe the account that authenticated and are read from the patched-on
|
||||||
|
* fields, never from BusPro: `person_id`/`address_id` are shared by every account of the same
|
||||||
|
* person, and `profile.communication.email` is the first contact address on the BusPro record,
|
||||||
|
* which is not necessarily the one signed in with. Deliberately without a fallback to that
|
||||||
|
* address — a caller that forgets to patch them gets null rather than a wrong identity.
|
||||||
|
*
|
||||||
* @return array<string, mixed> The claims array with user profile data
|
* @return array<string, mixed> The claims array with user profile data
|
||||||
*/
|
*/
|
||||||
public function getClaims(): array
|
public function getClaims(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'sub' => $this->subject,
|
||||||
'person_id' => $this->personId,
|
'person_id' => $this->personId,
|
||||||
'address_id' => $this->addressId,
|
'address_id' => $this->addressId,
|
||||||
'email' => $this->communication->email,
|
'email' => $this->loginEmail,
|
||||||
'roles' => $this->roles,
|
'roles' => $this->roles,
|
||||||
'profile' => [
|
'profile' => [
|
||||||
'first_name' => $this->firstName,
|
'first_name' => $this->firstName,
|
||||||
|
|||||||
@@ -20,4 +20,38 @@ final class XmlCrawlerFactory
|
|||||||
|
|
||||||
return $crawler;
|
return $crawler;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explains why create() returned an empty Crawler. addXmlContent() discards the
|
||||||
|
* libxml errors, so an empty, a malformed and a truncated payload are
|
||||||
|
* indistinguishable afterwards; this re-parses to recover the reason. Only worth
|
||||||
|
* calling on the failure path.
|
||||||
|
*/
|
||||||
|
public static function diagnose(string $xml): string
|
||||||
|
{
|
||||||
|
if ('' === trim($xml)) {
|
||||||
|
return sprintf('empty response (%d bytes)', strlen($xml));
|
||||||
|
}
|
||||||
|
|
||||||
|
$previousUseErrors = libxml_use_internal_errors(true);
|
||||||
|
libxml_clear_errors();
|
||||||
|
|
||||||
|
try {
|
||||||
|
simplexml_load_string($xml);
|
||||||
|
|
||||||
|
$messages = [];
|
||||||
|
foreach (libxml_get_errors() as $error) {
|
||||||
|
$messages[] = sprintf('%s (line %d, column %d)', trim($error->message), $error->line, $error->column);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([] === $messages) {
|
||||||
|
return sprintf('unknown XML error (%d bytes)', strlen($xml));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('%s (%d bytes)', implode('; ', array_unique($messages)), strlen($xml));
|
||||||
|
} finally {
|
||||||
|
libxml_clear_errors();
|
||||||
|
libxml_use_internal_errors($previousUseErrors);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -281,9 +281,9 @@ class TravelLoader extends AbstractLoader
|
|||||||
/**
|
/**
|
||||||
* Apply availability data to travel services.
|
* Apply availability data to travel services.
|
||||||
*
|
*
|
||||||
* Updates the availability status of additional and transportation
|
* Updates the remaining contingent and the live status of additional and
|
||||||
* services, and the allowed booking status, based on the provided
|
* transportation services, and the allowed booking status, based on the
|
||||||
* availability data.
|
* provided availability data.
|
||||||
*
|
*
|
||||||
* @param Travel $travel The travel object to update
|
* @param Travel $travel The travel object to update
|
||||||
* @param ServiceAvailabilityResponse $availabilities The availability data for services
|
* @param ServiceAvailabilityResponse $availabilities The availability data for services
|
||||||
@@ -293,8 +293,19 @@ class TravelLoader extends AbstractLoader
|
|||||||
$serviceAvailabilities = $availabilities->getServices();
|
$serviceAvailabilities = $availabilities->getServices();
|
||||||
|
|
||||||
foreach ([...$travel->additionalServices, ...$travel->transportationServices] as $service) {
|
foreach ([...$travel->additionalServices, ...$travel->transportationServices] as $service) {
|
||||||
if (array_key_exists($service->id, $serviceAvailabilities)) {
|
if (false === array_key_exists($service->id, $serviceAvailabilities)) {
|
||||||
$service->available = $serviceAvailabilities[$service->id]->available;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$availability = $serviceAvailabilities[$service->id];
|
||||||
|
$service->available = $availability->available;
|
||||||
|
|
||||||
|
// The live status is the only signal that a contingent has run out: BusPro moves a
|
||||||
|
// Leistung to 'Anfrage' without necessarily reporting frei="0". Only overwrite when the
|
||||||
|
// response actually carries one, so a response that omits the attribute does not wipe
|
||||||
|
// the value parsed from the XML export. Mirrors TravelSnapshotManager::applyExtendedToService().
|
||||||
|
if (null !== $availability->status && '' !== trim($availability->status)) {
|
||||||
|
$service->status = $availability->status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class ApiResponseParser extends AbstractParser
|
|||||||
$crawler = XmlCrawlerFactory::create($xml);
|
$crawler = XmlCrawlerFactory::create($xml);
|
||||||
|
|
||||||
if (0 === $crawler->count()) {
|
if (0 === $crawler->count()) {
|
||||||
throw new ResponseParserException('Empty response received from server');
|
throw new ResponseParserException('Unable to parse XML response: '.XmlCrawlerFactory::diagnose($xml));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override type unless present in XML to catch error responses
|
// Override type unless present in XML to catch error responses
|
||||||
@@ -56,7 +56,8 @@ class ApiResponseParser extends AbstractParser
|
|||||||
case 'Vorgangdruck':
|
case 'Vorgangdruck':
|
||||||
return (new DocumentsParser())->parseConfirmation($resultNode);
|
return (new DocumentsParser())->parseConfirmation($resultNode);
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
|
throw new ResponseParserException(sprintf('Unrecognised customer data subtype "%s"', $subType));
|
||||||
case ApiClient::TYPE_BASE_DATA_COUNTRIES:
|
case ApiClient::TYPE_BASE_DATA_COUNTRIES:
|
||||||
return (new CountriesParser())->parse($resultNode);
|
return (new CountriesParser())->parse($resultNode);
|
||||||
case ApiClient::TYPE_MUTABLE_DATA:
|
case ApiClient::TYPE_MUTABLE_DATA:
|
||||||
@@ -92,6 +93,6 @@ class ApiResponseParser extends AbstractParser
|
|||||||
throw new ResponseParserException('Invalid XML response structure: '.$e->getMessage(), 0, $e);
|
throw new ResponseParserException('Invalid XML response structure: '.$e->getMessage(), 0, $e);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new ResponseParserException('Unable to parse XML response');
|
throw new ResponseParserException(sprintf('Unrecognised BusProNet response type "%s"', $responseType));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ class CrmAttributesResponseParser
|
|||||||
1477 => Role::GROUPS_MANAGER,
|
1477 => Role::GROUPS_MANAGER,
|
||||||
1478 => Role::GROUPS_ADMIN,
|
1478 => Role::GROUPS_ADMIN,
|
||||||
1483 => Role::CUSTOMER_EXPERT,
|
1483 => Role::CUSTOMER_EXPERT,
|
||||||
|
1484 => Role::TEAM_ADMIN,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -26,8 +26,9 @@ class UserinfoController extends AbstractController
|
|||||||
#[Route('/userinfo', name: 'api_userinfo', methods: ['GET'])]
|
#[Route('/userinfo', name: 'api_userinfo', methods: ['GET'])]
|
||||||
public function index(): JsonResponse
|
public function index(): JsonResponse
|
||||||
{
|
{
|
||||||
// basic scopes applicable to all authenticated users
|
// basic scopes applicable to all authenticated users. `sub` is not gated on a scope of its
|
||||||
$scopes = ['email'];
|
// own: it identifies the account every other claim describes, so it is always exported.
|
||||||
|
$scopes = ['sub', 'email'];
|
||||||
|
|
||||||
// extend scopes depending on granted permissions
|
// extend scopes depending on granted permissions
|
||||||
if ($this->isGranted('ROLE_OAUTH2_ID')) {
|
if ($this->isGranted('ROLE_OAUTH2_ID')) {
|
||||||
@@ -53,6 +54,17 @@ class UserinfoController extends AbstractController
|
|||||||
return new JsonResponse(['message' => $data->message, 'code' => $data->code], Response::HTTP_BAD_REQUEST);
|
return new JsonResponse(['message' => $data->message, 'code' => $data->code], Response::HTTP_BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Patch the identity of the account that authenticated. BusPro accepts any of the
|
||||||
|
// addresses on a person's record as a login and answers all of them with the same
|
||||||
|
// ids and the same first contact address, so only the local account tells the staff
|
||||||
|
// account and the private one apart — and they hold different roles.
|
||||||
|
//
|
||||||
|
// The uuid, not the primary key: consumers store this value and match their own
|
||||||
|
// accounts on it, so it must not disclose how many accounts exist or let one
|
||||||
|
// account's identifier be guessed from another's.
|
||||||
|
$data->subject = $user->getUuid();
|
||||||
|
$data->loginEmail = $user->getEmail();
|
||||||
|
|
||||||
// Patch current user's roles. The implicit ROLE_USER says nothing about the
|
// Patch current user's roles. The implicit ROLE_USER says nothing about the
|
||||||
// account — every authenticated user holds it — and is not exported.
|
// account — every authenticated user holds it — and is not exported.
|
||||||
$data->roles = Role::effectiveOnly($user->getRoles());
|
$data->roles = Role::effectiveOnly($user->getRoles());
|
||||||
|
|||||||
@@ -116,6 +116,14 @@ class IndexController extends AbstractController
|
|||||||
$this->addFlash('info', 'Dein zuvor gespeicherter Entwurf wurde wiederhergestellt.');
|
$this->addFlash('info', 'Dein zuvor gespeicherter Entwurf wurde wiederhergestellt.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$droppedDraftServiceLabels = $this->dataLoader->getDroppedDraftServiceLabels();
|
||||||
|
if ([] !== $droppedDraftServiceLabels) {
|
||||||
|
$this->addFlash('info', sprintf(
|
||||||
|
'Folgende Leistungen aus deinem gespeicherten Entwurf sind inzwischen ausgebucht oder nur auf Anfrage verfügbar und konnten nicht übernommen werden: %s. An deiner bestehenden Buchung ändert sich dadurch nichts. Bitte kontaktiere uns, wenn du sie trotzdem benötigst.',
|
||||||
|
implode(', ', $droppedDraftServiceLabels)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch booking data for display (surcharges, canceled status, etc.)
|
// Fetch booking data for display (surcharges, canceled status, etc.)
|
||||||
$bookingData = $this->dataLoader->fetchBookingData($bookingId, $user);
|
$bookingData = $this->dataLoader->fetchBookingData($bookingId, $user);
|
||||||
if (null === $bookingData || $bookingData instanceof Notification) {
|
if (null === $bookingData || $bookingData instanceof Notification) {
|
||||||
@@ -185,6 +193,13 @@ class IndexController extends AbstractController
|
|||||||
$this->addFlash('info', 'Einige Änderungen wurden verworfen, da sie aktuell nicht mehr änderbar sind.');
|
$this->addFlash('info', 'Einige Änderungen wurden verworfen, da sie aktuell nicht mehr änderbar sind.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ([] !== $result->unbookableServicesReverted) {
|
||||||
|
$this->addFlash('info', sprintf(
|
||||||
|
'Folgende Leistungen konnten nicht hinzugebucht werden, da sie inzwischen ausgebucht oder nur auf Anfrage verfügbar sind: %s. An deiner bestehenden Buchung ändert sich dadurch nichts. Bitte kontaktiere uns, wenn du sie trotzdem benötigst.',
|
||||||
|
implode(', ', $result->unbookableServicesReverted)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
switch ($result->status) {
|
switch ($result->status) {
|
||||||
case BookingEditSubmissionResult::STATUS_BOOKING_DATA_RELOAD_FAILED:
|
case BookingEditSubmissionResult::STATUS_BOOKING_DATA_RELOAD_FAILED:
|
||||||
$this->addFlash('error', 'Buchungsdaten konnten vor dem Speichern nicht neu geladen werden');
|
$this->addFlash('error', 'Buchungsdaten konnten vor dem Speichern nicht neu geladen werden');
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Model\AbstractClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An OAuth2 client, extended with the roles an account must hold to authorize it.
|
||||||
|
*
|
||||||
|
* The bundle ships its own Client model and maps it for us. Replacing it (via
|
||||||
|
* league_oauth2_server.client.classname) is the supported way to add a column: the bundle
|
||||||
|
* maps AbstractClient as a mapped superclass and carries name, secret, redirectUris,
|
||||||
|
* grants, scopes, active and allowPlainTextPkce, then stops mapping its own Client, so
|
||||||
|
* this class only has to declare the table, the primary key and what it adds.
|
||||||
|
*
|
||||||
|
* Why the requirement lives here rather than in configuration: the alternative was a map
|
||||||
|
* in services.yaml keyed on the client identifier, and identifiers differ per environment,
|
||||||
|
* so that map would have needed an env var per client and would have drifted from the
|
||||||
|
* client table it describes. A column cannot drift from its own row.
|
||||||
|
*
|
||||||
|
* Nothing validates the contents. A misspelled role is not an error, it is a client that
|
||||||
|
* authorizes nobody, so AuthorizationCodeListener logs the requirement whenever it
|
||||||
|
* refuses — that is what makes the mistake visible without a database query.
|
||||||
|
*/
|
||||||
|
#[ORM\Entity]
|
||||||
|
#[ORM\Table(name: 'oauth2_client')]
|
||||||
|
class OAuth2Client extends AbstractClient
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Declared protected on AbstractClient and mapped by the bundle only for its own
|
||||||
|
* Client, so the custom class has to map it itself. The type restates the parent's
|
||||||
|
* contract, which redeclaring the property would otherwise widen.
|
||||||
|
*
|
||||||
|
* @var non-empty-string
|
||||||
|
*/
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\Column(type: 'string', length: 32)]
|
||||||
|
protected string $identifier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The roles that entitle an account to authorize this client. An empty list denies
|
||||||
|
* everyone: a row nobody has configured and a client that needs no roles are the same
|
||||||
|
* stored value, and of the two readings only "deny" is safe.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
#[ORM\Column(type: 'json')]
|
||||||
|
private array $requiredRoles = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function getRequiredRoles(): array
|
||||||
|
{
|
||||||
|
return $this->requiredRoles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $requiredRoles
|
||||||
|
*/
|
||||||
|
public function setRequiredRoles(array $requiredRoles): self
|
||||||
|
{
|
||||||
|
$this->requiredRoles = $requiredRoles;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use App\Repository\UserRepository;
|
|||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||||
use Symfony\Component\Security\Core\User\UserInterface;
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
|
||||||
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
||||||
class User implements UserInterface, PasswordAuthenticatedUserInterface
|
class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||||
@@ -15,6 +16,22 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
#[ORM\Column(type: 'integer')]
|
#[ORM\Column(type: 'integer')]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account's external identifier, exported as the OAuth2 `sub` claim.
|
||||||
|
*
|
||||||
|
* Separate from the primary key on purpose: `sub` is handed to other applications, which
|
||||||
|
* store it and match their own accounts on it, so it has to be stable, unique and free of
|
||||||
|
* anything the row's position in the table implies. The auto-increment id satisfied the
|
||||||
|
* first two and neither of the last: it disclosed how many accounts exist and made one
|
||||||
|
* account's identifier a guess away from the next.
|
||||||
|
*
|
||||||
|
* v4 rather than the framework's default v7 (see config/packages/uid.yaml): v7 embeds its
|
||||||
|
* creation timestamp and sorts, which is useful for a key and is exactly the leak this
|
||||||
|
* column exists to avoid. Matches Groups\AccommodationBooking::$uuid in shape and version.
|
||||||
|
*/
|
||||||
|
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||||
|
private string $uuid;
|
||||||
|
|
||||||
#[ORM\Column(type: 'string', length: 180, unique: true)]
|
#[ORM\Column(type: 'string', length: 180, unique: true)]
|
||||||
private ?string $email;
|
private ?string $email;
|
||||||
|
|
||||||
@@ -54,6 +71,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
public function __construct(string $email)
|
public function __construct(string $email)
|
||||||
{
|
{
|
||||||
$this->email = $email;
|
$this->email = $email;
|
||||||
|
$this->uuid = Uuid::v4()->toRfc4122();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
@@ -61,6 +79,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
return $this->id;
|
return $this->id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getUuid(): string
|
||||||
|
{
|
||||||
|
return $this->uuid;
|
||||||
|
}
|
||||||
|
|
||||||
public function getEmail(): ?string
|
public function getEmail(): ?string
|
||||||
{
|
{
|
||||||
return $this->email;
|
return $this->email;
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\EventListener;
|
namespace App\EventListener;
|
||||||
|
|
||||||
|
use App\Entity\OAuth2Client;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
|
use App\Security\Role;
|
||||||
use League\Bundle\OAuth2ServerBundle\Event\AuthorizationRequestResolveEvent;
|
use League\Bundle\OAuth2ServerBundle\Event\AuthorizationRequestResolveEvent;
|
||||||
use League\Bundle\OAuth2ServerBundle\OAuth2Events;
|
use League\Bundle\OAuth2ServerBundle\OAuth2Events;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
@@ -14,14 +16,21 @@ use Symfony\Component\HttpFoundation\RedirectResponse;
|
|||||||
use Symfony\Component\HttpFoundation\RequestStack;
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
use Twig\Environment;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles OAuth2 authorization code grant flow.
|
* Handles OAuth2 authorization code grant flow.
|
||||||
*
|
*
|
||||||
* Automatically approves authorization requests for authenticated users without
|
* Redirects anonymous users to login, then decides whether the account may authorize the
|
||||||
* consent prompts. Redirects anonymous users to login. For external OAuth2
|
* client that asked. Consent is deliberately not part of that decision: every client is
|
||||||
* requests (flagged via session), logs out the user after authorization to
|
* one of our own applications, so there is nothing for the user to weigh up. What the user
|
||||||
* prevent session hijacking.
|
* cannot be asked, though, is whether they are entitled to the application at all, and
|
||||||
|
* that is what this listener answers — a client declares the roles it requires in
|
||||||
|
* OAuth2Client::$requiredRoles and an account holding none of them is refused here rather
|
||||||
|
* than handed a token the client will reject for reasons it cannot explain.
|
||||||
|
*
|
||||||
|
* For external OAuth2 requests (flagged via session), logs out the user after a successful
|
||||||
|
* authorization to prevent session hijacking.
|
||||||
*/
|
*/
|
||||||
#[AsEventListener(event: OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, method: 'onAuthorizationRequestResolve')]
|
#[AsEventListener(event: OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE, method: 'onAuthorizationRequestResolve')]
|
||||||
class AuthorizationCodeListener
|
class AuthorizationCodeListener
|
||||||
@@ -31,6 +40,7 @@ class AuthorizationCodeListener
|
|||||||
private readonly RequestStack $requestStack,
|
private readonly RequestStack $requestStack,
|
||||||
private readonly Security $security,
|
private readonly Security $security,
|
||||||
private readonly LoggerInterface $authenticationLogger,
|
private readonly LoggerInterface $authenticationLogger,
|
||||||
|
private readonly Environment $twig,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,13 +63,81 @@ class AuthorizationCodeListener
|
|||||||
);
|
);
|
||||||
$event->setResponse($response);
|
$event->setResponse($response);
|
||||||
$this->authenticationLogger->info('Authorization request without session');
|
$this->authenticationLogger->info('Authorization request without session');
|
||||||
} else {
|
|
||||||
$event->resolveAuthorization(AuthorizationRequestResolveEvent::AUTHORIZATION_APPROVED);
|
return;
|
||||||
// in case this authorization request has been flagged as external in login controller,
|
}
|
||||||
// immediately logout the current user (see App\Controller\Core\Security\LoginController).
|
|
||||||
if (true === $request->getSession()->get('_oauth2', false)) {
|
$client = $event->getClient();
|
||||||
$this->security->logout(false);
|
|
||||||
}
|
// the event is typed to the bundle's ClientInterface, which knows nothing about the
|
||||||
|
// column below: anything that is not our entity is a client we cannot have configured
|
||||||
|
if (false === $client instanceof OAuth2Client) {
|
||||||
|
$this->deny($event, $user, $client->getIdentifier(), [], 'unexpected client class');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$required = $client->getRequiredRoles();
|
||||||
|
|
||||||
|
// An empty requirement is not "no requirement": it is the state a client row starts
|
||||||
|
// in, so it means nobody has said who may use this client yet.
|
||||||
|
if ([] === $required) {
|
||||||
|
$this->deny($event, $user, $client->getName(), $required, 'client has no roles configured');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// effectiveOnly(), never getRoles(): a *_PENDING marker is a CRM nomination that
|
||||||
|
// grants nothing until an administrator approves it, and matching the raw role set
|
||||||
|
// would let a nomination open a staff application on its own.
|
||||||
|
if ([] === array_intersect($required, Role::effectiveOnly($user->getRoles()))) {
|
||||||
|
$this->deny($event, $user, $client->getName(), $required, 'insufficient roles');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$event->resolveAuthorization(AuthorizationRequestResolveEvent::AUTHORIZATION_APPROVED);
|
||||||
|
|
||||||
|
// in case this authorization request has been flagged as external in login controller,
|
||||||
|
// immediately logout the current user (see App\Controller\SecurityController).
|
||||||
|
if (true === $request->getSession()->get('_oauth2', false)) {
|
||||||
|
$this->security->logout(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuses the request and explains it on our own page.
|
||||||
|
*
|
||||||
|
* The resolution is left at its AUTHORIZATION_DENIED default; setting a response is what
|
||||||
|
* keeps the user here instead of bouncing them back to a client that would only be able
|
||||||
|
* to report that no authorization code arrived. The session is deliberately left intact
|
||||||
|
* — the user has to still be signed in to read the page and navigate away from it.
|
||||||
|
*
|
||||||
|
* Logged with the client's requirement but never with the user's roles: the requirement
|
||||||
|
* is configuration, and it is what makes a misspelled entry diagnosable from the log,
|
||||||
|
* while the account's roles are personal data and the auth channel is database-backed.
|
||||||
|
*
|
||||||
|
* @param list<string> $required
|
||||||
|
*/
|
||||||
|
private function deny(
|
||||||
|
AuthorizationRequestResolveEvent $event,
|
||||||
|
User $user,
|
||||||
|
string $client,
|
||||||
|
array $required,
|
||||||
|
string $reason,
|
||||||
|
): void {
|
||||||
|
$this->authenticationLogger->info('Authorization request denied', [
|
||||||
|
'client' => $client,
|
||||||
|
'user_id' => $user->getId(),
|
||||||
|
'required_roles' => $required,
|
||||||
|
'reason' => $reason,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$event->setResponse(new Response(
|
||||||
|
// oauth2: true drops hx-boost in base.html.twig -- this page is rendered
|
||||||
|
// mid-redirect-chain, where boosting breaks the navigation
|
||||||
|
$this->twig->render('security/oauth2_denied.html.twig', ['client' => $client, 'oauth2' => true]),
|
||||||
|
Response::HTTP_FORBIDDEN
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,10 +154,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
$attributes['data-description'] = $service->description;
|
$attributes['data-description'] = $service->description;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make readonly if service is unavailable (intelligently handles edit mode)
|
// Make readonly if the service is not bookable for this participant
|
||||||
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'courses')) {
|
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
|
||||||
$attributes['readonly'] = true;
|
$attributes['readonly'] = true;
|
||||||
$attributes['data-tooltip'] = 'ausgebucht';
|
$attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $attributes;
|
return $attributes;
|
||||||
@@ -213,9 +213,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Make readonly if service is unavailable (only if not already mandatory)
|
// Make readonly if service is unavailable (only if not already mandatory)
|
||||||
if (false === $service->mandatory && $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'additionalServices')) {
|
if (false === $service->mandatory && $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
|
||||||
$attributes['readonly'] = true;
|
$attributes['readonly'] = true;
|
||||||
$attributes['data-tooltip'] = 'ausgebucht';
|
$attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $attributes;
|
return $attributes;
|
||||||
@@ -270,9 +270,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
|
|
||||||
// Make readonly if service is unavailable (only if not already mandatory)
|
// Make readonly if service is unavailable (only if not already mandatory)
|
||||||
if (false === $service->mandatory
|
if (false === $service->mandatory
|
||||||
&& $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'board')) {
|
&& $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
|
||||||
$attributes['readonly'] = true;
|
$attributes['readonly'] = true;
|
||||||
$attributes['data-tooltip'] = 'ausgebucht';
|
$attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $attributes;
|
return $attributes;
|
||||||
@@ -330,10 +330,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
return $attributes;
|
return $attributes;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make readonly if service is unavailable (intelligently handles edit mode)
|
// Make readonly if the service is not bookable for this participant
|
||||||
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'veg')) {
|
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
|
||||||
$attributes['readonly'] = true;
|
$attributes['readonly'] = true;
|
||||||
$attributes['data-tooltip'] = 'ausgebucht';
|
$attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $attributes;
|
return $attributes;
|
||||||
@@ -392,9 +392,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
|
|
||||||
// Make readonly if service is unavailable (only if not already mandatory)
|
// Make readonly if service is unavailable (only if not already mandatory)
|
||||||
if (false === $service->mandatory
|
if (false === $service->mandatory
|
||||||
&& $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'rentals')) {
|
&& $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
|
||||||
$attributes['readonly'] = true;
|
$attributes['readonly'] = true;
|
||||||
$attributes['data-tooltip'] = 'ausgebucht';
|
$attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $attributes;
|
return $attributes;
|
||||||
@@ -527,10 +527,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
$attributes['data-description'] = $service->description;
|
$attributes['data-description'] = $service->description;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make readonly if service is unavailable (intelligently handles edit mode)
|
// Make readonly if the service is not bookable for this participant
|
||||||
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'transportationOutbound')) {
|
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
|
||||||
$attributes['readonly'] = true;
|
$attributes['readonly'] = true;
|
||||||
$attributes['data-tooltip'] = 'ausgebucht';
|
$attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $attributes;
|
return $attributes;
|
||||||
@@ -567,10 +567,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
$attributes['data-description'] = $service->description;
|
$attributes['data-description'] = $service->description;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make readonly if service is unavailable (intelligently handles edit mode)
|
// Make readonly if the service is not bookable for this participant
|
||||||
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'transportationInbound')) {
|
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex)) {
|
||||||
$attributes['readonly'] = true;
|
$attributes['readonly'] = true;
|
||||||
$attributes['data-tooltip'] = 'ausgebucht';
|
$attributes['data-tooltip'] = $this->getUnavailabilityTooltip($service, $bookingDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $attributes;
|
return $attributes;
|
||||||
@@ -661,9 +661,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
$fieldOptions['attr']['data-description'] = $parkingService->description;
|
$fieldOptions['attr']['data-description'] = $parkingService->description;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->shouldMakeServiceReadonly($parkingService, $bookingDto, $participantIndex, 'parking')) {
|
if ($this->shouldMakeServiceReadonly($parkingService, $bookingDto, $participantIndex)) {
|
||||||
$fieldOptions['attr']['readonly'] = true;
|
$fieldOptions['attr']['readonly'] = true;
|
||||||
$fieldOptions['attr']['data-tooltip'] = 'ausgebucht';
|
$fieldOptions['attr']['data-tooltip'] = $this->getUnavailabilityTooltip($parkingService, $bookingDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $fieldOptions;
|
return $fieldOptions;
|
||||||
@@ -863,66 +863,25 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
*/
|
*/
|
||||||
private function isServiceUnavailableForParticipant(Service $service, BookingDto $bookingDto, int $participantIndex): bool
|
private function isServiceUnavailableForParticipant(Service $service, BookingDto $bookingDto, int $participantIndex): bool
|
||||||
{
|
{
|
||||||
if (BookingDto::MODE_EDIT === $bookingDto->getMode()) {
|
|
||||||
// For non-create workflows, don't apply availability restrictions
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->serviceAvailabilityCalculator->isServiceUnavailable($service->id, $bookingDto, $participantIndex);
|
return $this->serviceAvailabilityCalculator->isServiceUnavailable($service->id, $bookingDto, $participantIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines if a service should be rendered as read-only.
|
* Determines if a service should be rendered as read-only.
|
||||||
*
|
*
|
||||||
* This method intelligently handles readonly state for services in both create and edit modes:
|
* Both modes share one rule set. The already-held carve-out inside
|
||||||
*
|
* ServiceAvailabilityCalculator::isServiceUnavailable() is what lets a participant keep a
|
||||||
* - CREATE MODE: Uses existing availability calculator logic
|
* service that is now sold out or on request, so no mode-specific branch is needed here.
|
||||||
* - EDIT MODE: Services unavailable (available <= 0) are readonly ONLY if participant doesn't already have them
|
|
||||||
*
|
|
||||||
* This prevents fingerprint false positives in edit mode by allowing participants to keep
|
|
||||||
* services they already have, even if those services are now fully booked.
|
|
||||||
*
|
*
|
||||||
* @param Service $service The service to check
|
* @param Service $service The service to check
|
||||||
* @param BookingDto $bookingDto The booking DTO containing participant data
|
* @param BookingDto $bookingDto The booking DTO containing participant data
|
||||||
* @param int $participantIndex Index of the participant currently selecting services
|
* @param int $participantIndex Index of the participant currently selecting services
|
||||||
* @param string $fieldName Name of the service field (e.g., 'courses', 'board', 'rentals')
|
|
||||||
*
|
*
|
||||||
* @return bool True if the service should be read-only
|
* @return bool True if the service should be read-only
|
||||||
*/
|
*/
|
||||||
private function shouldMakeServiceReadonly(Service $service, BookingDto $bookingDto, int $participantIndex, string $fieldName): bool
|
private function shouldMakeServiceReadonly(Service $service, BookingDto $bookingDto, int $participantIndex): bool
|
||||||
{
|
{
|
||||||
// In CREATE mode, use existing availability logic
|
return $this->isServiceUnavailableForParticipant($service, $bookingDto, $participantIndex);
|
||||||
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
|
|
||||||
return $this->isServiceUnavailableForParticipant($service, $bookingDto, $participantIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
// In EDIT mode, apply intelligent readonly logic
|
|
||||||
// If service is available (available > 0), it's never readonly
|
|
||||||
if (null !== $service->available && $service->available > 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Service is unavailable - check if participant already has it
|
|
||||||
$participant = $bookingDto->getParticipant($participantIndex);
|
|
||||||
if (null === $participant) {
|
|
||||||
return true; // Readonly if no participant data
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if participant has this service based on field type
|
|
||||||
$participantHasService = match ($fieldName) {
|
|
||||||
'courses' => $this->hasServiceById($participant->courses, $service->id),
|
|
||||||
'additionalServices' => $this->hasServiceById($participant->additionalServices, $service->id),
|
|
||||||
'board' => $this->hasServiceById($participant->board, $service->id),
|
|
||||||
'veg' => $participant->veg?->id === $service->id,
|
|
||||||
'rentals' => $this->hasServiceById($participant->rentals, $service->id),
|
|
||||||
'skiPass' => $participant->skiPass?->id === $service->id,
|
|
||||||
'transportationOutbound' => $participant->transportationOutbound?->id === $service->id,
|
|
||||||
'transportationInbound' => $participant->transportationInbound?->id === $service->id,
|
|
||||||
default => false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Make readonly only if participant doesn't have it
|
|
||||||
return false === $participantHasService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -944,6 +903,23 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the tooltip explaining why a service is rendered read-only.
|
||||||
|
*
|
||||||
|
* Only called for a service that is already known to be blocked, so the status alone
|
||||||
|
* identifies the reason. Create keeps the pre-existing wording, since the on-request rule
|
||||||
|
* never fires there.
|
||||||
|
*/
|
||||||
|
private function getUnavailabilityTooltip(Service $service, BookingDto $bookingDto): string
|
||||||
|
{
|
||||||
|
if (BookingDto::MODE_EDIT === $bookingDto->getMode()
|
||||||
|
&& Constants::STATUS_ON_REQUEST === $service->status) {
|
||||||
|
return 'Diese Leistung ist derzeit nur auf Anfrage buchbar. Bitte kontaktiere uns.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'ausgebucht';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filters services based on participant's age constraints.
|
* Filters services based on participant's age constraints.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -14,10 +14,17 @@ final readonly class BookingEditSubmissionResult
|
|||||||
public const string STATUS_TIMEOUT = 'timeout';
|
public const string STATUS_TIMEOUT = 'timeout';
|
||||||
public const string STATUS_API_CLIENT_ERROR = 'api_client_error';
|
public const string STATUS_API_CLIENT_ERROR = 'api_client_error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $unbookableServicesReverted Labels of services dropped because they are
|
||||||
|
* no longer bookable, reported separately from
|
||||||
|
* the mutability revert: the category is still
|
||||||
|
* mutable, the service is not bookable
|
||||||
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public string $status,
|
public string $status,
|
||||||
public ?string $message = null,
|
public ?string $message = null,
|
||||||
public bool $immutableChangesReverted = false,
|
public bool $immutableChangesReverted = false,
|
||||||
|
public array $unbookableServicesReverted = [],
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,10 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
|
|||||||
|
|
||||||
public function authenticate(Request $request): Passport
|
public function authenticate(Request $request): Passport
|
||||||
{
|
{
|
||||||
$email = trim($request->request->getString('_username'));
|
// One canonical casing per account. BusPro matches an address case-insensitively and so
|
||||||
|
// does the utf8mb4_unicode_ci column, so the casing somebody happens to type must not
|
||||||
|
// become the casing every later BusPro call re-sends.
|
||||||
|
$email = mb_strtolower(trim($request->request->getString('_username')));
|
||||||
$passwordPlain = trim($request->request->getString('_password'));
|
$passwordPlain = trim($request->request->getString('_password'));
|
||||||
|
|
||||||
// BPN requires md5, not a real hash
|
// BPN requires md5, not a real hash
|
||||||
@@ -114,6 +117,10 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
|
|||||||
$user = $userRepository->findOneBy(['email' => $email]) ?? new User($email);
|
$user = $userRepository->findOneBy(['email' => $email]) ?? new User($email);
|
||||||
|
|
||||||
$user
|
$user
|
||||||
|
// Not redundant next to the constructor: an account created before the address was
|
||||||
|
// normalized still carries the casing of its very first login, and everything
|
||||||
|
// downstream re-sends what is stored rather than what was just typed.
|
||||||
|
->setEmail($email)
|
||||||
->setPassword($encryptedPassword)
|
->setPassword($encryptedPassword)
|
||||||
->setPersonId($personalData->personId)
|
->setPersonId($personalData->personId)
|
||||||
->setAddressId($personalData->addressId)
|
->setAddressId($personalData->addressId)
|
||||||
@@ -182,9 +189,11 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
|
|||||||
->setHotelCodes(array_values(array_unique($crmAttributes->hotelCodes)))
|
->setHotelCodes(array_values(array_unique($crmAttributes->hotelCodes)))
|
||||||
;
|
;
|
||||||
|
|
||||||
|
// Compared on the full role sets rather than on the markers alone: nominatedFrom() needs
|
||||||
|
// to see ROLE_EMPLOYEE to know whether an EMPLOYEE_ONLY marker counts.
|
||||||
$nominated = array_values(array_diff(
|
$nominated = array_values(array_diff(
|
||||||
Role::pendingOnly($user->getRoles()),
|
array_keys(Role::nominatedFrom($user->getRoles())),
|
||||||
Role::pendingOnly($previousRoles),
|
array_keys(Role::nominatedFrom($previousRoles)),
|
||||||
));
|
));
|
||||||
|
|
||||||
if ([] === $nominated) {
|
if ([] === $nominated) {
|
||||||
@@ -198,9 +207,9 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
|
|||||||
'roles' => $nominated,
|
'roles' => $nominated,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Only the newly appeared markers reach this point, so a repeat login with a nomination
|
// Only the newly appeared nominations reach this point, so a repeat login with a
|
||||||
// still standing announces nothing. That difference is the whole de-duplication.
|
// nomination still standing announces nothing. That difference is the whole de-duplication.
|
||||||
return array_keys(Role::nominatedFrom($nominated));
|
return $nominated;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
||||||
|
|||||||
+49
-8
@@ -23,6 +23,10 @@ namespace App\Security;
|
|||||||
* That widens a claim from "what the CRM reports" to "what the CRM reports plus what the account
|
* 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
|
* 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.
|
* nomination path, and the CRM remains the only source for every role that grants privileges.
|
||||||
|
*
|
||||||
|
* ROLE_EMPLOYEE does gate a few administrative roles, though (see EMPLOYEE_ONLY): for those, a
|
||||||
|
* CRM claim only counts when the account is staff. It narrows what the CRM can nominate and
|
||||||
|
* never widens it — the approval step still applies on top.
|
||||||
*/
|
*/
|
||||||
final class Role
|
final class Role
|
||||||
{
|
{
|
||||||
@@ -33,6 +37,7 @@ final class Role
|
|||||||
public const USER = 'ROLE_USER';
|
public const USER = 'ROLE_USER';
|
||||||
|
|
||||||
public const ADMIN = 'ROLE_ADMIN';
|
public const ADMIN = 'ROLE_ADMIN';
|
||||||
|
public const TEAM_ADMIN = 'ROLE_TEAM_ADMIN';
|
||||||
public const MANAGER = 'ROLE_MANAGER';
|
public const MANAGER = 'ROLE_MANAGER';
|
||||||
public const TEAMER = 'ROLE_TEAMER';
|
public const TEAMER = 'ROLE_TEAMER';
|
||||||
public const CUSTOMER = 'ROLE_CUSTOMER';
|
public const CUSTOMER = 'ROLE_CUSTOMER';
|
||||||
@@ -58,6 +63,7 @@ final class Role
|
|||||||
*/
|
*/
|
||||||
public const ALL = [
|
public const ALL = [
|
||||||
self::ADMIN,
|
self::ADMIN,
|
||||||
|
self::TEAM_ADMIN,
|
||||||
self::MANAGER,
|
self::MANAGER,
|
||||||
self::TEAMER,
|
self::TEAMER,
|
||||||
self::CUSTOMER,
|
self::CUSTOMER,
|
||||||
@@ -90,6 +96,7 @@ final class Role
|
|||||||
*/
|
*/
|
||||||
public const ADMINISTRATIVE = [
|
public const ADMINISTRATIVE = [
|
||||||
self::ADMIN,
|
self::ADMIN,
|
||||||
|
self::TEAM_ADMIN,
|
||||||
self::MANAGER,
|
self::MANAGER,
|
||||||
self::HOUSE_MANAGER,
|
self::HOUSE_MANAGER,
|
||||||
self::GROUPS_ADMIN,
|
self::GROUPS_ADMIN,
|
||||||
@@ -97,6 +104,25 @@ final class Role
|
|||||||
self::CUSTOMER_EXPERT,
|
self::CUSTOMER_EXPERT,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Administrative roles reserved for staff accounts. A CRM claim for one of these is ignored
|
||||||
|
* unless the account is also claimed as ROLE_EMPLOYEE, so it neither nominates nor keeps a
|
||||||
|
* role approved earlier. BusPro backend users outside the company can edit their own CRM
|
||||||
|
* selections too, and these roles reach far enough that the email domain has to agree.
|
||||||
|
* ROLE_HOUSE_MANAGER is the one administrative role left out: a Hausleitung signs in with
|
||||||
|
* the hotel's own address.
|
||||||
|
*
|
||||||
|
* @var string[]
|
||||||
|
*/
|
||||||
|
public const EMPLOYEE_ONLY = [
|
||||||
|
self::ADMIN,
|
||||||
|
self::TEAM_ADMIN,
|
||||||
|
self::MANAGER,
|
||||||
|
self::GROUPS_ADMIN,
|
||||||
|
self::GROUPS_MANAGER,
|
||||||
|
self::CUSTOMER_EXPERT,
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Roles nobody may approve for their own account. ROLE_ADMIN outranks every check in this
|
* Roles nobody may approve for their own account. ROLE_ADMIN outranks every check in this
|
||||||
* application, including the approval surface itself, so it always takes a second
|
* application, including the approval surface itself, so it always takes a second
|
||||||
@@ -159,7 +185,9 @@ final class Role
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The roles behind those markers, labelled — what an approver acts on.
|
* The roles behind those markers, labelled — what an approver acts on. A marker for an
|
||||||
|
* EMPLOYEE_ONLY role on an account that is not staff is left out: it predates the rule and
|
||||||
|
* goes on that account's next login, and until then it must not be approvable.
|
||||||
*
|
*
|
||||||
* @param string[] $roles
|
* @param string[] $roles
|
||||||
*
|
*
|
||||||
@@ -170,9 +198,15 @@ final class Role
|
|||||||
$labels = self::labels();
|
$labels = self::labels();
|
||||||
$nominated = [];
|
$nominated = [];
|
||||||
|
|
||||||
|
$isEmployee = \in_array(self::EMPLOYEE, $roles, true);
|
||||||
|
|
||||||
foreach (self::pendingOnly($roles) as $marker) {
|
foreach (self::pendingOnly($roles) as $marker) {
|
||||||
$role = self::realRole($marker);
|
$role = self::realRole($marker);
|
||||||
|
|
||||||
|
if (false === $isEmployee && \in_array($role, self::EMPLOYEE_ONLY, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (\in_array($role, self::ADMINISTRATIVE, true)) {
|
if (\in_array($role, self::ADMINISTRATIVE, true)) {
|
||||||
$nominated[$role] = $labels[$role];
|
$nominated[$role] = $labels[$role];
|
||||||
}
|
}
|
||||||
@@ -184,6 +218,8 @@ final class Role
|
|||||||
/**
|
/**
|
||||||
* The whole policy, applied on every login.
|
* The whole policy, applied on every login.
|
||||||
*
|
*
|
||||||
|
* 0. drop the claims for EMPLOYEE_ONLY roles unless ROLE_EMPLOYEE is claimed as well — they
|
||||||
|
* then count as not made, so the steps below revoke and never mark them;
|
||||||
* 1. revoke everything the CRM no longer claims — granted roles and markers alike, which is
|
* 1. revoke everything the CRM no longer claims — granted roles and markers alike, which is
|
||||||
* what makes BusPro the source of truth;
|
* what makes BusPro the source of truth;
|
||||||
* 2. grant the unconditional roles it claims;
|
* 2. grant the unconditional roles it claims;
|
||||||
@@ -204,6 +240,10 @@ final class Role
|
|||||||
{
|
{
|
||||||
$claimed = array_values(array_intersect(self::ALL, array_unique($claimedRoles)));
|
$claimed = array_values(array_intersect(self::ALL, array_unique($claimedRoles)));
|
||||||
|
|
||||||
|
if (false === \in_array(self::EMPLOYEE, $claimed, true)) {
|
||||||
|
$claimed = array_values(array_diff($claimed, self::EMPLOYEE_ONLY));
|
||||||
|
}
|
||||||
|
|
||||||
$roles = array_values(array_filter(
|
$roles = array_values(array_filter(
|
||||||
self::assignedOnly($storedRoles),
|
self::assignedOnly($storedRoles),
|
||||||
static fn (string $role): bool => \in_array(self::realRole($role), $claimed, true),
|
static fn (string $role): bool => \in_array(self::realRole($role), $claimed, true),
|
||||||
@@ -224,8 +264,8 @@ final class Role
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns a marker into the role it stands for. Refuses anything the account is not nominated
|
* Turns a marker into the role it stands for. Refuses anything the account is not nominated
|
||||||
* for, so neither a hand-crafted request nor a claim revoked while the confirmation dialog
|
* for (see nominatedFrom()), so neither a hand-crafted request nor a claim revoked while the
|
||||||
* was open can grant a role the CRM never reported.
|
* confirmation dialog was open can grant a role the CRM never reported.
|
||||||
*
|
*
|
||||||
* @param string[] $storedRoles
|
* @param string[] $storedRoles
|
||||||
*
|
*
|
||||||
@@ -237,7 +277,7 @@ final class Role
|
|||||||
{
|
{
|
||||||
$roles = self::assignedOnly($storedRoles);
|
$roles = self::assignedOnly($storedRoles);
|
||||||
|
|
||||||
if (false === \in_array(self::pending($role), $roles, true)) {
|
if (false === \array_key_exists($role, self::nominatedFrom($roles))) {
|
||||||
throw new \InvalidArgumentException(sprintf('The role "%s" is not pending approval.', $role));
|
throw new \InvalidArgumentException(sprintf('The role "%s" is not pending approval.', $role));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,13 +324,14 @@ final class Role
|
|||||||
{
|
{
|
||||||
$labels = [
|
$labels = [
|
||||||
self::ADMIN => 'Administration',
|
self::ADMIN => 'Administration',
|
||||||
self::MANAGER => 'Manager:in',
|
self::TEAM_ADMIN => 'Team Administration',
|
||||||
|
self::MANAGER => 'Reisemanager:in',
|
||||||
self::TEAMER => 'Teamer:in',
|
self::TEAMER => 'Teamer:in',
|
||||||
self::CUSTOMER => 'Kund:in',
|
self::CUSTOMER => 'Kund:in',
|
||||||
self::HOUSE_MANAGER => 'Hausleitung',
|
self::HOUSE_MANAGER => 'Hausleitung',
|
||||||
self::GROUPS_ADMIN => 'Preisrechner Admin',
|
self::GROUPS_ADMIN => 'GRO Admin',
|
||||||
self::GROUPS_MANAGER => 'Preisrechner',
|
self::GROUPS_MANAGER => 'GRO-Expert:in',
|
||||||
self::CUSTOMER_EXPERT => 'KO-Experte',
|
self::CUSTOMER_EXPERT => 'KO-Expert:in',
|
||||||
self::EMPLOYEE => 'Mitarbeiter:in',
|
self::EMPLOYEE => 'Mitarbeiter:in',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ class BookingEditDataLoader
|
|||||||
|
|
||||||
private bool $draftRestored = false;
|
private bool $draftRestored = false;
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
private array $droppedDraftServiceLabels = [];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ApiClient $apiClient,
|
private readonly ApiClient $apiClient,
|
||||||
private readonly BookingDataProcessor $bookingDataProcessor,
|
private readonly BookingDataProcessor $bookingDataProcessor,
|
||||||
@@ -53,6 +56,17 @@ class BookingEditDataLoader
|
|||||||
return $this->draftRestored;
|
return $this->draftRestored;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the drafted services dropped during the last load because they are no longer
|
||||||
|
* bookable. Reset on each call to loadFormData(), like isDraftRestored().
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function getDroppedDraftServiceLabels(): array
|
||||||
|
{
|
||||||
|
return $this->droppedDraftServiceLabels;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads booking data from session or initializes from API.
|
* Loads booking data from session or initializes from API.
|
||||||
*
|
*
|
||||||
@@ -72,6 +86,7 @@ class BookingEditDataLoader
|
|||||||
public function loadFormData(Request $request, int $bookingId, User $user): ?BookingDto
|
public function loadFormData(Request $request, int $bookingId, User $user): ?BookingDto
|
||||||
{
|
{
|
||||||
$this->draftRestored = false;
|
$this->draftRestored = false;
|
||||||
|
$this->droppedDraftServiceLabels = [];
|
||||||
|
|
||||||
$formData = $this->bookingSessionService->getBookingDto($request, BookingDto::MODE_EDIT);
|
$formData = $this->bookingSessionService->getBookingDto($request, BookingDto::MODE_EDIT);
|
||||||
|
|
||||||
@@ -177,6 +192,10 @@ class BookingEditDataLoader
|
|||||||
// it would tell the user their draft was restored for edits they never made.
|
// it would tell the user their draft was restored for edits they never made.
|
||||||
$this->draftRestored = true === $applied
|
$this->draftRestored = true === $applied
|
||||||
&& $formData->originalFingerprint !== $this->fingerprintService->generateFingerprint($formData);
|
&& $formData->originalFingerprint !== $this->fingerprintService->generateFingerprint($formData);
|
||||||
|
|
||||||
|
if (true === $applied) {
|
||||||
|
$this->droppedDraftServiceLabels = $this->draftService->getDroppedServiceLabels();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->bookingSessionService->saveBookingDto($request, $formData, BookingDto::MODE_EDIT);
|
$this->bookingSessionService->saveBookingDto($request, $formData, BookingDto::MODE_EDIT);
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ use Psr\Log\LoggerInterface;
|
|||||||
*/
|
*/
|
||||||
class BookingEditDraftManager
|
class BookingEditDraftManager
|
||||||
{
|
{
|
||||||
|
/** @var list<string> */
|
||||||
|
private array $droppedServiceLabels = [];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly BookingEditDraftRepository $draftRepository,
|
private readonly BookingEditDraftRepository $draftRepository,
|
||||||
private readonly EntityManagerInterface $entityManager,
|
private readonly EntityManagerInterface $entityManager,
|
||||||
@@ -32,6 +35,19 @@ class BookingEditDraftManager
|
|||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the services dropped during the last applyDraftToDto() call.
|
||||||
|
*
|
||||||
|
* Reset on each call, so the caller can report them to the user. Mirrors
|
||||||
|
* BookingEditDataLoader::isDraftRestored().
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function getDroppedServiceLabels(): array
|
||||||
|
{
|
||||||
|
return $this->droppedServiceLabels;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds an existing draft for a user and booking combination.
|
* Finds an existing draft for a user and booking combination.
|
||||||
*
|
*
|
||||||
@@ -129,6 +145,9 @@ class BookingEditDraftManager
|
|||||||
*/
|
*/
|
||||||
public function applyDraftToDto(BookingEditDraft $draft, BookingDto $dto, Travel $travel): bool
|
public function applyDraftToDto(BookingEditDraft $draft, BookingDto $dto, Travel $travel): bool
|
||||||
{
|
{
|
||||||
|
$this->droppedServiceLabels = [];
|
||||||
|
$droppedServiceLabels = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$formData = $draft->getFormData();
|
$formData = $draft->getFormData();
|
||||||
|
|
||||||
@@ -149,16 +168,25 @@ class BookingEditDraftManager
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->participantApplier->apply(
|
$droppedServiceLabels = [...$droppedServiceLabels, ...$this->participantApplier->apply(
|
||||||
$dto,
|
$dto,
|
||||||
$index,
|
$index,
|
||||||
$dto->participants[$index],
|
$dto->participants[$index],
|
||||||
$participantData,
|
$participantData,
|
||||||
$travel,
|
$travel,
|
||||||
);
|
)];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->droppedServiceLabels = array_values(array_unique($droppedServiceLabels));
|
||||||
|
|
||||||
|
if ([] !== $this->droppedServiceLabels) {
|
||||||
|
$this->logger->info('Dropped drafted services that are no longer bookable', [
|
||||||
|
'booking_id' => $draft->getBookingId(),
|
||||||
|
'services' => $this->droppedServiceLabels,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$this->logger->info('Applied draft to booking DTO', [
|
$this->logger->info('Applied draft to booking DTO', [
|
||||||
'booking_id' => $draft->getBookingId(),
|
'booking_id' => $draft->getBookingId(),
|
||||||
'draft_created_at' => $draft->getCreatedAt()->format('Y-m-d H:i:s'),
|
'draft_created_at' => $draft->getCreatedAt()->format('Y-m-d H:i:s'),
|
||||||
|
|||||||
@@ -24,14 +24,24 @@ use App\Form\Model\ParticipantDto;
|
|||||||
*/
|
*/
|
||||||
class BookingEditDraftMerger
|
class BookingEditDraftMerger
|
||||||
{
|
{
|
||||||
/** @param array<string, mixed> $data */
|
public function __construct(
|
||||||
|
private readonly ServiceAvailabilityCalculator $serviceAvailabilityCalculator,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*
|
||||||
|
* @return list<string> Labels of drafted services dropped because they are no longer bookable
|
||||||
|
*/
|
||||||
public function apply(
|
public function apply(
|
||||||
BookingDto $bookingDto,
|
BookingDto $bookingDto,
|
||||||
int $participantIndex,
|
int $participantIndex,
|
||||||
ParticipantDto $participant,
|
ParticipantDto $participant,
|
||||||
array $data,
|
array $data,
|
||||||
Travel $travel,
|
Travel $travel,
|
||||||
): void {
|
): array {
|
||||||
|
$droppedServiceLabels = [];
|
||||||
$canApplyPersonalDataDraft = $this->canApplyPersonalDataDraft($bookingDto, $participantIndex, $participant);
|
$canApplyPersonalDataDraft = $this->canApplyPersonalDataDraft($bookingDto, $participantIndex, $participant);
|
||||||
|
|
||||||
// Personal data
|
// Personal data
|
||||||
@@ -68,10 +78,19 @@ class BookingEditDraftMerger
|
|||||||
|
|
||||||
// Service selections (gated per-category by travel mutability flags)
|
// Service selections (gated per-category by travel mutability flags)
|
||||||
if (isset($data['services']) && true === is_array($data['services'])) {
|
if (isset($data['services']) && true === is_array($data['services'])) {
|
||||||
$this->applyServiceSelections($participant, $data['services'], $travel);
|
$this->applyServiceSelections(
|
||||||
|
$participant,
|
||||||
|
$data['services'],
|
||||||
|
$travel,
|
||||||
|
$bookingDto,
|
||||||
|
$participantIndex,
|
||||||
|
$droppedServiceLabels
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$participant->normalizeLoadedData();
|
$participant->normalizeLoadedData();
|
||||||
|
|
||||||
|
return array_values(array_unique($droppedServiceLabels));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function canApplyPersonalDataDraft(BookingDto $bookingDto, int $participantIndex, ParticipantDto $participant): bool
|
private function canApplyPersonalDataDraft(BookingDto $bookingDto, int $participantIndex, ParticipantDto $participant): bool
|
||||||
@@ -205,16 +224,25 @@ class BookingEditDraftMerger
|
|||||||
* Multi-select fields (checkboxes) and booleans use overwrite strategy: draft values
|
* Multi-select fields (checkboxes) and booleans use overwrite strategy: draft values
|
||||||
* always replace API data, since users can intentionally clear these selections.
|
* always replace API data, since users can intentionally clear these selections.
|
||||||
*/
|
*/
|
||||||
/** @param array<string, mixed> $data */
|
/**
|
||||||
private function applyServiceSelections(ParticipantDto $participant, array $data, Travel $travel): void
|
* @param array<string, mixed> $data
|
||||||
{
|
* @param list<string> $droppedServiceLabels Collects what was dropped, by reference
|
||||||
|
*/
|
||||||
|
private function applyServiceSelections(
|
||||||
|
ParticipantDto $participant,
|
||||||
|
array $data,
|
||||||
|
Travel $travel,
|
||||||
|
BookingDto $bookingDto,
|
||||||
|
int $participantIndex,
|
||||||
|
array &$droppedServiceLabels,
|
||||||
|
): void {
|
||||||
// Additional services category — only apply draft data when services are mutable.
|
// Additional services category — only apply draft data when services are mutable.
|
||||||
// When immutable, the booking's current services must be preserved as-is to avoid
|
// When immutable, the booking's current services must be preserved as-is to avoid
|
||||||
// API rejection (stale draft data could differ from the locked booking state).
|
// API rejection (stale draft data could differ from the locked booking state).
|
||||||
if (true === $travel->additionalServicesMutable) {
|
if (true === $travel->additionalServicesMutable) {
|
||||||
// Ski pass (single service) - merge strategy: only apply if resolves to valid service
|
// Ski pass (single service) - merge strategy: only apply if resolves to valid service
|
||||||
if (true === array_key_exists('skiPass', $data) && null !== $data['skiPass']) {
|
if (true === array_key_exists('skiPass', $data) && null !== $data['skiPass']) {
|
||||||
$resolved = $this->resolveService($data['skiPass'], $travel->additionalServices);
|
$resolved = $this->resolveService($data['skiPass'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels);
|
||||||
if (null !== $resolved) {
|
if (null !== $resolved) {
|
||||||
$participant->skiPass = $resolved;
|
$participant->skiPass = $resolved;
|
||||||
}
|
}
|
||||||
@@ -222,17 +250,17 @@ class BookingEditDraftMerger
|
|||||||
|
|
||||||
// Courses (array) - overwrite strategy: user can deselect all
|
// Courses (array) - overwrite strategy: user can deselect all
|
||||||
if (true === array_key_exists('courses', $data) && true === is_array($data['courses'])) {
|
if (true === array_key_exists('courses', $data) && true === is_array($data['courses'])) {
|
||||||
$participant->courses = $this->resolveServiceArray($data['courses'], $travel->additionalServices);
|
$participant->courses = $this->resolveServiceArray($data['courses'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Board (array) - overwrite strategy: user can deselect all
|
// Board (array) - overwrite strategy: user can deselect all
|
||||||
if (true === array_key_exists('board', $data) && true === is_array($data['board'])) {
|
if (true === array_key_exists('board', $data) && true === is_array($data['board'])) {
|
||||||
$participant->board = $this->resolveServiceArray($data['board'], $travel->additionalServices);
|
$participant->board = $this->resolveServiceArray($data['board'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Veg (single service) - merge strategy: only apply if resolves to valid service
|
// Veg (single service) - merge strategy: only apply if resolves to valid service
|
||||||
if (true === array_key_exists('veg', $data) && null !== $data['veg']) {
|
if (true === array_key_exists('veg', $data) && null !== $data['veg']) {
|
||||||
$resolved = $this->resolveService($data['veg'], $travel->additionalServices);
|
$resolved = $this->resolveService($data['veg'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels);
|
||||||
if (null !== $resolved) {
|
if (null !== $resolved) {
|
||||||
$participant->veg = $resolved;
|
$participant->veg = $resolved;
|
||||||
}
|
}
|
||||||
@@ -240,12 +268,12 @@ class BookingEditDraftMerger
|
|||||||
|
|
||||||
// Rentals (array) - overwrite strategy: user can deselect all
|
// Rentals (array) - overwrite strategy: user can deselect all
|
||||||
if (true === array_key_exists('rentals', $data) && true === is_array($data['rentals'])) {
|
if (true === array_key_exists('rentals', $data) && true === is_array($data['rentals'])) {
|
||||||
$participant->rentals = $this->resolveServiceArray($data['rentals'], $travel->additionalServices);
|
$participant->rentals = $this->resolveServiceArray($data['rentals'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rental insurance (single) - merge strategy: only apply if resolves to valid service
|
// Rental insurance (single) - merge strategy: only apply if resolves to valid service
|
||||||
if (true === array_key_exists('rentalInsurance', $data) && null !== $data['rentalInsurance']) {
|
if (true === array_key_exists('rentalInsurance', $data) && null !== $data['rentalInsurance']) {
|
||||||
$resolved = $this->resolveService($data['rentalInsurance'], $travel->additionalServices);
|
$resolved = $this->resolveService($data['rentalInsurance'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels);
|
||||||
if (null !== $resolved) {
|
if (null !== $resolved) {
|
||||||
$participant->rentalInsurance = $resolved;
|
$participant->rentalInsurance = $resolved;
|
||||||
$participant->rentalInsuranceSelected = true;
|
$participant->rentalInsuranceSelected = true;
|
||||||
@@ -255,7 +283,7 @@ class BookingEditDraftMerger
|
|||||||
// Additional services (array) - overwrite strategy with mandatory service preservation
|
// Additional services (array) - overwrite strategy with mandatory service preservation
|
||||||
// User can deselect optional services, but mandatory services from API must be preserved
|
// User can deselect optional services, but mandatory services from API must be preserved
|
||||||
if (true === array_key_exists('additionalServices', $data) && true === is_array($data['additionalServices'])) {
|
if (true === array_key_exists('additionalServices', $data) && true === is_array($data['additionalServices'])) {
|
||||||
$resolvedFromDraft = $this->resolveServiceArray($data['additionalServices'], $travel->additionalServices);
|
$resolvedFromDraft = $this->resolveServiceArray($data['additionalServices'], $travel->additionalServices, $bookingDto, $participantIndex, $droppedServiceLabels);
|
||||||
$participant->additionalServices = $this->preserveMandatoryServices(
|
$participant->additionalServices = $this->preserveMandatoryServices(
|
||||||
$resolvedFromDraft,
|
$resolvedFromDraft,
|
||||||
$participant->additionalServices,
|
$participant->additionalServices,
|
||||||
@@ -268,7 +296,7 @@ class BookingEditDraftMerger
|
|||||||
if (true === $travel->transportationServicesMutable) {
|
if (true === $travel->transportationServicesMutable) {
|
||||||
// Transportation outbound (single) - merge strategy: only apply if resolves to valid service
|
// Transportation outbound (single) - merge strategy: only apply if resolves to valid service
|
||||||
if (true === array_key_exists('transportationOutbound', $data) && null !== $data['transportationOutbound']) {
|
if (true === array_key_exists('transportationOutbound', $data) && null !== $data['transportationOutbound']) {
|
||||||
$resolved = $this->resolveService($data['transportationOutbound'], $travel->transportationServices);
|
$resolved = $this->resolveService($data['transportationOutbound'], $travel->transportationServices, $bookingDto, $participantIndex, $droppedServiceLabels);
|
||||||
if (null !== $resolved) {
|
if (null !== $resolved) {
|
||||||
$participant->transportationOutbound = $resolved;
|
$participant->transportationOutbound = $resolved;
|
||||||
}
|
}
|
||||||
@@ -276,7 +304,7 @@ class BookingEditDraftMerger
|
|||||||
|
|
||||||
// Transportation inbound (single) - merge strategy: only apply if resolves to valid service
|
// Transportation inbound (single) - merge strategy: only apply if resolves to valid service
|
||||||
if (true === array_key_exists('transportationInbound', $data) && null !== $data['transportationInbound']) {
|
if (true === array_key_exists('transportationInbound', $data) && null !== $data['transportationInbound']) {
|
||||||
$resolved = $this->resolveService($data['transportationInbound'], $travel->transportationServices);
|
$resolved = $this->resolveService($data['transportationInbound'], $travel->transportationServices, $bookingDto, $participantIndex, $droppedServiceLabels);
|
||||||
if (null !== $resolved) {
|
if (null !== $resolved) {
|
||||||
$participant->transportationInbound = $resolved;
|
$participant->transportationInbound = $resolved;
|
||||||
}
|
}
|
||||||
@@ -368,37 +396,84 @@ class BookingEditDraftMerger
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, object> $services
|
* @param array<int, object> $services
|
||||||
|
* @param list<string> $droppedServiceLabels
|
||||||
*/
|
*/
|
||||||
private function resolveService(?int $serviceId, array $services): ?object
|
private function resolveService(
|
||||||
{
|
?int $serviceId,
|
||||||
|
array $services,
|
||||||
|
BookingDto $bookingDto,
|
||||||
|
int $participantIndex,
|
||||||
|
array &$droppedServiceLabels,
|
||||||
|
): ?object {
|
||||||
if (null === $serviceId) {
|
if (null === $serviceId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $services[$serviceId] ?? null;
|
$service = $services[$serviceId] ?? null;
|
||||||
|
|
||||||
|
if (null !== $service && true === $this->isDroppedFromDraft($service, $bookingDto, $participantIndex)) {
|
||||||
|
$droppedServiceLabels[] = (string) $service->label;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $service;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, int> $serviceIds
|
* @param array<int, int> $serviceIds
|
||||||
* @param array<int, object> $services
|
* @param array<int, object> $services
|
||||||
|
* @param list<string> $droppedServiceLabels
|
||||||
*
|
*
|
||||||
* @return array<int, object>
|
* @return array<int, object>
|
||||||
*/
|
*/
|
||||||
private function resolveServiceArray(array $serviceIds, array $services): array
|
private function resolveServiceArray(
|
||||||
{
|
array $serviceIds,
|
||||||
|
array $services,
|
||||||
|
BookingDto $bookingDto,
|
||||||
|
int $participantIndex,
|
||||||
|
array &$droppedServiceLabels,
|
||||||
|
): array {
|
||||||
$resolved = [];
|
$resolved = [];
|
||||||
$addedIds = [];
|
$addedIds = [];
|
||||||
|
|
||||||
foreach ($serviceIds as $serviceId) {
|
foreach ($serviceIds as $serviceId) {
|
||||||
if (isset($services[$serviceId]) && false === isset($addedIds[$serviceId])) {
|
if (false === isset($services[$serviceId]) || true === isset($addedIds[$serviceId])) {
|
||||||
$resolved[] = $services[$serviceId];
|
continue;
|
||||||
$addedIds[$serviceId] = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$service = $services[$serviceId];
|
||||||
|
|
||||||
|
if (true === $this->isDroppedFromDraft($service, $bookingDto, $participantIndex)) {
|
||||||
|
$droppedServiceLabels[] = (string) $service->label;
|
||||||
|
$addedIds[$serviceId] = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolved[] = $service;
|
||||||
|
$addedIds[$serviceId] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $resolved;
|
return $resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a drafted selection must not be restored.
|
||||||
|
*
|
||||||
|
* A draft can be weeks old. Restoring a selection for a service that has since sold out or
|
||||||
|
* moved to 'Anfrage' is what makes a stuck booking stuck: the stale choice is re-applied on
|
||||||
|
* every re-entry and BusPro rejects the whole update again. Services the participant already
|
||||||
|
* holds are carved out by the availability rule itself, so they still restore.
|
||||||
|
*/
|
||||||
|
private function isDroppedFromDraft(object $service, BookingDto $bookingDto, int $participantIndex): bool
|
||||||
|
{
|
||||||
|
if (false === $service instanceof Service || null === $service->id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->serviceAvailabilityCalculator->isServiceUnavailable($service->id, $bookingDto, $participantIndex);
|
||||||
|
}
|
||||||
|
|
||||||
private function resolvePickup(?int $pickupId, Travel $travel): ?object
|
private function resolvePickup(?int $pickupId, Travel $travel): ?object
|
||||||
{
|
{
|
||||||
if (null === $pickupId) {
|
if (null === $pickupId) {
|
||||||
|
|||||||
@@ -24,9 +24,72 @@ class BookingEditSubmitGuard
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly BookingDataProcessor $bookingDataProcessor,
|
private readonly BookingDataProcessor $bookingDataProcessor,
|
||||||
|
private readonly ServiceAvailabilityCalculator $serviceAvailabilityCalculator,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverts selections of services that are no longer bookable.
|
||||||
|
*
|
||||||
|
* The read-only state in the participant form is presentation only, so a stale session or
|
||||||
|
* a replayed POST can still carry a service that has since sold out or moved to 'Anfrage'.
|
||||||
|
* Sending one makes BusPro reject the entire update - every participant's changes with it -
|
||||||
|
* so the selection is reverted to what the live booking already holds.
|
||||||
|
*
|
||||||
|
* Services the participant already holds are never touched: the availability rule carves
|
||||||
|
* them out, and withdrawing one would break the Leistung/Teilnehmer counts.
|
||||||
|
*
|
||||||
|
* Expects $workingDto->booking to be the fresh booking, as the availability rule reads its
|
||||||
|
* already-held carve-out from there.
|
||||||
|
*
|
||||||
|
* @return list<string> Labels of the services that were reverted, for user feedback
|
||||||
|
*/
|
||||||
|
public function revertUnbookableServiceAdditions(BookingDto $workingDto, Booking $freshBooking): array
|
||||||
|
{
|
||||||
|
$baselineDto = $this->bookingDataProcessor->createBookingDtoFromBooking($freshBooking, $workingDto->travel, $workingDto->isInternalAgencyBooking());
|
||||||
|
$revertedLabels = [];
|
||||||
|
|
||||||
|
foreach ($workingDto->participants as $index => $participant) {
|
||||||
|
$baseline = $baselineDto->participants[$index] ?? null;
|
||||||
|
if (null === $baseline) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$isBlocked = fn (?Service $service): bool => null !== $service
|
||||||
|
&& null !== $service->id
|
||||||
|
&& $this->serviceAvailabilityCalculator->isServiceUnavailable($service->id, $workingDto, $index);
|
||||||
|
|
||||||
|
// Multi-selection fields: drop the blocked additions, keep everything else
|
||||||
|
foreach (['courses', 'additionalServices', 'board', 'rentals'] as $field) {
|
||||||
|
$kept = [];
|
||||||
|
foreach ($participant->{$field} as $service) {
|
||||||
|
if (true === $isBlocked($service)) {
|
||||||
|
$revertedLabels[] = (string) $service->label;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$kept[] = $service;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($kept) !== count($participant->{$field})) {
|
||||||
|
$participant->{$field} = $kept;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-selection fields: fall back to what the booking already holds rather than
|
||||||
|
// clearing, so a required field does not end up empty
|
||||||
|
foreach (['skiPass', 'veg', 'transportationOutbound', 'transportationInbound', 'parkingService'] as $field) {
|
||||||
|
if (false === $isBlocked($participant->{$field})) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$revertedLabels[] = (string) $participant->{$field}->label;
|
||||||
|
$participant->{$field} = $baseline->{$field};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($revertedLabels));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reverts immutable category changes to fresh booking values.
|
* Reverts immutable category changes to fresh booking values.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -63,11 +63,32 @@ class BookingEditSubmitter
|
|||||||
$this->travelDataService->patchMutability($bookingDto->travel, $mutableData);
|
$this->travelDataService->patchMutability($bookingDto->travel, $mutableData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The guard checks service status and contingent, so it needs the live figures rather
|
||||||
|
// than whatever was cached when the edit session started.
|
||||||
|
$availabilities = $this->travelDataService->getAvailabilityData(
|
||||||
|
$freshBookingData->dateId,
|
||||||
|
cached: true,
|
||||||
|
forceRefresh: true
|
||||||
|
);
|
||||||
|
if (null !== $availabilities) {
|
||||||
|
$this->travelDataService->patchAvailabilities($bookingDto->travel, $availabilities);
|
||||||
|
}
|
||||||
|
|
||||||
$immutableChangesReverted = $this->submitGuard->reconcileImmutableCategories($bookingDto, $freshBookingData);
|
$immutableChangesReverted = $this->submitGuard->reconcileImmutableCategories($bookingDto, $freshBookingData);
|
||||||
if (true === $immutableChangesReverted) {
|
$unbookableServicesReverted = $this->submitGuard->revertUnbookableServiceAdditions($bookingDto, $freshBookingData);
|
||||||
|
|
||||||
|
if (true === $immutableChangesReverted || [] !== $unbookableServicesReverted) {
|
||||||
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT);
|
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ([] !== $unbookableServicesReverted) {
|
||||||
|
$this->logger->warning('Reverted selections of services that are no longer bookable', [
|
||||||
|
'email' => $email,
|
||||||
|
'booking_id' => $bookingId,
|
||||||
|
'services' => $unbookableServicesReverted,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$response = $this->apiClient->updateBooking($bookingDto, true);
|
$response = $this->apiClient->updateBooking($bookingDto, true);
|
||||||
if ($response instanceof Notification) {
|
if ($response instanceof Notification) {
|
||||||
@@ -83,6 +104,7 @@ class BookingEditSubmitter
|
|||||||
: BookingEditSubmissionResult::STATUS_NOTIFICATION_INFO,
|
: BookingEditSubmissionResult::STATUS_NOTIFICATION_INFO,
|
||||||
$response->message,
|
$response->message,
|
||||||
$immutableChangesReverted,
|
$immutableChangesReverted,
|
||||||
|
$unbookableServicesReverted,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +121,7 @@ class BookingEditSubmitter
|
|||||||
BookingEditSubmissionResult::STATUS_SUCCESS,
|
BookingEditSubmissionResult::STATUS_SUCCESS,
|
||||||
null,
|
null,
|
||||||
$immutableChangesReverted,
|
$immutableChangesReverted,
|
||||||
|
$unbookableServicesReverted,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +136,7 @@ class BookingEditSubmitter
|
|||||||
BookingEditSubmissionResult::STATUS_UNSUCCESSFUL,
|
BookingEditSubmissionResult::STATUS_UNSUCCESSFUL,
|
||||||
$response->status ?? 'Buchung konnte nicht aktualisiert werden',
|
$response->status ?? 'Buchung konnte nicht aktualisiert werden',
|
||||||
$immutableChangesReverted,
|
$immutableChangesReverted,
|
||||||
|
$unbookableServicesReverted,
|
||||||
);
|
);
|
||||||
} catch (TimeoutException) {
|
} catch (TimeoutException) {
|
||||||
$this->logger->error('Booking update timeout', [
|
$this->logger->error('Booking update timeout', [
|
||||||
@@ -124,12 +148,14 @@ class BookingEditSubmitter
|
|||||||
BookingEditSubmissionResult::STATUS_TIMEOUT,
|
BookingEditSubmissionResult::STATUS_TIMEOUT,
|
||||||
null,
|
null,
|
||||||
$immutableChangesReverted,
|
$immutableChangesReverted,
|
||||||
|
$unbookableServicesReverted,
|
||||||
);
|
);
|
||||||
} catch (ApiClientException) {
|
} catch (ApiClientException) {
|
||||||
return new BookingEditSubmissionResult(
|
return new BookingEditSubmissionResult(
|
||||||
BookingEditSubmissionResult::STATUS_API_CLIENT_ERROR,
|
BookingEditSubmissionResult::STATUS_API_CLIENT_ERROR,
|
||||||
null,
|
null,
|
||||||
$immutableChangesReverted,
|
$immutableChangesReverted,
|
||||||
|
$unbookableServicesReverted,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,15 @@ class DatabaseAnonymizer
|
|||||||
return $report;
|
return $report;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looked up by email alone, never by name: resolveIdentity() would register a name alias,
|
||||||
|
* and two accounts of the same person — an intended state here — would then collapse onto
|
||||||
|
* one synthetic identity and so onto one synthetic email, which User::$email is unique on.
|
||||||
|
*
|
||||||
|
* The names are only replaced where one is already set. Most accounts carry none until the
|
||||||
|
* BusPro import fills them in, and minting a name for those would turn an incomplete profile
|
||||||
|
* into one that looks complete while profileComplete still says otherwise.
|
||||||
|
*/
|
||||||
public function anonymizeUser(User $user): void
|
public function anonymizeUser(User $user): void
|
||||||
{
|
{
|
||||||
$identity = $this->resolveIdentity(
|
$identity = $this->resolveIdentity(
|
||||||
@@ -104,6 +113,14 @@ class DatabaseAnonymizer
|
|||||||
);
|
);
|
||||||
|
|
||||||
$user->setEmail($identity['email']);
|
$user->setEmail($identity['email']);
|
||||||
|
|
||||||
|
if ($this->hasReplaceableValue($user->getFirstName())) {
|
||||||
|
$user->setFirstName($identity['firstName']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->hasReplaceableValue($user->getLastName())) {
|
||||||
|
$user->setLastName($identity['lastName']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function anonymizeNewsletterConsent(NewsletterConsent $consent): void
|
public function anonymizeNewsletterConsent(NewsletterConsent $consent): void
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ class ServiceAvailabilityCalculator
|
|||||||
*
|
*
|
||||||
* Delegates to isServiceUnavailable() so that both entry points share one rule set.
|
* Delegates to isServiceUnavailable() so that both entry points share one rule set.
|
||||||
*
|
*
|
||||||
|
* Never empties a non-empty choice group on the on-request rule alone: on some termine
|
||||||
|
* every outbound transport option or every ski pass is 'Anfrage', and an empty required
|
||||||
|
* group makes the travel unbookable - ParticipantEligibilityChecker reads "offered but
|
||||||
|
* none selectable" as the participant being unable to travel. Sold-out and Buchungsstop
|
||||||
|
* still empty a group, preserving the pre-existing semantics. Inert in create mode, where
|
||||||
|
* the on-request rule cannot fire.
|
||||||
|
*
|
||||||
* @param array<int, Service> $services Array of Service objects to filter
|
* @param array<int, Service> $services Array of Service objects to filter
|
||||||
* @param BookingDto $bookingDto The booking data with participant selections
|
* @param BookingDto $bookingDto The booking data with participant selections
|
||||||
* @param int $participantIndex The index of the participant currently filling the form
|
* @param int $participantIndex The index of the participant currently filling the form
|
||||||
@@ -63,7 +70,7 @@ class ServiceAvailabilityCalculator
|
|||||||
*/
|
*/
|
||||||
public function filterAvailableServices(array $services, BookingDto $bookingDto, int $participantIndex): array
|
public function filterAvailableServices(array $services, BookingDto $bookingDto, int $participantIndex): array
|
||||||
{
|
{
|
||||||
return array_filter($services, function (Service $service) use ($bookingDto, $participantIndex) {
|
$filtered = array_filter($services, function (Service $service) use ($bookingDto, $participantIndex) {
|
||||||
// Services without an ID cannot be resolved against travel data - treat as available
|
// Services without an ID cannot be resolved against travel data - treat as available
|
||||||
if (null === $service->id) {
|
if (null === $service->id) {
|
||||||
return true;
|
return true;
|
||||||
@@ -71,6 +78,17 @@ class ServiceAvailabilityCalculator
|
|||||||
|
|
||||||
return false === $this->isServiceUnavailable($service->id, $bookingDto, $participantIndex);
|
return false === $this->isServiceUnavailable($service->id, $bookingDto, $participantIndex);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if ([] !== $filtered || [] === $services) {
|
||||||
|
return $filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_filter(
|
||||||
|
$services,
|
||||||
|
fn (Service $service): bool => $this->isBlockedByOnRequestStatus($service, $bookingDto)
|
||||||
|
&& Constants::STATUS_BLOCKED !== $service->status
|
||||||
|
&& false === $this->isContingentExhausted($service, $bookingDto, $participantIndex)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -80,6 +98,16 @@ class ServiceAvailabilityCalculator
|
|||||||
* contingent is exhausted, either at the API level or through selections made by the
|
* contingent is exhausted, either at the API level or through selections made by the
|
||||||
* other participants of the current booking.
|
* other participants of the current booking.
|
||||||
*
|
*
|
||||||
|
* In edit mode one further rule applies: a service that is only available on request
|
||||||
|
* (Anfrage) cannot be *acquired*. The participants of an existing booking are fixed at
|
||||||
|
* status 'F', and BusPro derives a Leistung's status from the travel data and refuses to
|
||||||
|
* attach it when the two differ ("Status der Leistung (A) ist unterschiedlich zum Status
|
||||||
|
* des Teilnehmers (F)"), rejecting the whole update. The create flow has no such problem,
|
||||||
|
* because the booking status can still move to 'A' there - hence the mode precondition.
|
||||||
|
*
|
||||||
|
* The rule is asymmetric: a service the participant already holds in the live booking
|
||||||
|
* stays available, so it can be kept and re-sent.
|
||||||
|
*
|
||||||
* @param int $serviceId The ID of the service to check
|
* @param int $serviceId The ID of the service to check
|
||||||
* @param BookingDto $bookingDto The booking data with participant selections
|
* @param BookingDto $bookingDto The booking data with participant selections
|
||||||
* @param int $participantIndex The index of the participant currently filling the form
|
* @param int $participantIndex The index of the participant currently filling the form
|
||||||
@@ -103,11 +131,32 @@ class ServiceAvailabilityCalculator
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A service the participant already holds is always keepable, whatever its state now
|
||||||
|
if (true === $this->isServiceHeldByParticipant($service, $bookingDto, $participantIndex)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// A booking stop blocks the service regardless of its contingent
|
// A booking stop blocks the service regardless of its contingent
|
||||||
if (Constants::STATUS_BLOCKED === $service->status) {
|
if (Constants::STATUS_BLOCKED === $service->status) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On request cannot be added to a participant whose status is already fixed
|
||||||
|
if (true === $this->isBlockedByOnRequestStatus($service, $bookingDto)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->isContingentExhausted($service, $bookingDto, $participantIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a service has no contingent left.
|
||||||
|
*
|
||||||
|
* Covers both the API-level figure and the seats taken by the other participants of the
|
||||||
|
* current booking.
|
||||||
|
*/
|
||||||
|
private function isContingentExhausted(Service $service, BookingDto $bookingDto, int $participantIndex): bool
|
||||||
|
{
|
||||||
// If no availability tracking (null), service is unlimited and available
|
// If no availability tracking (null), service is unlimited and available
|
||||||
if (null === $service->available) {
|
if (null === $service->available) {
|
||||||
return false;
|
return false;
|
||||||
@@ -121,7 +170,50 @@ class ServiceAvailabilityCalculator
|
|||||||
// For services with positive availability, calculate remaining based on booking selections
|
// For services with positive availability, calculate remaining based on booking selections
|
||||||
$remainingAvailability = $this->calculateRemainingAvailability($bookingDto, $participantIndex);
|
$remainingAvailability = $this->calculateRemainingAvailability($bookingDto, $participantIndex);
|
||||||
|
|
||||||
return ($remainingAvailability[$serviceId] ?? $service->available) <= 0;
|
return ($remainingAvailability[$service->id] ?? $service->available) <= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether the on-request rule blocks a service.
|
||||||
|
*
|
||||||
|
* Only applies in edit mode, and never to services the form would then be unable to
|
||||||
|
* satisfy: auto-booked services and mandatory services outside the transport categories
|
||||||
|
* are required by MandatoryAdditionalServicesSelectedValidator, so blocking one makes the
|
||||||
|
* form unsatisfiable. Mandatory transport legs are deliberately not exempt - there
|
||||||
|
* pflicht="True" means "pick one of this group" rather than "compulsory", and exempting
|
||||||
|
* them would exempt the bus legs this rule exists for.
|
||||||
|
*/
|
||||||
|
private function isBlockedByOnRequestStatus(Service $service, BookingDto $bookingDto): bool
|
||||||
|
{
|
||||||
|
if (BookingDto::MODE_EDIT !== $bookingDto->getMode()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Constants::STATUS_ON_REQUEST !== $service->status) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (true === $service->autoBook) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false === ($service->mandatory && Constants::CATEGORY_TRANSPORTATION !== $service->category);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether the participant already holds the service in the live booking.
|
||||||
|
*
|
||||||
|
* Reads the BusPro-side baseline (BookingDto::$booking), never the working selection,
|
||||||
|
* which already carries whatever the customer just picked. Always false in create mode,
|
||||||
|
* where there is no booking yet.
|
||||||
|
*/
|
||||||
|
private function isServiceHeldByParticipant(Service $service, BookingDto $bookingDto, int $participantIndex): bool
|
||||||
|
{
|
||||||
|
if (null === $bookingDto->booking || null === $service->id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $bookingDto->booking->hasServiceForParticipant($participantIndex, $service->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{% extends 'layout.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Kein Zugriff{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="px-4 lg:px-8 py-8 lg:py-16 max-w-2xl">
|
||||||
|
<h1 class="text-white uppercase pb-4 mb-8 border-b border-primary-bg/40">
|
||||||
|
Kein Zugriff
|
||||||
|
</h1>
|
||||||
|
<p class="mb-4 text-white">
|
||||||
|
Du bist bei MyE&P angemeldet, aber dein Konto ist nicht für
|
||||||
|
<strong>{{ client }}</strong> freigeschaltet.
|
||||||
|
</p>
|
||||||
|
<p class="mb-8 text-white">
|
||||||
|
Das liegt an den Berechtigungen deines Kontos, nicht an deinem Passwort –
|
||||||
|
ein erneuter Login ändert daran nichts. Wenn du hier Zugriff brauchst, wende
|
||||||
|
dich bitte an dein E&P-Team.
|
||||||
|
</p>
|
||||||
|
<a href="{{ path('app_account') }}" class="button button--primary">
|
||||||
|
Zurück zu MyE&P
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -6,6 +6,7 @@ namespace App\Tests\BusProNet;
|
|||||||
|
|
||||||
use App\BusProNet\ApiClient;
|
use App\BusProNet\ApiClient;
|
||||||
use App\BusProNet\DataProcessor\BookingDataProcessor;
|
use App\BusProNet\DataProcessor\BookingDataProcessor;
|
||||||
|
use App\BusProNet\Exception\ApiClientException;
|
||||||
use App\BusProNet\Exception\ImmediateConnectionCloseException;
|
use App\BusProNet\Exception\ImmediateConnectionCloseException;
|
||||||
use App\BusProNet\XmlParser\ApiResponseParser;
|
use App\BusProNet\XmlParser\ApiResponseParser;
|
||||||
use App\Service\RequestIdGenerator;
|
use App\Service\RequestIdGenerator;
|
||||||
@@ -70,7 +71,7 @@ class ApiClientReceiveTest extends TestCase
|
|||||||
$this->invokeReceive($stream);
|
$this->invokeReceive($stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testNonEmptyResponseReturnsData(): void
|
public function testNonEmptyResponseReturnsPayloadWithoutTheLengthHeader(): void
|
||||||
{
|
{
|
||||||
$stream = fopen('php://memory', 'r+');
|
$stream = fopen('php://memory', 'r+');
|
||||||
fwrite($stream, '0000000005Hello');
|
fwrite($stream, '0000000005Hello');
|
||||||
@@ -80,7 +81,56 @@ class ApiClientReceiveTest extends TestCase
|
|||||||
|
|
||||||
$result = $this->invokeReceive($stream);
|
$result = $this->invokeReceive($stream);
|
||||||
|
|
||||||
self::assertSame('0000000005Hello', $result);
|
self::assertSame('Hello', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A peer closing mid-stream used to yield a silently shortened payload, which only
|
||||||
|
* surfaced later as an unexplained parse failure. The announced length catches it here.
|
||||||
|
*/
|
||||||
|
public function testTruncatedResponseThrows(): void
|
||||||
|
{
|
||||||
|
$stream = fopen('php://memory', 'r+');
|
||||||
|
fwrite($stream, '0000000050Hello');
|
||||||
|
rewind($stream);
|
||||||
|
|
||||||
|
$this->setOperationStartTime();
|
||||||
|
$this->setSelectedPort(9000);
|
||||||
|
|
||||||
|
$this->expectException(ApiClientException::class);
|
||||||
|
$this->expectExceptionMessage('Truncated response, got 5 of 50 announced bytes');
|
||||||
|
|
||||||
|
$this->invokeReceive($stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testResponseShorterThanTheLengthHeaderThrows(): void
|
||||||
|
{
|
||||||
|
$stream = fopen('php://memory', 'r+');
|
||||||
|
fwrite($stream, '00000');
|
||||||
|
rewind($stream);
|
||||||
|
|
||||||
|
$this->setOperationStartTime();
|
||||||
|
$this->setSelectedPort(9000);
|
||||||
|
|
||||||
|
$this->expectException(ApiClientException::class);
|
||||||
|
$this->expectExceptionMessage('Incomplete response header, got 5 of 10 bytes');
|
||||||
|
|
||||||
|
$this->invokeReceive($stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHeaderOnlyResponseThrows(): void
|
||||||
|
{
|
||||||
|
$stream = fopen('php://memory', 'r+');
|
||||||
|
fwrite($stream, '0000000000');
|
||||||
|
rewind($stream);
|
||||||
|
|
||||||
|
$this->setOperationStartTime();
|
||||||
|
$this->setSelectedPort(9000);
|
||||||
|
|
||||||
|
$this->expectException(ApiClientException::class);
|
||||||
|
$this->expectExceptionMessage('Response announced an empty body (header "0000000000")');
|
||||||
|
|
||||||
|
$this->invokeReceive($stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function invokeReceive($socket): string
|
private function invokeReceive($socket): string
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\BusProNet\XmlLoader;
|
||||||
|
|
||||||
|
use App\BusProNet\Constants;
|
||||||
|
use App\BusProNet\Model\Availability;
|
||||||
|
use App\BusProNet\Model\Service;
|
||||||
|
use App\BusProNet\Model\ServiceAvailabilityResponse;
|
||||||
|
use App\BusProNet\Model\Travel;
|
||||||
|
use App\BusProNet\XmlLoader\HotelLoader;
|
||||||
|
use App\BusProNet\XmlLoader\TravelLoader;
|
||||||
|
use App\BusProNet\XmlParser\TravelParser;
|
||||||
|
use League\Flysystem\FilesystemOperator;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Contracts\Cache\CacheInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that patchAvailabilities() carries the live service status, not just the contingent.
|
||||||
|
*
|
||||||
|
* When a Leistung's contingent runs out BusPro moves it to 'Anfrage' without necessarily
|
||||||
|
* reporting frei="0", so the status is the only reliable signal. Dropping it meant every
|
||||||
|
* availability rule read the status from the nightly XML export instead.
|
||||||
|
*/
|
||||||
|
class TravelLoaderAvailabilityTest extends TestCase
|
||||||
|
{
|
||||||
|
private TravelLoader $loader;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->loader = new TravelLoader(
|
||||||
|
$this->createStub(HotelLoader::class),
|
||||||
|
$this->createStub(TravelParser::class),
|
||||||
|
'https://example.test',
|
||||||
|
$this->createStub(CacheInterface::class),
|
||||||
|
$this->createStub(FilesystemOperator::class),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPatchesStatusAlongsideAvailability(): void
|
||||||
|
{
|
||||||
|
$service = $this->createService(60, Constants::STATUS_AVAILABLE, 10);
|
||||||
|
$travel = $this->createTravel([$service]);
|
||||||
|
|
||||||
|
$this->loader->patchAvailabilities(
|
||||||
|
$travel,
|
||||||
|
$this->createResponse([$this->createAvailability(60, Constants::STATUS_ON_REQUEST, 3)])
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(Constants::STATUS_ON_REQUEST, $service->status);
|
||||||
|
$this->assertSame(3, $service->available);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testKeepsExportStatusWhenTheResponseOmitsIt(): void
|
||||||
|
{
|
||||||
|
$service = $this->createService(60, Constants::STATUS_AVAILABLE, 10);
|
||||||
|
$travel = $this->createTravel([$service]);
|
||||||
|
|
||||||
|
$this->loader->patchAvailabilities(
|
||||||
|
$travel,
|
||||||
|
$this->createResponse([$this->createAvailability(60, null, 3)])
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(Constants::STATUS_AVAILABLE, $service->status);
|
||||||
|
$this->assertSame(3, $service->available);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testKeepsExportStatusWhenTheResponseCarriesABlankOne(): void
|
||||||
|
{
|
||||||
|
$service = $this->createService(60, Constants::STATUS_AVAILABLE, 10);
|
||||||
|
$travel = $this->createTravel([$service]);
|
||||||
|
|
||||||
|
$this->loader->patchAvailabilities(
|
||||||
|
$travel,
|
||||||
|
$this->createResponse([$this->createAvailability(60, ' ', 3)])
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(Constants::STATUS_AVAILABLE, $service->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPatchesTransportationServicesToo(): void
|
||||||
|
{
|
||||||
|
$service = $this->createService(80, Constants::STATUS_AVAILABLE, 10);
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->transportationServices = [80 => $service];
|
||||||
|
|
||||||
|
$this->loader->patchAvailabilities(
|
||||||
|
$travel,
|
||||||
|
$this->createResponse([$this->createAvailability(80, Constants::STATUS_BLOCKED, 0)])
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(Constants::STATUS_BLOCKED, $service->status);
|
||||||
|
$this->assertSame(0, $service->available);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<int, Service> $services */
|
||||||
|
private function createTravel(array $services): Travel
|
||||||
|
{
|
||||||
|
$travel = new Travel();
|
||||||
|
|
||||||
|
$indexed = [];
|
||||||
|
foreach ($services as $service) {
|
||||||
|
$indexed[$service->id] = $service;
|
||||||
|
}
|
||||||
|
$travel->additionalServices = $indexed;
|
||||||
|
|
||||||
|
return $travel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createService(int $id, string $status, ?int $available): Service
|
||||||
|
{
|
||||||
|
$service = new Service();
|
||||||
|
$service->id = $id;
|
||||||
|
$service->status = $status;
|
||||||
|
$service->available = $available;
|
||||||
|
|
||||||
|
return $service;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createAvailability(int $serviceId, ?string $status, ?int $available): Availability
|
||||||
|
{
|
||||||
|
$availability = new Availability();
|
||||||
|
$availability->serviceId = $serviceId;
|
||||||
|
$availability->status = $status;
|
||||||
|
$availability->available = $available;
|
||||||
|
|
||||||
|
return $availability;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<int, Availability> $availabilities */
|
||||||
|
private function createResponse(array $availabilities): ServiceAvailabilityResponse
|
||||||
|
{
|
||||||
|
$indexed = [];
|
||||||
|
foreach ($availabilities as $availability) {
|
||||||
|
$indexed[$availability->serviceId] = $availability;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ServiceAvailabilityResponse($indexed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\BusProNet\XmlParser;
|
||||||
|
|
||||||
|
use App\BusProNet\ApiClient;
|
||||||
|
use App\BusProNet\Exception\ResponseParserException;
|
||||||
|
use App\BusProNet\XmlParser\ApiResponseParser;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A broken response used to collapse into either "Empty response received from server" —
|
||||||
|
* which is wrong for a malformed body — or an opaque "Unable to parse XML response".
|
||||||
|
* Each shape must now name itself, otherwise a production parse failure is not
|
||||||
|
* diagnosable after the fact.
|
||||||
|
*/
|
||||||
|
class ApiResponseParserFailureTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testEmptyResponse(): void
|
||||||
|
{
|
||||||
|
$this->expectException(ResponseParserException::class);
|
||||||
|
$this->expectExceptionMessage('empty response (0 bytes)');
|
||||||
|
|
||||||
|
(new ApiResponseParser())->parseXmlString(ApiClient::TYPE_NOTIFICATION, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTruncatedResponseNamesTheLibxmlReason(): void
|
||||||
|
{
|
||||||
|
$xml = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="HINWEIS"></satz><hinweis';
|
||||||
|
|
||||||
|
try {
|
||||||
|
(new ApiResponseParser())->parseXmlString(ApiClient::TYPE_NOTIFICATION, $xml);
|
||||||
|
$this->fail('Expected a ResponseParserException');
|
||||||
|
} catch (ResponseParserException $e) {
|
||||||
|
self::assertStringContainsString('('.strlen($xml).' bytes)', $e->getMessage());
|
||||||
|
self::assertStringContainsString('line 1', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWellFormedResponseOfAnUnknownType(): void
|
||||||
|
{
|
||||||
|
$xml = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="IRGENDWAS"></satz></ergebnis>';
|
||||||
|
|
||||||
|
$this->expectException(ResponseParserException::class);
|
||||||
|
$this->expectExceptionMessage('Unrecognised BusProNet response type "IRGENDWAS"');
|
||||||
|
|
||||||
|
(new ApiResponseParser())->parseXmlString(ApiClient::TYPE_NOTIFICATION, $xml);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnknownCustomerDataSubtype(): void
|
||||||
|
{
|
||||||
|
$xml = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="KUNDENKONTO"></satz><art>Irgendwas</art></ergebnis>';
|
||||||
|
|
||||||
|
$this->expectException(ResponseParserException::class);
|
||||||
|
$this->expectExceptionMessage('Unrecognised customer data subtype "Irgendwas"');
|
||||||
|
|
||||||
|
(new ApiResponseParser())->parseXmlString(ApiClient::TYPE_CUSTOMER_DATA, $xml);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDiagnosingDoesNotLeakLibxmlErrorState(): void
|
||||||
|
{
|
||||||
|
$previous = libxml_use_internal_errors(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
(new ApiResponseParser())->parseXmlString(ApiClient::TYPE_NOTIFICATION, '<ergebnis');
|
||||||
|
} catch (ResponseParserException) {
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertFalse(libxml_use_internal_errors(false));
|
||||||
|
self::assertSame([], libxml_get_errors());
|
||||||
|
} finally {
|
||||||
|
libxml_use_internal_errors($previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,26 @@ class CrmAttributesResponseParserTest extends TestCase
|
|||||||
self::assertContains('ROLE_CUSTOMER_EXPERT', $roles);
|
self::assertContains('ROLE_CUSTOMER_EXPERT', $roles);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ROLE_TEAM_ADMIN is not consumed here at all — it is exported through /api/userinfo and is
|
||||||
|
* myep-team's administrative role — so the claim is the only thing standing between the CRM
|
||||||
|
* selection and that application.
|
||||||
|
*/
|
||||||
|
public function testParseAssignsTeamAdminRoleWhenSelected(): void
|
||||||
|
{
|
||||||
|
$roles = $this->parseRoles($this->selectionXml(1484, true));
|
||||||
|
|
||||||
|
self::assertContains('ROLE_TEAM_ADMIN', $roles);
|
||||||
|
self::assertNotContains('ROLE_ADMIN', $roles, 'the "Admin" selection 1292 is a different one');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testParseAssignsNoTeamAdminRoleWhenNotSelected(): void
|
||||||
|
{
|
||||||
|
$roles = $this->parseRoles($this->selectionXml(1484, false));
|
||||||
|
|
||||||
|
self::assertNotContains('ROLE_TEAM_ADMIN', $roles);
|
||||||
|
}
|
||||||
|
|
||||||
public function testParseAssignsNoCustomerExpertRoleWhenNotSelected(): void
|
public function testParseAssignsNoCustomerExpertRoleWhenNotSelected(): void
|
||||||
{
|
{
|
||||||
$roles = $this->parseRoles($this->selectionXml(1483, false));
|
$roles = $this->parseRoles($this->selectionXml(1483, false));
|
||||||
|
|||||||
@@ -33,11 +33,11 @@ class ApproveRoleControllerTest extends TestCase
|
|||||||
|
|
||||||
$controller = new TestableApproveRoleController($entityManager, $this->createStub(LoggerInterface::class));
|
$controller = new TestableApproveRoleController($entityManager, $this->createStub(LoggerInterface::class));
|
||||||
|
|
||||||
$response = $controller->index($user, Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN'));
|
$response = $controller->index($user, Role::HOUSE_MANAGER, Request::create('/admin/user/1/approve/ROLE_HOUSE_MANAGER'));
|
||||||
|
|
||||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||||
self::assertSame('admin/user/modal_approve_role.html.twig', $controller->renderedView);
|
self::assertSame('admin/user/modal_approve_role.html.twig', $controller->renderedView);
|
||||||
self::assertSame([Role::TEAMER, Role::pending(Role::GROUPS_ADMIN)], Role::assignedOnly($user->getRoles()));
|
self::assertSame([Role::TEAMER, Role::pending(Role::HOUSE_MANAGER)], Role::assignedOnly($user->getRoles()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testPostGrantsTheRoleAndRedirectsTheBrowser(): void
|
public function testPostGrantsTheRoleAndRedirectsTheBrowser(): void
|
||||||
@@ -49,9 +49,9 @@ class ApproveRoleControllerTest extends TestCase
|
|||||||
|
|
||||||
$controller = new TestableApproveRoleController($entityManager, $this->createStub(LoggerInterface::class));
|
$controller = new TestableApproveRoleController($entityManager, $this->createStub(LoggerInterface::class));
|
||||||
|
|
||||||
$response = $controller->index($user, Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN', 'POST'));
|
$response = $controller->index($user, Role::HOUSE_MANAGER, Request::create('/admin/user/1/approve/ROLE_HOUSE_MANAGER', 'POST'));
|
||||||
|
|
||||||
self::assertSame([Role::TEAMER, Role::GROUPS_ADMIN], Role::assignedOnly($user->getRoles()));
|
self::assertSame([Role::TEAMER, Role::HOUSE_MANAGER], Role::assignedOnly($user->getRoles()));
|
||||||
self::assertTrue($response->headers->has('HX-Redirect'));
|
self::assertTrue($response->headers->has('HX-Redirect'));
|
||||||
self::assertSame(['success'], array_column($controller->flashes, 'type'));
|
self::assertSame(['success'], array_column($controller->flashes, 'type'));
|
||||||
}
|
}
|
||||||
@@ -94,9 +94,9 @@ class ApproveRoleControllerTest extends TestCase
|
|||||||
|
|
||||||
// Only ROLE_ADMIN needs a second pair of eyes — an approver already holds it, so the
|
// Only ROLE_ADMIN needs a second pair of eyes — an approver already holds it, so the
|
||||||
// rest grant less than they could grant themselves anyway.
|
// rest grant less than they could grant themselves anyway.
|
||||||
$controller->index($user, Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN', 'POST'));
|
$controller->index($user, Role::HOUSE_MANAGER, Request::create('/admin/user/1/approve/ROLE_HOUSE_MANAGER', 'POST'));
|
||||||
|
|
||||||
self::assertSame([Role::TEAMER, Role::GROUPS_ADMIN], Role::assignedOnly($user->getRoles()));
|
self::assertSame([Role::TEAMER, Role::HOUSE_MANAGER], Role::assignedOnly($user->getRoles()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testPostWithAnInvalidTokenIsDenied(): void
|
public function testPostWithAnInvalidTokenIsDenied(): void
|
||||||
@@ -108,7 +108,7 @@ class ApproveRoleControllerTest extends TestCase
|
|||||||
|
|
||||||
$this->expectException(AccessDeniedException::class);
|
$this->expectException(AccessDeniedException::class);
|
||||||
|
|
||||||
$controller->index($this->nominatedUser(), Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN', 'POST'));
|
$controller->index($this->nominatedUser(), Role::HOUSE_MANAGER, Request::create('/admin/user/1/approve/ROLE_HOUSE_MANAGER', 'POST'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testApprovingForYourselfReissuesTheSecurityToken(): void
|
public function testApprovingForYourselfReissuesTheSecurityToken(): void
|
||||||
@@ -125,12 +125,12 @@ class ApproveRoleControllerTest extends TestCase
|
|||||||
tokenStorage: $tokenStorage,
|
tokenStorage: $tokenStorage,
|
||||||
);
|
);
|
||||||
|
|
||||||
$controller->index($user, Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN', 'POST'));
|
$controller->index($user, Role::HOUSE_MANAGER, Request::create('/admin/user/1/approve/ROLE_HOUSE_MANAGER', 'POST'));
|
||||||
|
|
||||||
// Without this the next request would find the stored roles out of step with the token
|
// Without this the next request would find the stored roles out of step with the token
|
||||||
// and end the session, logging the approver out mid-action.
|
// and end the session, logging the approver out mid-action.
|
||||||
self::assertContains(Role::GROUPS_ADMIN, $tokenStorage->getToken()?->getRoleNames() ?? []);
|
self::assertContains(Role::HOUSE_MANAGER, $tokenStorage->getToken()?->getRoleNames() ?? []);
|
||||||
self::assertNotContains(Role::pending(Role::GROUPS_ADMIN), $tokenStorage->getToken()?->getRoleNames() ?? []);
|
self::assertNotContains(Role::pending(Role::HOUSE_MANAGER), $tokenStorage->getToken()?->getRoleNames() ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testApprovingForSomebodyElseLeavesYourOwnTokenAlone(): void
|
public function testApprovingForSomebodyElseLeavesYourOwnTokenAlone(): void
|
||||||
@@ -148,14 +148,14 @@ class ApproveRoleControllerTest extends TestCase
|
|||||||
tokenStorage: $tokenStorage,
|
tokenStorage: $tokenStorage,
|
||||||
);
|
);
|
||||||
|
|
||||||
$controller->index($other, Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN', 'POST'));
|
$controller->index($other, Role::HOUSE_MANAGER, Request::create('/admin/user/1/approve/ROLE_HOUSE_MANAGER', 'POST'));
|
||||||
|
|
||||||
self::assertSame($originalToken, $tokenStorage->getToken());
|
self::assertSame($originalToken, $tokenStorage->getToken());
|
||||||
}
|
}
|
||||||
|
|
||||||
private function nominatedUser(): User
|
private function nominatedUser(): User
|
||||||
{
|
{
|
||||||
return (new User('[email protected]'))->setRoles([Role::TEAMER, Role::pending(Role::GROUPS_ADMIN)]);
|
return (new User('[email protected]'))->setRoles([Role::TEAMER, Role::pending(Role::HOUSE_MANAGER)]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class ShowControllerTest extends TestCase
|
|||||||
$controller->index($this->nominatedUser(), $this->permissionsRequest());
|
$controller->index($this->nominatedUser(), $this->permissionsRequest());
|
||||||
|
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
[Role::MANAGER => 'Manager:in', Role::GROUPS_ADMIN => 'Preisrechner Admin'],
|
[Role::HOUSE_MANAGER => 'Hausleitung', Role::GROUPS_ADMIN => 'GRO Admin'],
|
||||||
$controller->parameters['approvableRoles'],
|
$controller->parameters['approvableRoles'],
|
||||||
);
|
);
|
||||||
self::assertSame([], $controller->parameters['selfRefusedRoles']);
|
self::assertSame([], $controller->parameters['selfRefusedRoles']);
|
||||||
@@ -29,14 +29,14 @@ class ShowControllerTest extends TestCase
|
|||||||
|
|
||||||
public function testWhatCannotBeSelfApprovedIsNotOffered(): void
|
public function testWhatCannotBeSelfApprovedIsNotOffered(): void
|
||||||
{
|
{
|
||||||
$user = (new User('admin@example.org'))->setRoles([Role::ADMIN, Role::pending(Role::ADMIN), Role::pending(Role::MANAGER)]);
|
$user = (new User('admin@ep-reisen.de'))->setRoles([Role::EMPLOYEE, Role::ADMIN, Role::pending(Role::ADMIN), Role::pending(Role::MANAGER)]);
|
||||||
|
|
||||||
$controller = new TestableShowController(currentUser: $user);
|
$controller = new TestableShowController(currentUser: $user);
|
||||||
|
|
||||||
$controller->index($user, $this->permissionsRequest());
|
$controller->index($user, $this->permissionsRequest());
|
||||||
|
|
||||||
// ROLE_ADMIN needs a second administrator, so no button leads into an access denied page.
|
// ROLE_ADMIN needs a second administrator, so no button leads into an access denied page.
|
||||||
self::assertSame([Role::MANAGER => 'Manager:in'], $controller->parameters['approvableRoles']);
|
self::assertSame([Role::MANAGER => 'Reisemanager:in'], $controller->parameters['approvableRoles']);
|
||||||
self::assertSame([Role::ADMIN => 'Administration'], $controller->parameters['selfRefusedRoles']);
|
self::assertSame([Role::ADMIN => 'Administration'], $controller->parameters['selfRefusedRoles']);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,8 +62,8 @@ class ShowControllerTest extends TestCase
|
|||||||
|
|
||||||
private function nominatedUser(): User
|
private function nominatedUser(): User
|
||||||
{
|
{
|
||||||
return (new User('teamer@example.org'))
|
return (new User('teamer@ep-reisen.de'))
|
||||||
->setRoles([Role::TEAMER, Role::pending(Role::MANAGER), Role::pending(Role::GROUPS_ADMIN)]);
|
->setRoles([Role::TEAMER, Role::EMPLOYEE, Role::pending(Role::HOUSE_MANAGER), Role::pending(Role::GROUPS_ADMIN)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Controller\Api;
|
||||||
|
|
||||||
|
use App\BusProNet\ApiClient;
|
||||||
|
use App\BusProNet\Model\PersonalData;
|
||||||
|
use App\Controller\Api\UserinfoController;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Security\Crypt;
|
||||||
|
use App\Security\Role;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\DependencyInjection\Container;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage;
|
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
|
||||||
|
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The claims are a published contract (docs/api-consumer-guide.md), and the identity claims in
|
||||||
|
* particular: a BusPro person may sign in under any address on its record, every one of those is
|
||||||
|
* a separate account with its own roles, and BusPro answers all of them identically. So what
|
||||||
|
* separates the accounts is asserted here rather than left to the consumers to discover.
|
||||||
|
*/
|
||||||
|
class UserinfoControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
private const LOGIN_EMAIL = '[email protected]';
|
||||||
|
private const CONTACT_EMAIL = '[email protected]';
|
||||||
|
private const UUID = '018f3c2a-7b4d-4e91-a3c5-6d2f8b1e4a07';
|
||||||
|
|
||||||
|
public function testSubAndEmailAreExportedWithoutAnyOptionalScope(): void
|
||||||
|
{
|
||||||
|
$claims = $this->claims();
|
||||||
|
|
||||||
|
self::assertSame(['sub', 'email'], array_keys($claims));
|
||||||
|
self::assertSame(self::UUID, $claims['sub']);
|
||||||
|
self::assertSame(self::LOGIN_EMAIL, $claims['email']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSubIsTheLocalAccountAndNotTheBusProPerson(): void
|
||||||
|
{
|
||||||
|
$claims = $this->claims(['ROLE_OAUTH2_ID']);
|
||||||
|
|
||||||
|
// The ids identify the human, the subject identifies the account. A second account of the
|
||||||
|
// same person reports these same two ids and must still be told apart.
|
||||||
|
self::assertSame(self::UUID, $claims['sub']);
|
||||||
|
self::assertSame(7, $claims['person_id']);
|
||||||
|
self::assertSame(9, $claims['address_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consumers store `sub` and match their own accounts on it, so it must not disclose how many
|
||||||
|
* accounts exist or let one account's identifier be guessed from the next. The primary key
|
||||||
|
* did both.
|
||||||
|
*/
|
||||||
|
public function testSubIsTheAccountUuidRatherThanItsPrimaryKey(): void
|
||||||
|
{
|
||||||
|
$claims = $this->claims();
|
||||||
|
|
||||||
|
self::assertNotSame('42', $claims['sub']);
|
||||||
|
self::assertMatchesRegularExpression(
|
||||||
|
'/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/',
|
||||||
|
$claims['sub']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEmailIsTheAuthenticatedAddressAndNotTheFirstContactOnTheBusProRecord(): void
|
||||||
|
{
|
||||||
|
$claims = $this->claims(['ROLE_OAUTH2_PROFILE']);
|
||||||
|
|
||||||
|
self::assertSame(self::LOGIN_EMAIL, $claims['email']);
|
||||||
|
self::assertSame(self::CONTACT_EMAIL, $claims['profile']['communication']['email']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRolesExportOnlyWhatIsEffective(): void
|
||||||
|
{
|
||||||
|
$claims = $this->claims(['ROLE_OAUTH2_ROLES'], [
|
||||||
|
Role::USER,
|
||||||
|
Role::EMPLOYEE,
|
||||||
|
Role::TEAMER,
|
||||||
|
Role::pending(Role::MANAGER),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ROLE_EMPLOYEE is exported because consumers must read staff status from here rather than
|
||||||
|
// re-deriving it from an email domain — which, given the two addresses, would disagree.
|
||||||
|
self::assertSame([Role::EMPLOYEE, Role::TEAMER], $claims['roles']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* myep-team reads its own administrative role out of this claim and revokes what the claim
|
||||||
|
* omits, so the export is asserted here rather than left to the role policy's own tests.
|
||||||
|
*/
|
||||||
|
public function testTeamAdminIsExportedOnceApproved(): void
|
||||||
|
{
|
||||||
|
$claims = $this->claims(['ROLE_OAUTH2_ROLES'], [
|
||||||
|
Role::USER,
|
||||||
|
Role::EMPLOYEE,
|
||||||
|
Role::TEAM_ADMIN,
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertSame([Role::EMPLOYEE, Role::TEAM_ADMIN], $claims['roles']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpstreamFailureIsReportedRatherThanAnswered(): void
|
||||||
|
{
|
||||||
|
$apiClient = $this->createStub(ApiClient::class);
|
||||||
|
$apiClient->method('getPersonalData')->willThrowException(new \App\BusProNet\Exception\ApiClientException('nope'));
|
||||||
|
|
||||||
|
$response = $this->call($apiClient, []);
|
||||||
|
|
||||||
|
self::assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $grantedScopeRoles
|
||||||
|
* @param string[] $roles
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function claims(array $grantedScopeRoles = [], array $roles = [Role::TEAMER]): array
|
||||||
|
{
|
||||||
|
$apiClient = $this->createStub(ApiClient::class);
|
||||||
|
$apiClient->method('getPersonalData')->willReturn($this->personalData());
|
||||||
|
|
||||||
|
$response = $this->call($apiClient, $grantedScopeRoles, $roles);
|
||||||
|
|
||||||
|
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||||
|
|
||||||
|
return json_decode((string) $response->getContent(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $grantedScopeRoles
|
||||||
|
* @param string[] $roles
|
||||||
|
*/
|
||||||
|
private function call(ApiClient $apiClient, array $grantedScopeRoles, array $roles = [Role::TEAMER]): Response
|
||||||
|
{
|
||||||
|
$crypt = $this->createStub(Crypt::class);
|
||||||
|
$crypt->method('decrypt')->willReturn('md5-of-the-password');
|
||||||
|
|
||||||
|
$controller = new UserinfoController($apiClient, $crypt);
|
||||||
|
$controller->setContainer($this->container($this->user($roles), $grantedScopeRoles));
|
||||||
|
|
||||||
|
return $controller->index();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $grantedScopeRoles
|
||||||
|
*/
|
||||||
|
private function container(User $user, array $grantedScopeRoles): Container
|
||||||
|
{
|
||||||
|
$tokenStorage = new TokenStorage();
|
||||||
|
$tokenStorage->setToken(new UsernamePasswordToken($user, 'api', $user->getRoles()));
|
||||||
|
|
||||||
|
$authorizationChecker = $this->createStub(AuthorizationCheckerInterface::class);
|
||||||
|
$authorizationChecker->method('isGranted')->willReturnCallback(
|
||||||
|
static fn (mixed $attribute): bool => \in_array($attribute, $grantedScopeRoles, true),
|
||||||
|
);
|
||||||
|
|
||||||
|
// No 'serializer' service registered, so AbstractController::json() falls back to
|
||||||
|
// JsonResponse — which is what this endpoint produces in production anyway.
|
||||||
|
$container = new Container();
|
||||||
|
$container->set('security.token_storage', $tokenStorage);
|
||||||
|
$container->set('security.authorization_checker', $authorizationChecker);
|
||||||
|
|
||||||
|
return $container;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $roles
|
||||||
|
*/
|
||||||
|
private function user(array $roles): User
|
||||||
|
{
|
||||||
|
$user = (new User(self::LOGIN_EMAIL))
|
||||||
|
->setPassword('encrypted')
|
||||||
|
->setRoles($roles)
|
||||||
|
;
|
||||||
|
|
||||||
|
// The id is generated by Doctrine and has no setter. It is deliberately not what `sub`
|
||||||
|
// exports -- see testSubIsTheAccountUuidRatherThanItsPrimaryKey.
|
||||||
|
$property = new \ReflectionProperty(User::class, 'id');
|
||||||
|
$property->setValue($user, 42);
|
||||||
|
|
||||||
|
// The constructor generates a fresh uuid; pin it so the claim can be asserted literally.
|
||||||
|
$property = new \ReflectionProperty(User::class, 'uuid');
|
||||||
|
$property->setValue($user, self::UUID);
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function personalData(): PersonalData
|
||||||
|
{
|
||||||
|
$personalData = new PersonalData();
|
||||||
|
$personalData->personId = 7;
|
||||||
|
$personalData->addressId = 9;
|
||||||
|
$personalData->firstName = 'Alex';
|
||||||
|
$personalData->name = 'Beispiel';
|
||||||
|
|
||||||
|
// Deliberately not the login address: BusPro returns the first contact row on the record,
|
||||||
|
// which is whichever of the person's addresses happens to come first.
|
||||||
|
$personalData->communication->email = self::CONTACT_EMAIL;
|
||||||
|
|
||||||
|
return $personalData;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ class PendingRoleApprovalsWidgetProviderTest extends TestCase
|
|||||||
$user = (new User('[email protected]'))
|
$user = (new User('[email protected]'))
|
||||||
->setFirstName('Rita')
|
->setFirstName('Rita')
|
||||||
->setLastName('Vorschlag')
|
->setLastName('Vorschlag')
|
||||||
->setRoles([Role::TEAMER, Role::pending(Role::GROUPS_ADMIN)])
|
->setRoles([Role::TEAMER, Role::pending(Role::HOUSE_MANAGER)])
|
||||||
;
|
;
|
||||||
|
|
||||||
$widget = $this->provider([$user])->build();
|
$widget = $this->provider([$user])->build();
|
||||||
@@ -27,7 +27,7 @@ class PendingRoleApprovalsWidgetProviderTest extends TestCase
|
|||||||
$this->assertNotNull($widget);
|
$this->assertNotNull($widget);
|
||||||
$this->assertCount(1, $widget->entries);
|
$this->assertCount(1, $widget->entries);
|
||||||
$this->assertStringContainsString('Rita Vorschlag', $widget->entries[0]->label);
|
$this->assertStringContainsString('Rita Vorschlag', $widget->entries[0]->label);
|
||||||
$this->assertStringContainsString(Role::labels()[Role::GROUPS_ADMIN], $widget->entries[0]->label);
|
$this->assertStringContainsString(Role::labels()[Role::HOUSE_MANAGER], $widget->entries[0]->label);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Entity;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class UserTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The uuid is what /api/userinfo exports as `sub`, and consumers key their own accounts on
|
||||||
|
* it, so an account has to carry one from the moment it exists -- there is no later step
|
||||||
|
* that could assign it.
|
||||||
|
*/
|
||||||
|
public function testAnAccountIsGivenAUuidOnCreation(): void
|
||||||
|
{
|
||||||
|
$user = new User('[email protected]');
|
||||||
|
|
||||||
|
self::assertMatchesRegularExpression(
|
||||||
|
'/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/',
|
||||||
|
$user->getUuid()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v4, not the framework's default v7: a v7 would sort by creation time and carry that
|
||||||
|
* timestamp in the claim, which is the disclosure the column exists to avoid.
|
||||||
|
*/
|
||||||
|
public function testUuidsAreRandomRatherThanOrdered(): void
|
||||||
|
{
|
||||||
|
$uuids = [];
|
||||||
|
for ($i = 0; $i < 50; ++$i) {
|
||||||
|
$uuids[] = (new User("someone{$i}@ep-reisen.de"))->getUuid();
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertCount(50, array_unique($uuids));
|
||||||
|
|
||||||
|
$sorted = $uuids;
|
||||||
|
sort($sorted);
|
||||||
|
self::assertNotSame($sorted, $uuids, 'v4 uuids must not come out in creation order');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\EventListener;
|
||||||
|
|
||||||
|
use App\Entity\OAuth2Client;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\EventListener\AuthorizationCodeListener;
|
||||||
|
use App\Security\Role;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Event\AuthorizationRequestResolveEvent;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Model\ClientInterface;
|
||||||
|
use League\OAuth2\Server\RequestTypes\AuthorizationRequestInterface;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpFoundation\Session\Session;
|
||||||
|
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
use Twig\Environment;
|
||||||
|
|
||||||
|
class AuthorizationCodeListenerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testAnonymousRequestIsRedirectedToLoginWithoutResolving(): void
|
||||||
|
{
|
||||||
|
$event = $this->dispatch(null, $this->client(['ROLE_EMPLOYEE']));
|
||||||
|
|
||||||
|
$response = $event->getResponse();
|
||||||
|
self::assertInstanceOf(RedirectResponse::class, $response);
|
||||||
|
self::assertSame(Response::HTTP_TEMPORARY_REDIRECT, $response->getStatusCode());
|
||||||
|
self::assertFalse($event->getAuthorizationResolution());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAccountHoldingOneOfTheRequiredRolesIsApproved(): void
|
||||||
|
{
|
||||||
|
$event = $this->dispatch(
|
||||||
|
$this->user([Role::EMPLOYEE]),
|
||||||
|
$this->client([Role::EMPLOYEE, Role::ADMIN])
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertTrue($event->getAuthorizationResolution());
|
||||||
|
self::assertNull($event->getResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAccountHoldingNoneOfTheRequiredRolesIsDenied(): void
|
||||||
|
{
|
||||||
|
$event = $this->dispatch(
|
||||||
|
$this->user([Role::CUSTOMER]),
|
||||||
|
$this->client([Role::EMPLOYEE, Role::ADMIN])
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertFalse($event->getAuthorizationResolution());
|
||||||
|
self::assertSame(Response::HTTP_FORBIDDEN, $event->getResponse()?->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A CRM nomination grants nothing until an administrator approves it, so it must not
|
||||||
|
* open a client either.
|
||||||
|
*/
|
||||||
|
public function testPendingMarkerDoesNotSatisfyTheRequirement(): void
|
||||||
|
{
|
||||||
|
$event = $this->dispatch(
|
||||||
|
$this->user([Role::ADMIN.Role::PENDING_SUFFIX]),
|
||||||
|
$this->client([Role::ADMIN])
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertFalse($event->getAuthorizationResolution());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The column starts empty, so "nobody configured this client" and "this client needs
|
||||||
|
* no roles" are the same value. Only one of the two readings is safe.
|
||||||
|
*/
|
||||||
|
public function testClientWithoutConfiguredRolesIsDenied(): void
|
||||||
|
{
|
||||||
|
$event = $this->dispatch($this->user([Role::ADMIN]), $this->client([]));
|
||||||
|
|
||||||
|
self::assertFalse($event->getAuthorizationResolution());
|
||||||
|
self::assertSame(Response::HTTP_FORBIDDEN, $event->getResponse()?->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testClientOfAnotherImplementationIsDenied(): void
|
||||||
|
{
|
||||||
|
$foreign = $this->createStub(ClientInterface::class);
|
||||||
|
$foreign->method('getIdentifier')->willReturn('f3f10f8807e191c127d74cc9a247bf45');
|
||||||
|
|
||||||
|
$event = $this->dispatch($this->user([Role::ADMIN]), $foreign);
|
||||||
|
|
||||||
|
self::assertFalse($event->getAuthorizationResolution());
|
||||||
|
self::assertSame(Response::HTTP_FORBIDDEN, $event->getResponse()?->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExternalRequestLogsTheUserOutAfterApproval(): void
|
||||||
|
{
|
||||||
|
$security = $this->createMock(Security::class);
|
||||||
|
$security->method('getUser')->willReturn($this->user([Role::EMPLOYEE]));
|
||||||
|
$security->expects(self::once())->method('logout')->with(false);
|
||||||
|
|
||||||
|
$this->dispatch(
|
||||||
|
$this->user([Role::EMPLOYEE]),
|
||||||
|
$this->client([Role::EMPLOYEE]),
|
||||||
|
external: true,
|
||||||
|
security: $security
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A denied user has to stay signed in: they need a session to read the explanation and
|
||||||
|
* navigate away from it.
|
||||||
|
*/
|
||||||
|
public function testExternalRequestDoesNotLogTheUserOutAfterDenial(): void
|
||||||
|
{
|
||||||
|
$security = $this->createMock(Security::class);
|
||||||
|
$security->method('getUser')->willReturn($this->user([Role::CUSTOMER]));
|
||||||
|
$security->expects(self::never())->method('logout');
|
||||||
|
|
||||||
|
$this->dispatch(
|
||||||
|
$this->user([Role::CUSTOMER]),
|
||||||
|
$this->client([Role::EMPLOYEE]),
|
||||||
|
external: true,
|
||||||
|
security: $security
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $requiredRoles
|
||||||
|
*/
|
||||||
|
private function client(array $requiredRoles): OAuth2Client
|
||||||
|
{
|
||||||
|
return (new OAuth2Client('EPHub', 'f3f10f8807e191c127d74cc9a247bf45', 'secret'))
|
||||||
|
->setRequiredRoles($requiredRoles);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $roles
|
||||||
|
*/
|
||||||
|
private function user(array $roles): User
|
||||||
|
{
|
||||||
|
return (new User('[email protected]'))->setRoles($roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function dispatch(
|
||||||
|
?UserInterface $user,
|
||||||
|
ClientInterface $client,
|
||||||
|
bool $external = false,
|
||||||
|
?Security $security = null,
|
||||||
|
): AuthorizationRequestResolveEvent {
|
||||||
|
$request = new Request();
|
||||||
|
$session = new Session(new MockArraySessionStorage());
|
||||||
|
$session->set('_oauth2', $external);
|
||||||
|
$request->setSession($session);
|
||||||
|
|
||||||
|
$requestStack = new RequestStack();
|
||||||
|
$requestStack->push($request);
|
||||||
|
|
||||||
|
if (null === $security) {
|
||||||
|
$security = $this->createStub(Security::class);
|
||||||
|
$security->method('getUser')->willReturn($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
$urlGenerator = $this->createStub(UrlGeneratorInterface::class);
|
||||||
|
$urlGenerator->method('generate')->willReturn('/');
|
||||||
|
|
||||||
|
$twig = $this->createStub(Environment::class);
|
||||||
|
$twig->method('render')->willReturn('<html lang="de"></html>');
|
||||||
|
|
||||||
|
$listener = new AuthorizationCodeListener(
|
||||||
|
$urlGenerator,
|
||||||
|
$requestStack,
|
||||||
|
$security,
|
||||||
|
$this->createStub(LoggerInterface::class),
|
||||||
|
$twig,
|
||||||
|
);
|
||||||
|
|
||||||
|
$event = new AuthorizationRequestResolveEvent(
|
||||||
|
$this->createStub(AuthorizationRequestInterface::class),
|
||||||
|
[],
|
||||||
|
$client,
|
||||||
|
$user ?? new User('[email protected]'),
|
||||||
|
);
|
||||||
|
|
||||||
|
$listener->onAuthorizationRequestResolve($event);
|
||||||
|
|
||||||
|
return $event;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ class UserFilterDtoTest extends TestCase
|
|||||||
$chip = $filter->activeFilters()[0];
|
$chip = $filter->activeFilters()[0];
|
||||||
|
|
||||||
self::assertSame('Rolle', $chip->label);
|
self::assertSame('Rolle', $chip->label);
|
||||||
self::assertSame('Preisrechner Admin', $chip->value);
|
self::assertSame('GRO Admin', $chip->value);
|
||||||
self::assertSame(['role'], $chip->removeKeys);
|
self::assertSame(['role'], $chip->removeKeys);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Form\Service;
|
||||||
|
|
||||||
|
use App\BusProNet\Constants;
|
||||||
|
use App\BusProNet\Model\Booking;
|
||||||
|
use App\BusProNet\Model\Service;
|
||||||
|
use App\BusProNet\Model\Travel;
|
||||||
|
use App\Form\Model\BookingDto;
|
||||||
|
use App\Form\Model\ParticipantDto;
|
||||||
|
use App\Form\Service\ParticipantFieldOptionsProvider;
|
||||||
|
use App\Service\BookingPriceCalculator;
|
||||||
|
use App\Service\InsuranceManager;
|
||||||
|
use App\Service\ServiceAvailabilityCalculator;
|
||||||
|
use App\Service\ServiceLabelFormatter;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit mode applies the availability rules instead of bypassing them.
|
||||||
|
*
|
||||||
|
* Edit used to skip availability entirely for every category except ski passes, so a course
|
||||||
|
* that had sold out or moved to 'Anfrage' was still offered. Selecting one made BusPro refuse
|
||||||
|
* the whole update ("Status der Leistung (A) ist unterschiedlich zum Status des Teilnehmers
|
||||||
|
* (F)"), because the participants of an existing booking are fixed at status 'F'.
|
||||||
|
*
|
||||||
|
* Courses stand in for the categories that render read-only rather than dropping the option.
|
||||||
|
*/
|
||||||
|
class ParticipantFieldOptionsProviderEditAvailabilityTest extends TestCase
|
||||||
|
{
|
||||||
|
private ParticipantFieldOptionsProvider $provider;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$translator = $this->createStub(TranslatorInterface::class);
|
||||||
|
$translator->method('trans')->willReturnArgument(0);
|
||||||
|
|
||||||
|
$this->provider = new ParticipantFieldOptionsProvider(
|
||||||
|
new ServiceAvailabilityCalculator(),
|
||||||
|
$this->createStub(InsuranceManager::class),
|
||||||
|
$this->createStub(BookingPriceCalculator::class),
|
||||||
|
new ServiceLabelFormatter(),
|
||||||
|
$translator
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOnRequestCourseIsReadonlyInEditMode(): void
|
||||||
|
{
|
||||||
|
$course = $this->createCourse(id: 1, status: Constants::STATUS_ON_REQUEST);
|
||||||
|
$bookingDto = $this->createEditBookingDto([$course]);
|
||||||
|
|
||||||
|
$attributes = $this->getChoiceAttributes($bookingDto, $course);
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('readonly', $attributes);
|
||||||
|
$this->assertSame(
|
||||||
|
'Diese Leistung ist derzeit nur auf Anfrage buchbar. Bitte kontaktiere uns.',
|
||||||
|
$attributes['data-tooltip']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSoldOutCourseIsReadonlyInEditMode(): void
|
||||||
|
{
|
||||||
|
$course = $this->createCourse(id: 1, available: 0);
|
||||||
|
$bookingDto = $this->createEditBookingDto([$course]);
|
||||||
|
|
||||||
|
$attributes = $this->getChoiceAttributes($bookingDto, $course);
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('readonly', $attributes);
|
||||||
|
$this->assertSame('ausgebucht', $attributes['data-tooltip']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOnRequestCourseStaysSelectableWhenAlreadyBooked(): void
|
||||||
|
{
|
||||||
|
$course = $this->createCourse(id: 1, status: Constants::STATUS_ON_REQUEST);
|
||||||
|
$bookingDto = $this->createEditBookingDto([$course], heldServiceIds: [1]);
|
||||||
|
|
||||||
|
$attributes = $this->getChoiceAttributes($bookingDto, $course);
|
||||||
|
|
||||||
|
$this->assertArrayNotHasKey('readonly', $attributes);
|
||||||
|
$this->assertArrayNotHasKey('data-tooltip', $attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnlimitedCourseIsNotReadonlyInEditMode(): void
|
||||||
|
{
|
||||||
|
// A null contingent means unlimited. The previous edit branch inverted this and made
|
||||||
|
// such a service read-only for anyone who did not already hold it.
|
||||||
|
$course = $this->createCourse(id: 1, available: null);
|
||||||
|
$bookingDto = $this->createEditBookingDto([$course]);
|
||||||
|
|
||||||
|
$attributes = $this->getChoiceAttributes($bookingDto, $course);
|
||||||
|
|
||||||
|
$this->assertArrayNotHasKey('readonly', $attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOnRequestCourseStaysSelectableInCreateMode(): void
|
||||||
|
{
|
||||||
|
$course = $this->createCourse(id: 1, status: Constants::STATUS_ON_REQUEST);
|
||||||
|
$bookingDto = $this->createBookingDto([$course]);
|
||||||
|
|
||||||
|
$this->assertSame(BookingDto::MODE_CREATE, $bookingDto->getMode());
|
||||||
|
|
||||||
|
$attributes = $this->getChoiceAttributes($bookingDto, $course);
|
||||||
|
|
||||||
|
$this->assertArrayNotHasKey('readonly', $attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function getChoiceAttributes(BookingDto $bookingDto, Service $service): array
|
||||||
|
{
|
||||||
|
$options = $this->provider->getFieldOptions('courses', $bookingDto, 0);
|
||||||
|
|
||||||
|
return $options['choice_attr']($service);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param Service[] $courses */
|
||||||
|
private function createBookingDto(array $courses): BookingDto
|
||||||
|
{
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->dateFrom = new \DateTimeImmutable('2027-01-09');
|
||||||
|
$travel->dateTo = new \DateTimeImmutable('2027-01-16');
|
||||||
|
|
||||||
|
foreach ($courses as $course) {
|
||||||
|
$travel->additionalServices[$course->id] = $course;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bookingDto = new BookingDto($travel, 1);
|
||||||
|
|
||||||
|
$participant = new ParticipantDto();
|
||||||
|
$participant->index = 0;
|
||||||
|
$participant->dateOfBirth = new \DateTimeImmutable('1990-01-01');
|
||||||
|
$bookingDto->participants[0] = $participant;
|
||||||
|
|
||||||
|
return $bookingDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Service[] $courses
|
||||||
|
* @param list<int> $heldServiceIds IDs participant 0 already holds in the booking
|
||||||
|
*/
|
||||||
|
private function createEditBookingDto(array $courses, array $heldServiceIds = []): BookingDto
|
||||||
|
{
|
||||||
|
$bookingDto = $this->createBookingDto($courses);
|
||||||
|
|
||||||
|
$heldServices = [];
|
||||||
|
foreach ($heldServiceIds as $serviceId) {
|
||||||
|
$held = clone $bookingDto->travel->additionalServices[$serviceId];
|
||||||
|
$held->mapping = [0];
|
||||||
|
$heldServices[$serviceId] = $held;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A non-null booking is what puts the DTO into edit mode
|
||||||
|
$booking = new Booking();
|
||||||
|
$booking->additionalServices = $heldServices;
|
||||||
|
$bookingDto->booking = $booking;
|
||||||
|
|
||||||
|
return $bookingDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createCourse(int $id, ?int $available = 10, string $status = Constants::STATUS_AVAILABLE): Service
|
||||||
|
{
|
||||||
|
$service = new Service();
|
||||||
|
$service->id = $id;
|
||||||
|
$service->label = sprintf('Kurs %d', $id);
|
||||||
|
$service->subType = Constants::TOKEN_COURSES;
|
||||||
|
$service->category = Constants::CATEGORY_ADDITIONAL;
|
||||||
|
$service->price = (float) $id;
|
||||||
|
$service->available = $available;
|
||||||
|
$service->status = $status;
|
||||||
|
|
||||||
|
return $service;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,7 +43,7 @@ class BpnAuthenticatorTest extends TestCase
|
|||||||
{
|
{
|
||||||
$persisted = null;
|
$persisted = null;
|
||||||
$authenticator = $this->authenticator(
|
$authenticator = $this->authenticator(
|
||||||
$this->crmAttributes([Role::ADMIN, Role::TEAMER], ['SSL', 'SSL']),
|
$this->crmAttributes([Role::HOUSE_MANAGER, Role::TEAMER], ['SSL', 'SSL']),
|
||||||
null,
|
null,
|
||||||
$persisted,
|
$persisted,
|
||||||
);
|
);
|
||||||
@@ -51,7 +51,7 @@ class BpnAuthenticatorTest extends TestCase
|
|||||||
$user = $this->loadUser($authenticator);
|
$user = $this->loadUser($authenticator);
|
||||||
|
|
||||||
self::assertSame($persisted, $user);
|
self::assertSame($persisted, $user);
|
||||||
self::assertSame(['ROLE_USER', Role::TEAMER, Role::pending(Role::ADMIN)], $user->getRoles());
|
self::assertSame(['ROLE_USER', Role::TEAMER, Role::pending(Role::HOUSE_MANAGER)], $user->getRoles());
|
||||||
self::assertSame(['SSL'], $user->getHotelCodes());
|
self::assertSame(['SSL'], $user->getHotelCodes());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ class BpnAuthenticatorTest extends TestCase
|
|||||||
|
|
||||||
$persisted = null;
|
$persisted = null;
|
||||||
$authenticator = $this->authenticator(
|
$authenticator = $this->authenticator(
|
||||||
$this->crmAttributes([Role::TEAMER, Role::GROUPS_ADMIN], []),
|
$this->crmAttributes([Role::TEAMER, Role::HOUSE_MANAGER], []),
|
||||||
$existing,
|
$existing,
|
||||||
$persisted,
|
$persisted,
|
||||||
);
|
);
|
||||||
@@ -70,32 +70,61 @@ class BpnAuthenticatorTest extends TestCase
|
|||||||
|
|
||||||
self::assertSame($existing, $persisted, 'a login must not create a second account');
|
self::assertSame($existing, $persisted, 'a login must not create a second account');
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
['ROLE_USER', Role::TEAMER, Role::pending(Role::GROUPS_ADMIN)],
|
['ROLE_USER', Role::TEAMER, Role::pending(Role::HOUSE_MANAGER)],
|
||||||
$user->getRoles(),
|
$user->getRoles(),
|
||||||
);
|
);
|
||||||
self::assertNotNull($user->getLastLoginAt(), 'the rest of the profile is still synced');
|
self::assertNotNull($user->getLastLoginAt(), 'the rest of the profile is still synced');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testTheLoginAddressIsNormalizedAndRewritesAStoredCasing(): void
|
||||||
|
{
|
||||||
|
// An account from before the address was normalized: it carries the casing of whatever
|
||||||
|
// its very first login happened to type.
|
||||||
|
$existing = (new User('[email protected]'))->setRoles([Role::TEAMER]);
|
||||||
|
|
||||||
|
$persisted = null;
|
||||||
|
$persistedPassword = null;
|
||||||
|
$lookupCriteria = null;
|
||||||
|
$authenticator = $this->authenticator(
|
||||||
|
$this->crmAttributes([Role::TEAMER], []),
|
||||||
|
$existing,
|
||||||
|
$persisted,
|
||||||
|
$persistedPassword,
|
||||||
|
null,
|
||||||
|
$lookupCriteria,
|
||||||
|
);
|
||||||
|
|
||||||
|
$user = $this->loadUser($authenticator, ' [email protected] ');
|
||||||
|
|
||||||
|
self::assertSame($existing, $persisted, 'a differently cased address is the same account');
|
||||||
|
self::assertSame(['email' => '[email protected]'], $lookupCriteria);
|
||||||
|
self::assertSame(
|
||||||
|
'[email protected]',
|
||||||
|
$user->getEmail(),
|
||||||
|
'the stored casing follows the login rather than staying frozen',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function testApprovedRoleSurvivesTheNextLogin(): void
|
public function testApprovedRoleSurvivesTheNextLogin(): void
|
||||||
{
|
{
|
||||||
$existing = (new User('[email protected]'))->setRoles([Role::TEAMER, Role::GROUPS_MANAGER]);
|
$existing = (new User('[email protected]'))->setRoles([Role::TEAMER, Role::HOUSE_MANAGER]);
|
||||||
|
|
||||||
$persisted = null;
|
$persisted = null;
|
||||||
$authenticator = $this->authenticator(
|
$authenticator = $this->authenticator(
|
||||||
$this->crmAttributes([Role::TEAMER, Role::GROUPS_MANAGER], []),
|
$this->crmAttributes([Role::TEAMER, Role::HOUSE_MANAGER], []),
|
||||||
$existing,
|
$existing,
|
||||||
$persisted,
|
$persisted,
|
||||||
);
|
);
|
||||||
|
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
['ROLE_USER', Role::TEAMER, Role::GROUPS_MANAGER],
|
['ROLE_USER', Role::TEAMER, Role::HOUSE_MANAGER],
|
||||||
$this->loadUser($authenticator)->getRoles(),
|
$this->loadUser($authenticator)->getRoles(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testRoleRevokedInBusProIsWithdrawnOnLogin(): void
|
public function testRoleRevokedInBusProIsWithdrawnOnLogin(): void
|
||||||
{
|
{
|
||||||
$existing = (new User('[email protected]'))->setRoles([Role::TEAMER, Role::GROUPS_MANAGER]);
|
$existing = (new User('[email protected]'))->setRoles([Role::TEAMER, Role::HOUSE_MANAGER]);
|
||||||
|
|
||||||
$persisted = null;
|
$persisted = null;
|
||||||
$authenticator = $this->authenticator($this->crmAttributes([], []), $existing, $persisted);
|
$authenticator = $this->authenticator($this->crmAttributes([], []), $existing, $persisted);
|
||||||
@@ -140,7 +169,7 @@ class BpnAuthenticatorTest extends TestCase
|
|||||||
public function testDegradedCrmResponseLeavesAnExistingAccountUntouched(): void
|
public function testDegradedCrmResponseLeavesAnExistingAccountUntouched(): void
|
||||||
{
|
{
|
||||||
$existing = (new User('[email protected]'))
|
$existing = (new User('[email protected]'))
|
||||||
->setRoles([Role::TEAMER, Role::GROUPS_MANAGER])
|
->setRoles([Role::TEAMER, Role::HOUSE_MANAGER])
|
||||||
->setHotelCodes(['DKS'])
|
->setHotelCodes(['DKS'])
|
||||||
;
|
;
|
||||||
|
|
||||||
@@ -155,7 +184,7 @@ class BpnAuthenticatorTest extends TestCase
|
|||||||
|
|
||||||
$user = $this->loadUser($authenticator);
|
$user = $this->loadUser($authenticator);
|
||||||
|
|
||||||
self::assertSame(['ROLE_USER', Role::TEAMER, Role::GROUPS_MANAGER], $user->getRoles());
|
self::assertSame(['ROLE_USER', Role::TEAMER, Role::HOUSE_MANAGER], $user->getRoles());
|
||||||
self::assertSame(['DKS'], $user->getHotelCodes());
|
self::assertSame(['DKS'], $user->getHotelCodes());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,12 +269,62 @@ class BpnAuthenticatorTest extends TestCase
|
|||||||
self::assertSame(['ROLE_USER', Role::CUSTOMER], $user->getRoles());
|
self::assertSame(['ROLE_USER', Role::CUSTOMER], $user->getRoles());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testAStaffContactAddressOnTheBusProRecordDoesNotMakeTheLoginStaff(): void
|
||||||
|
{
|
||||||
|
$persisted = null;
|
||||||
|
$persistedPassword = null;
|
||||||
|
$authenticator = $this->authenticator(
|
||||||
|
$this->crmAttributes([Role::ADMIN, Role::TEAMER], []),
|
||||||
|
null,
|
||||||
|
$persisted,
|
||||||
|
$persistedPassword,
|
||||||
|
'[email protected]',
|
||||||
|
);
|
||||||
|
|
||||||
|
$user = $this->loadUser($authenticator, '[email protected]');
|
||||||
|
|
||||||
|
// BusPro accepts any address on the record as a login and answers with the first contact
|
||||||
|
// address regardless of which one was used, so only the typed address may decide. Reading
|
||||||
|
// the response instead would hand ROLE_EMPLOYEE — and every EMPLOYEE_ONLY role with it —
|
||||||
|
// to anyone who can add a staff address to their own BusPro record.
|
||||||
|
self::assertSame(['ROLE_USER', Role::TEAMER], $user->getRoles());
|
||||||
|
self::assertSame([], $this->dispatched);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEmployeeOnlyClaimFromAnotherDomainIsIgnored(): void
|
||||||
|
{
|
||||||
|
$persisted = null;
|
||||||
|
$authenticator = $this->authenticator($this->crmAttributes([Role::ADMIN, Role::TEAMER], []), null, $persisted);
|
||||||
|
|
||||||
|
$user = $this->loadUser($authenticator, '[email protected]');
|
||||||
|
|
||||||
|
// Not even a nomination, so there is nothing for an administrator to be told about.
|
||||||
|
self::assertSame(['ROLE_USER', Role::TEAMER], $user->getRoles());
|
||||||
|
self::assertSame([], $this->dispatched);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEmployeeOnlyRoleIsRevokedWhenTheAddressIsNotStaff(): void
|
||||||
|
{
|
||||||
|
$existing = (new User('[email protected]'))->setRoles([Role::TEAMER, Role::ADMIN, Role::HOUSE_MANAGER]);
|
||||||
|
$persisted = null;
|
||||||
|
$authenticator = $this->authenticator(
|
||||||
|
$this->crmAttributes([Role::TEAMER, Role::ADMIN, Role::HOUSE_MANAGER], []),
|
||||||
|
$existing,
|
||||||
|
$persisted,
|
||||||
|
);
|
||||||
|
|
||||||
|
$user = $this->loadUser($authenticator, '[email protected]');
|
||||||
|
|
||||||
|
// Only the EMPLOYEE_ONLY role goes; a Hausleitung does not need a staff address.
|
||||||
|
self::assertSame(['ROLE_USER', Role::TEAMER, Role::HOUSE_MANAGER], $user->getRoles());
|
||||||
|
}
|
||||||
|
|
||||||
public function testANewNominationIsAnnouncedOnce(): void
|
public function testANewNominationIsAnnouncedOnce(): void
|
||||||
{
|
{
|
||||||
$persisted = null;
|
$persisted = null;
|
||||||
$authenticator = $this->authenticator($this->crmAttributes([Role::ADMIN], []), null, $persisted);
|
$authenticator = $this->authenticator($this->crmAttributes([Role::ADMIN], []), null, $persisted);
|
||||||
|
|
||||||
$user = $this->loadUser($authenticator);
|
$user = $this->loadUser($authenticator, '[email protected]');
|
||||||
|
|
||||||
self::assertCount(1, $this->dispatched);
|
self::assertCount(1, $this->dispatched);
|
||||||
$message = $this->dispatched[0];
|
$message = $this->dispatched[0];
|
||||||
@@ -257,11 +336,11 @@ class BpnAuthenticatorTest extends TestCase
|
|||||||
|
|
||||||
public function testAStandingNominationIsNotAnnouncedAgain(): void
|
public function testAStandingNominationIsNotAnnouncedAgain(): void
|
||||||
{
|
{
|
||||||
$existing = (new User('[email protected]'))->setRoles([Role::pending(Role::ADMIN)]);
|
$existing = (new User('[email protected]'))->setRoles([Role::EMPLOYEE, Role::pending(Role::ADMIN)]);
|
||||||
$persisted = null;
|
$persisted = null;
|
||||||
$authenticator = $this->authenticator($this->crmAttributes([Role::ADMIN], []), $existing, $persisted);
|
$authenticator = $this->authenticator($this->crmAttributes([Role::ADMIN], []), $existing, $persisted);
|
||||||
|
|
||||||
$this->loadUser($authenticator);
|
$this->loadUser($authenticator, '[email protected]');
|
||||||
|
|
||||||
// The nomination has not changed, so there is nothing new to tell an administrator about.
|
// The nomination has not changed, so there is nothing new to tell an administrator about.
|
||||||
self::assertSame([], $this->dispatched);
|
self::assertSame([], $this->dispatched);
|
||||||
@@ -269,11 +348,11 @@ class BpnAuthenticatorTest extends TestCase
|
|||||||
|
|
||||||
public function testAnApprovedRoleIsNotAnnouncedAsANomination(): void
|
public function testAnApprovedRoleIsNotAnnouncedAsANomination(): void
|
||||||
{
|
{
|
||||||
$existing = (new User('[email protected]'))->setRoles([Role::ADMIN]);
|
$existing = (new User('[email protected]'))->setRoles([Role::EMPLOYEE, Role::ADMIN]);
|
||||||
$persisted = null;
|
$persisted = null;
|
||||||
$authenticator = $this->authenticator($this->crmAttributes([Role::ADMIN], []), $existing, $persisted);
|
$authenticator = $this->authenticator($this->crmAttributes([Role::ADMIN], []), $existing, $persisted);
|
||||||
|
|
||||||
$this->loadUser($authenticator);
|
$this->loadUser($authenticator, '[email protected]');
|
||||||
|
|
||||||
self::assertSame([], $this->dispatched);
|
self::assertSame([], $this->dispatched);
|
||||||
}
|
}
|
||||||
@@ -293,17 +372,27 @@ class BpnAuthenticatorTest extends TestCase
|
|||||||
?User $existing,
|
?User $existing,
|
||||||
?User &$persisted,
|
?User &$persisted,
|
||||||
?string &$persistedPassword = null,
|
?string &$persistedPassword = null,
|
||||||
|
?string $contactEmail = null,
|
||||||
|
?array &$lookupCriteria = null,
|
||||||
): BpnAuthenticator {
|
): BpnAuthenticator {
|
||||||
$personalData = new PersonalData();
|
$personalData = new PersonalData();
|
||||||
$personalData->personId = 42;
|
$personalData->personId = 42;
|
||||||
$personalData->addressId = 4711;
|
$personalData->addressId = 4711;
|
||||||
|
$personalData->communication->email = $contactEmail;
|
||||||
|
|
||||||
$apiClient = $this->createStub(ApiClient::class);
|
$apiClient = $this->createStub(ApiClient::class);
|
||||||
$apiClient->method('getPersonalData')->willReturn($personalData);
|
$apiClient->method('getPersonalData')->willReturn($personalData);
|
||||||
$apiClient->method('getCrmAttributes')->willReturn($crmAttributes);
|
$apiClient->method('getCrmAttributes')->willReturn($crmAttributes);
|
||||||
|
|
||||||
$repository = $this->createStub(EntityRepository::class);
|
$repository = $this->createStub(EntityRepository::class);
|
||||||
$repository->method('findOneBy')->willReturn($existing);
|
$repository
|
||||||
|
->method('findOneBy')
|
||||||
|
->willReturnCallback(static function (array $criteria) use ($existing, &$lookupCriteria): ?User {
|
||||||
|
$lookupCriteria = $criteria;
|
||||||
|
|
||||||
|
return $existing;
|
||||||
|
})
|
||||||
|
;
|
||||||
|
|
||||||
$entityManager = $this->createStub(EntityManagerInterface::class);
|
$entityManager = $this->createStub(EntityManagerInterface::class);
|
||||||
$entityManager->method('getRepository')->willReturn($repository);
|
$entityManager->method('getRepository')->willReturn($repository);
|
||||||
|
|||||||
+76
-20
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Tests\Security;
|
namespace App\Tests\Security;
|
||||||
|
|
||||||
use App\Security\Role;
|
use App\Security\Role;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,46 +16,46 @@ class RoleTest extends TestCase
|
|||||||
{
|
{
|
||||||
public function testAdministrativeClaimOnlyProducesANomination(): void
|
public function testAdministrativeClaimOnlyProducesANomination(): void
|
||||||
{
|
{
|
||||||
$roles = Role::sync([], [Role::ADMIN, Role::GROUPS_ADMIN, Role::TEAMER]);
|
$roles = Role::sync([], [Role::HOUSE_MANAGER, Role::GROUPS_ADMIN, Role::TEAMER, Role::EMPLOYEE]);
|
||||||
|
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
[Role::TEAMER, Role::pending(Role::ADMIN), Role::pending(Role::GROUPS_ADMIN)],
|
[Role::TEAMER, Role::EMPLOYEE, Role::pending(Role::HOUSE_MANAGER), Role::pending(Role::GROUPS_ADMIN)],
|
||||||
$roles,
|
$roles,
|
||||||
);
|
);
|
||||||
self::assertSame([Role::TEAMER], Role::effectiveOnly($roles));
|
self::assertSame([Role::TEAMER, Role::EMPLOYEE], Role::effectiveOnly($roles));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCustomerExpertClaimOnlyProducesANomination(): void
|
public function testCustomerExpertClaimOnlyProducesANomination(): void
|
||||||
{
|
{
|
||||||
$roles = Role::sync([], [Role::CUSTOMER_EXPERT]);
|
$roles = Role::sync([], [Role::EMPLOYEE, Role::CUSTOMER_EXPERT]);
|
||||||
|
|
||||||
self::assertSame([Role::pending(Role::CUSTOMER_EXPERT), Role::CUSTOMER], $roles);
|
self::assertSame([Role::EMPLOYEE, Role::pending(Role::CUSTOMER_EXPERT)], $roles);
|
||||||
self::assertSame([Role::CUSTOMER], Role::effectiveOnly($roles));
|
self::assertSame([Role::EMPLOYEE], Role::effectiveOnly($roles));
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
[Role::CUSTOMER_EXPERT => 'KO-Experte'],
|
[Role::CUSTOMER_EXPERT => 'KO-Expert:in'],
|
||||||
Role::nominatedFrom($roles),
|
Role::nominatedFrom($roles),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testApprovedCustomerExpertDisplacesTheCustomerFallback(): void
|
public function testCustomerExpertCanBeApprovedForStaff(): void
|
||||||
{
|
{
|
||||||
$roles = Role::approve([Role::pending(Role::CUSTOMER_EXPERT), Role::CUSTOMER], Role::CUSTOMER_EXPERT);
|
$roles = Role::approve([Role::EMPLOYEE, Role::pending(Role::CUSTOMER_EXPERT)], Role::CUSTOMER_EXPERT);
|
||||||
|
|
||||||
self::assertSame([Role::CUSTOMER_EXPERT], $roles);
|
self::assertSame([Role::EMPLOYEE, Role::CUSTOMER_EXPERT], $roles);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testApprovedRoleSurvivesTheNextSyncAndIsNotMarkedAgain(): void
|
public function testApprovedRoleSurvivesTheNextSyncAndIsNotMarkedAgain(): void
|
||||||
{
|
{
|
||||||
$roles = Role::sync([Role::TEAMER, Role::GROUPS_ADMIN], [Role::GROUPS_ADMIN, Role::TEAMER]);
|
$roles = Role::sync([Role::TEAMER, Role::HOUSE_MANAGER], [Role::HOUSE_MANAGER, Role::TEAMER]);
|
||||||
|
|
||||||
self::assertSame([Role::TEAMER, Role::GROUPS_ADMIN], $roles);
|
self::assertSame([Role::TEAMER, Role::HOUSE_MANAGER], $roles);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testRoleTheCrmNoLongerClaimsIsRevoked(): void
|
public function testRoleTheCrmNoLongerClaimsIsRevoked(): void
|
||||||
{
|
{
|
||||||
// Both halves go: BusPro is the source of truth for the granted role as much as for
|
// Both halves go: BusPro is the source of truth for the granted role as much as for
|
||||||
// the nomination.
|
// the nomination.
|
||||||
$roles = Role::sync([Role::TEAMER, Role::ADMIN, Role::pending(Role::MANAGER)], [Role::TEAMER]);
|
$roles = Role::sync([Role::TEAMER, Role::GROUPS_ADMIN, Role::pending(Role::HOUSE_MANAGER)], [Role::TEAMER]);
|
||||||
|
|
||||||
self::assertSame([Role::TEAMER], $roles);
|
self::assertSame([Role::TEAMER], $roles);
|
||||||
}
|
}
|
||||||
@@ -69,8 +70,8 @@ class RoleTest extends TestCase
|
|||||||
// The nomination stays visible — it is what an approver acts on — but grants nothing,
|
// The nomination stays visible — it is what an approver acts on — but grants nothing,
|
||||||
// so the account is a customer in the meantime.
|
// so the account is a customer in the meantime.
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
[Role::pending(Role::ADMIN), Role::CUSTOMER],
|
[Role::pending(Role::HOUSE_MANAGER), Role::CUSTOMER],
|
||||||
Role::sync([], [Role::ADMIN]),
|
Role::sync([], [Role::HOUSE_MANAGER]),
|
||||||
);
|
);
|
||||||
self::assertSame([Role::CUSTOMER], Role::sync([], []));
|
self::assertSame([Role::CUSTOMER], Role::sync([], []));
|
||||||
}
|
}
|
||||||
@@ -90,10 +91,10 @@ class RoleTest extends TestCase
|
|||||||
|
|
||||||
public function testApprovalTurnsTheNominationIntoTheRole(): void
|
public function testApprovalTurnsTheNominationIntoTheRole(): void
|
||||||
{
|
{
|
||||||
$roles = Role::approve([Role::pending(Role::ADMIN), Role::CUSTOMER], Role::ADMIN);
|
$roles = Role::approve([Role::pending(Role::HOUSE_MANAGER), Role::CUSTOMER], Role::HOUSE_MANAGER);
|
||||||
|
|
||||||
// The customer fallback goes with it: the account now holds an effective role.
|
// The customer fallback goes with it: the account now holds an effective role.
|
||||||
self::assertSame([Role::ADMIN], $roles);
|
self::assertSame([Role::HOUSE_MANAGER], $roles);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testApprovingARoleWithoutANominationIsRefused(): void
|
public function testApprovingARoleWithoutANominationIsRefused(): void
|
||||||
@@ -105,11 +106,11 @@ class RoleTest extends TestCase
|
|||||||
|
|
||||||
public function testEffectiveRolesExcludeNominationsAndTheImplicitRoleUser(): void
|
public function testEffectiveRolesExcludeNominationsAndTheImplicitRoleUser(): void
|
||||||
{
|
{
|
||||||
$roles = [Role::USER, Role::TEAMER, Role::pending(Role::ADMIN)];
|
$roles = [Role::USER, Role::TEAMER, Role::pending(Role::HOUSE_MANAGER)];
|
||||||
|
|
||||||
self::assertSame([Role::TEAMER], Role::effectiveOnly($roles));
|
self::assertSame([Role::TEAMER], Role::effectiveOnly($roles));
|
||||||
self::assertSame([Role::pending(Role::ADMIN)], Role::pendingOnly($roles));
|
self::assertSame([Role::pending(Role::HOUSE_MANAGER)], Role::pendingOnly($roles));
|
||||||
self::assertSame([Role::ADMIN => 'Administration'], Role::nominatedFrom($roles));
|
self::assertSame([Role::HOUSE_MANAGER => 'Hausleitung'], Role::nominatedFrom($roles));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testEmployeeIsGrantedOutrightAndDisplacesTheCustomerFallback(): void
|
public function testEmployeeIsGrantedOutrightAndDisplacesTheCustomerFallback(): void
|
||||||
@@ -133,6 +134,61 @@ class RoleTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return iterable<string, array{string}>
|
||||||
|
*/
|
||||||
|
public static function employeeOnlyRoles(): iterable
|
||||||
|
{
|
||||||
|
foreach (Role::EMPLOYEE_ONLY as $role) {
|
||||||
|
yield $role => [$role];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('employeeOnlyRoles')]
|
||||||
|
public function testEmployeeOnlyClaimWithoutEmployeeIsNotNominated(string $role): void
|
||||||
|
{
|
||||||
|
self::assertSame([Role::TEAMER], Role::sync([], [Role::TEAMER, $role]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('employeeOnlyRoles')]
|
||||||
|
public function testEmployeeOnlyRoleIsRevokedWithoutEmployee(string $role): void
|
||||||
|
{
|
||||||
|
// The CRM still claims it, but the account is no longer staff: the claim counts as not
|
||||||
|
// made, and a role approved back when it was staff goes with it.
|
||||||
|
self::assertSame(
|
||||||
|
[Role::TEAMER],
|
||||||
|
Role::sync([Role::TEAMER, Role::EMPLOYEE, $role], [Role::TEAMER, $role]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('employeeOnlyRoles')]
|
||||||
|
public function testEmployeeOnlyRoleSurvivesTheNextSyncForStaff(string $role): void
|
||||||
|
{
|
||||||
|
self::assertSame(
|
||||||
|
[Role::EMPLOYEE, $role],
|
||||||
|
Role::sync([Role::EMPLOYEE, $role], [Role::EMPLOYEE, $role]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHouseManagerDoesNotNeedEmployee(): void
|
||||||
|
{
|
||||||
|
// A Hausleitung signs in with the hotel's own address.
|
||||||
|
self::assertSame([Role::pending(Role::HOUSE_MANAGER), Role::CUSTOMER], Role::sync([], [Role::HOUSE_MANAGER]));
|
||||||
|
self::assertSame([Role::HOUSE_MANAGER], Role::sync([Role::HOUSE_MANAGER], [Role::HOUSE_MANAGER]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStaleEmployeeOnlyNominationWithoutEmployeeCannotBeApproved(): void
|
||||||
|
{
|
||||||
|
// A marker from before the rule, still stored until the account's next login.
|
||||||
|
$roles = [Role::TEAMER, Role::pending(Role::ADMIN)];
|
||||||
|
|
||||||
|
self::assertSame([], Role::nominatedFrom($roles));
|
||||||
|
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
|
||||||
|
Role::approve($roles, Role::ADMIN);
|
||||||
|
}
|
||||||
|
|
||||||
public function testEmployeeAndTeamerCoexist(): void
|
public function testEmployeeAndTeamerCoexist(): void
|
||||||
{
|
{
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use App\Repository\BookingEditDraftRepository;
|
|||||||
use App\Service\BookingChangeTracker;
|
use App\Service\BookingChangeTracker;
|
||||||
use App\Service\BookingEditDraftManager;
|
use App\Service\BookingEditDraftManager;
|
||||||
use App\Service\BookingEditDraftMerger;
|
use App\Service\BookingEditDraftMerger;
|
||||||
|
use App\Service\ServiceAvailabilityCalculator;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Psr\Log\NullLogger;
|
use Psr\Log\NullLogger;
|
||||||
@@ -37,7 +38,7 @@ class BookingEditDraftManagerMutabilityTest extends TestCase
|
|||||||
$this->createStub(BookingEditDraftRepository::class),
|
$this->createStub(BookingEditDraftRepository::class),
|
||||||
$this->createStub(EntityManagerInterface::class),
|
$this->createStub(EntityManagerInterface::class),
|
||||||
$this->createStub(BookingChangeTracker::class),
|
$this->createStub(BookingChangeTracker::class),
|
||||||
new BookingEditDraftMerger(),
|
new BookingEditDraftMerger(new ServiceAvailabilityCalculator()),
|
||||||
new NullLogger(),
|
new NullLogger(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\BusProNet\Model\Travel;
|
|||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
use App\Form\Model\ParticipantDto;
|
use App\Form\Model\ParticipantDto;
|
||||||
use App\Service\BookingEditDraftMerger;
|
use App\Service\BookingEditDraftMerger;
|
||||||
|
use App\Service\ServiceAvailabilityCalculator;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,7 +27,7 @@ class BookingEditDraftMergerRoomAssignmentTest extends TestCase
|
|||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$this->merger = new BookingEditDraftMerger();
|
$this->merger = new BookingEditDraftMerger(new ServiceAvailabilityCalculator());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDraftDoesNotUnassignRoomWhenDraftValueIsNull(): void
|
public function testDraftDoesNotUnassignRoomWhenDraftValueIsNull(): void
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service;
|
||||||
|
|
||||||
|
use App\BusProNet\Constants;
|
||||||
|
use App\BusProNet\Model\Booking;
|
||||||
|
use App\BusProNet\Model\Service;
|
||||||
|
use App\BusProNet\Model\Travel;
|
||||||
|
use App\Form\Model\BookingDto;
|
||||||
|
use App\Form\Model\ParticipantDto;
|
||||||
|
use App\Service\BookingEditDraftMerger;
|
||||||
|
use App\Service\ServiceAvailabilityCalculator;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that draft restoration does not re-arm a selection BusPro would reject.
|
||||||
|
*
|
||||||
|
* A draft can be weeks old. Restoring a selection for a service that has since sold out or
|
||||||
|
* moved to 'Anfrage' is what makes a stuck booking stuck: the stale choice comes back on every
|
||||||
|
* re-entry and BusPro refuses the whole update again. Services the participant already holds
|
||||||
|
* still restore, because withdrawing one breaks the Leistung/Teilnehmer counts.
|
||||||
|
*/
|
||||||
|
class BookingEditDraftMergerServiceAvailabilityTest extends TestCase
|
||||||
|
{
|
||||||
|
private BookingEditDraftMerger $merger;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->merger = new BookingEditDraftMerger(new ServiceAvailabilityCalculator());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDraftDropsSkiPassThatIsNowOnRequest(): void
|
||||||
|
{
|
||||||
|
$skiPass = $this->createSkiPass(60, 'Skipass 6 Tage', Constants::STATUS_ON_REQUEST);
|
||||||
|
$travel = $this->createTravel([60 => $skiPass]);
|
||||||
|
|
||||||
|
$participant = new ParticipantDto();
|
||||||
|
$participant->mutable = true;
|
||||||
|
|
||||||
|
$dropped = $this->apply($travel, $participant, ['skiPass' => 60], new Booking());
|
||||||
|
|
||||||
|
$this->assertNull($participant->skiPass);
|
||||||
|
$this->assertSame(['Skipass 6 Tage'], $dropped);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDraftRestoresSkiPassThatIsStillFree(): void
|
||||||
|
{
|
||||||
|
$skiPass = $this->createSkiPass(60, 'Skipass 6 Tage');
|
||||||
|
$travel = $this->createTravel([60 => $skiPass]);
|
||||||
|
|
||||||
|
$participant = new ParticipantDto();
|
||||||
|
$participant->mutable = true;
|
||||||
|
|
||||||
|
$dropped = $this->apply($travel, $participant, ['skiPass' => 60], new Booking());
|
||||||
|
|
||||||
|
$this->assertSame(60, $participant->skiPass?->id);
|
||||||
|
$this->assertSame([], $dropped);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDraftRestoresOnRequestSkiPassTheParticipantAlreadyHolds(): void
|
||||||
|
{
|
||||||
|
$skiPass = $this->createSkiPass(60, 'Skipass 6 Tage', Constants::STATUS_ON_REQUEST);
|
||||||
|
$travel = $this->createTravel([60 => $skiPass]);
|
||||||
|
|
||||||
|
$held = clone $skiPass;
|
||||||
|
$held->mapping = [1];
|
||||||
|
$booking = new Booking();
|
||||||
|
$booking->additionalServices = [60 => $held];
|
||||||
|
|
||||||
|
$participant = new ParticipantDto();
|
||||||
|
$participant->mutable = true;
|
||||||
|
|
||||||
|
$dropped = $this->apply($travel, $participant, ['skiPass' => 60], $booking);
|
||||||
|
|
||||||
|
$this->assertSame(60, $participant->skiPass?->id);
|
||||||
|
$this->assertSame([], $dropped);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDraftDropsOnlyTheUnbookableEntryOfAMultiSelectField(): void
|
||||||
|
{
|
||||||
|
$bookable = $this->createCourse(70, 'Snowboardkurs');
|
||||||
|
$onRequest = $this->createCourse(71, 'Skikurs', Constants::STATUS_ON_REQUEST);
|
||||||
|
$travel = $this->createTravel([70 => $bookable, 71 => $onRequest]);
|
||||||
|
|
||||||
|
$participant = new ParticipantDto();
|
||||||
|
$participant->mutable = true;
|
||||||
|
|
||||||
|
$dropped = $this->apply($travel, $participant, ['courses' => [70, 71]], new Booking());
|
||||||
|
|
||||||
|
$this->assertSame([70], array_map(static fn (Service $s) => $s->id, $participant->courses));
|
||||||
|
$this->assertSame(['Skikurs'], $dropped);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $services
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function apply(Travel $travel, ParticipantDto $participant, array $services, Booking $booking): array
|
||||||
|
{
|
||||||
|
$dto = new BookingDto($travel, 1);
|
||||||
|
$dto->participants = [1 => $participant];
|
||||||
|
// A non-null booking is what puts the DTO into edit mode
|
||||||
|
$dto->booking = $booking;
|
||||||
|
|
||||||
|
return $this->merger->apply($dto, 1, $participant, ['services' => $services], $travel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<int, Service> $additionalServices */
|
||||||
|
private function createTravel(array $additionalServices): Travel
|
||||||
|
{
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->additionalServices = $additionalServices;
|
||||||
|
$travel->additionalServicesMutable = true;
|
||||||
|
|
||||||
|
return $travel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createSkiPass(int $id, string $label, string $status = Constants::STATUS_AVAILABLE): Service
|
||||||
|
{
|
||||||
|
return $this->createService($id, $label, Constants::TOKEN_SKI_PASS, $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createCourse(int $id, string $label, string $status = Constants::STATUS_AVAILABLE): Service
|
||||||
|
{
|
||||||
|
return $this->createService($id, $label, Constants::TOKEN_COURSES, $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createService(int $id, string $label, string $subType, string $status): Service
|
||||||
|
{
|
||||||
|
$service = new Service();
|
||||||
|
$service->id = $id;
|
||||||
|
$service->label = $label;
|
||||||
|
$service->subType = $subType;
|
||||||
|
$service->status = $status;
|
||||||
|
$service->category = Constants::CATEGORY_ADDITIONAL;
|
||||||
|
|
||||||
|
return $service;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Tests\Service;
|
namespace App\Tests\Service;
|
||||||
|
|
||||||
|
use App\BusProNet\Constants;
|
||||||
use App\BusProNet\DataProcessor\BookingDataProcessor;
|
use App\BusProNet\DataProcessor\BookingDataProcessor;
|
||||||
use App\BusProNet\Model\Booking;
|
use App\BusProNet\Model\Booking;
|
||||||
use App\BusProNet\Model\Pickup;
|
use App\BusProNet\Model\Pickup;
|
||||||
@@ -12,6 +13,7 @@ use App\BusProNet\Model\Travel;
|
|||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
use App\Form\Model\ParticipantDto;
|
use App\Form\Model\ParticipantDto;
|
||||||
use App\Service\BookingEditSubmitGuard;
|
use App\Service\BookingEditSubmitGuard;
|
||||||
|
use App\Service\ServiceAvailabilityCalculator;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
@@ -54,7 +56,7 @@ class BookingEditSubmitGuardTest extends TestCase
|
|||||||
->method('createBookingDtoFromBooking')
|
->method('createBookingDtoFromBooking')
|
||||||
->willReturn($baselineDto);
|
->willReturn($baselineDto);
|
||||||
|
|
||||||
$service = new BookingEditSubmitGuard($processor);
|
$service = new BookingEditSubmitGuard($processor, new ServiceAvailabilityCalculator());
|
||||||
|
|
||||||
$changed = $service->reconcileImmutableCategories($workingDto, new Booking());
|
$changed = $service->reconcileImmutableCategories($workingDto, new Booking());
|
||||||
|
|
||||||
@@ -91,7 +93,7 @@ class BookingEditSubmitGuardTest extends TestCase
|
|||||||
->method('createBookingDtoFromBooking')
|
->method('createBookingDtoFromBooking')
|
||||||
->willReturn($baselineDto);
|
->willReturn($baselineDto);
|
||||||
|
|
||||||
$service = new BookingEditSubmitGuard($processor);
|
$service = new BookingEditSubmitGuard($processor, new ServiceAvailabilityCalculator());
|
||||||
|
|
||||||
$changed = $service->reconcileImmutableCategories($workingDto, new Booking());
|
$changed = $service->reconcileImmutableCategories($workingDto, new Booking());
|
||||||
|
|
||||||
@@ -128,7 +130,7 @@ class BookingEditSubmitGuardTest extends TestCase
|
|||||||
->method('createBookingDtoFromBooking')
|
->method('createBookingDtoFromBooking')
|
||||||
->willReturn($baselineDto);
|
->willReturn($baselineDto);
|
||||||
|
|
||||||
$service = new BookingEditSubmitGuard($processor);
|
$service = new BookingEditSubmitGuard($processor, new ServiceAvailabilityCalculator());
|
||||||
|
|
||||||
$changed = $service->reconcileImmutableCategories($workingDto, new Booking());
|
$changed = $service->reconcileImmutableCategories($workingDto, new Booking());
|
||||||
|
|
||||||
@@ -136,6 +138,85 @@ class BookingEditSubmitGuardTest extends TestCase
|
|||||||
$this->assertSame([10], array_map(static fn (Service $s) => $s->id, $workingParticipant->rentals));
|
$this->assertSame([10], array_map(static fn (Service $s) => $s->id, $workingParticipant->rentals));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testRevertUnbookableServiceAdditionsDropsOnRequestAddition(): void
|
||||||
|
{
|
||||||
|
$onRequest = $this->createService(99);
|
||||||
|
$onRequest->label = 'Bus-Hinfahrt';
|
||||||
|
$onRequest->subType = Constants::TOKEN_SKI_PASS;
|
||||||
|
$onRequest->status = Constants::STATUS_ON_REQUEST;
|
||||||
|
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->additionalServices = [99 => $onRequest];
|
||||||
|
|
||||||
|
$workingParticipant = new ParticipantDto();
|
||||||
|
$workingParticipant->index = 0;
|
||||||
|
$workingParticipant->skiPass = $onRequest;
|
||||||
|
|
||||||
|
$freshBooking = new Booking();
|
||||||
|
|
||||||
|
$workingDto = new BookingDto($travel, 1);
|
||||||
|
$workingDto->participants = [$workingParticipant];
|
||||||
|
$workingDto->booking = $freshBooking;
|
||||||
|
|
||||||
|
$baselineParticipant = new ParticipantDto();
|
||||||
|
$baselineParticipant->index = 0;
|
||||||
|
|
||||||
|
$baselineDto = new BookingDto($travel, 1);
|
||||||
|
$baselineDto->participants = [$baselineParticipant];
|
||||||
|
|
||||||
|
$processor = $this->createStub(BookingDataProcessor::class);
|
||||||
|
$processor->method('createBookingDtoFromBooking')->willReturn($baselineDto);
|
||||||
|
|
||||||
|
$guard = new BookingEditSubmitGuard($processor, new ServiceAvailabilityCalculator());
|
||||||
|
|
||||||
|
$reverted = $guard->revertUnbookableServiceAdditions($workingDto, $freshBooking);
|
||||||
|
|
||||||
|
$this->assertSame(['Bus-Hinfahrt'], $reverted);
|
||||||
|
$this->assertNull($workingParticipant->skiPass);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRevertUnbookableServiceAdditionsKeepsAServiceTheParticipantAlreadyHolds(): void
|
||||||
|
{
|
||||||
|
$onRequest = $this->createService(99);
|
||||||
|
$onRequest->label = 'Bus-Hinfahrt';
|
||||||
|
$onRequest->subType = Constants::TOKEN_SKI_PASS;
|
||||||
|
$onRequest->status = Constants::STATUS_ON_REQUEST;
|
||||||
|
|
||||||
|
$travel = new Travel();
|
||||||
|
$travel->additionalServices = [99 => $onRequest];
|
||||||
|
|
||||||
|
$held = clone $onRequest;
|
||||||
|
$held->mapping = [0];
|
||||||
|
|
||||||
|
$freshBooking = new Booking();
|
||||||
|
$freshBooking->additionalServices = [99 => $held];
|
||||||
|
|
||||||
|
$workingParticipant = new ParticipantDto();
|
||||||
|
$workingParticipant->index = 0;
|
||||||
|
$workingParticipant->skiPass = $onRequest;
|
||||||
|
|
||||||
|
$workingDto = new BookingDto($travel, 1);
|
||||||
|
$workingDto->participants = [$workingParticipant];
|
||||||
|
$workingDto->booking = $freshBooking;
|
||||||
|
|
||||||
|
$baselineParticipant = new ParticipantDto();
|
||||||
|
$baselineParticipant->index = 0;
|
||||||
|
$baselineParticipant->skiPass = $held;
|
||||||
|
|
||||||
|
$baselineDto = new BookingDto($travel, 1);
|
||||||
|
$baselineDto->participants = [$baselineParticipant];
|
||||||
|
|
||||||
|
$processor = $this->createStub(BookingDataProcessor::class);
|
||||||
|
$processor->method('createBookingDtoFromBooking')->willReturn($baselineDto);
|
||||||
|
|
||||||
|
$guard = new BookingEditSubmitGuard($processor, new ServiceAvailabilityCalculator());
|
||||||
|
|
||||||
|
$reverted = $guard->revertUnbookableServiceAdditions($workingDto, $freshBooking);
|
||||||
|
|
||||||
|
$this->assertSame([], $reverted);
|
||||||
|
$this->assertSame($onRequest, $workingParticipant->skiPass);
|
||||||
|
}
|
||||||
|
|
||||||
private function createService(int $id): Service
|
private function createService(int $id): Service
|
||||||
{
|
{
|
||||||
$service = new Service();
|
$service = new Service();
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ class DatabaseAnonymizerTest extends TestCase
|
|||||||
);
|
);
|
||||||
|
|
||||||
$user = new User('[email protected]');
|
$user = new User('[email protected]');
|
||||||
|
$user->setFirstName('Mia');
|
||||||
|
$user->setLastName('Muster');
|
||||||
$user->setPassword('plain-text-password');
|
$user->setPassword('plain-text-password');
|
||||||
$user->setPersonId(12345);
|
$user->setPersonId(12345);
|
||||||
$user->setAddressId(67890);
|
$user->setAddressId(67890);
|
||||||
@@ -162,6 +164,10 @@ class DatabaseAnonymizerTest extends TestCase
|
|||||||
$service->anonymizeAccommodationBooking($booking);
|
$service->anonymizeAccommodationBooking($booking);
|
||||||
|
|
||||||
self::assertStringEndsWith('@example.test', $user->getEmail());
|
self::assertStringEndsWith('@example.test', $user->getEmail());
|
||||||
|
self::assertNotSame('Mia', $user->getFirstName());
|
||||||
|
self::assertNotSame('Muster', $user->getLastName());
|
||||||
|
self::assertNotNull($user->getFirstName());
|
||||||
|
self::assertNotNull($user->getLastName());
|
||||||
self::assertSame('plain-text-password', $user->getPassword());
|
self::assertSame('plain-text-password', $user->getPassword());
|
||||||
self::assertSame(12345, $user->getPersonId());
|
self::assertSame(12345, $user->getPersonId());
|
||||||
self::assertSame(67890, $user->getAddressId());
|
self::assertSame(67890, $user->getAddressId());
|
||||||
@@ -254,4 +260,46 @@ class DatabaseAnonymizerTest extends TestCase
|
|||||||
self::assertNull($booking->getCity());
|
self::assertNull($booking->getCity());
|
||||||
self::assertNull($booking->getRemarks());
|
self::assertNull($booking->getRemarks());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Most accounts carry no name until the BusPro import fills one in. Minting one here would
|
||||||
|
* turn an incomplete profile into one that reads as complete while profileComplete still
|
||||||
|
* says otherwise.
|
||||||
|
*/
|
||||||
|
public function testAnonymizeUserLeavesUnsetNamesNull(): void
|
||||||
|
{
|
||||||
|
$service = new DatabaseAnonymizer(
|
||||||
|
$this->createStub(EntityManagerInterface::class),
|
||||||
|
new NullLogger(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$user = new User('[email protected]');
|
||||||
|
|
||||||
|
$service->anonymizeUser($user);
|
||||||
|
|
||||||
|
self::assertStringEndsWith('@example.test', (string) $user->getEmail());
|
||||||
|
self::assertNull($user->getFirstName());
|
||||||
|
self::assertNull($user->getLastName());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two accounts of the same person are an intended state, and User::$email is unique. The
|
||||||
|
* identity is therefore looked up by email alone: resolving it by name too would hand both
|
||||||
|
* accounts the same synthetic email and break the run on a constraint violation.
|
||||||
|
*/
|
||||||
|
public function testAnonymizeUserGivesTwoAccountsOfTheSamePersonDistinctEmails(): void
|
||||||
|
{
|
||||||
|
$service = new DatabaseAnonymizer(
|
||||||
|
$this->createStub(EntityManagerInterface::class),
|
||||||
|
new NullLogger(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$private = (new User('[email protected]'))->setFirstName('Mia')->setLastName('Muster');
|
||||||
|
$staff = (new User('[email protected]'))->setFirstName('Mia')->setLastName('Muster');
|
||||||
|
|
||||||
|
$service->anonymizeUser($private);
|
||||||
|
$service->anonymizeUser($staff);
|
||||||
|
|
||||||
|
self::assertNotSame($private->getEmail(), $staff->getEmail());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Tests\Service;
|
namespace App\Tests\Service;
|
||||||
|
|
||||||
use App\BusProNet\Constants;
|
use App\BusProNet\Constants;
|
||||||
|
use App\BusProNet\Model\Booking;
|
||||||
use App\BusProNet\Model\Service;
|
use App\BusProNet\Model\Service;
|
||||||
use App\BusProNet\Model\Travel;
|
use App\BusProNet\Model\Travel;
|
||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
@@ -85,6 +86,95 @@ class ServiceAvailabilityCalculatorTest extends TestCase
|
|||||||
$this->assertSame([1, 4], array_keys($filtered));
|
$this->assertSame([1, 4], array_keys($filtered));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testOnRequestServiceStaysAvailableInCreateMode(): void
|
||||||
|
{
|
||||||
|
$skiPass = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST);
|
||||||
|
$bookingDto = $this->createBookingDtoWithServices([$skiPass]);
|
||||||
|
|
||||||
|
// Create may still move the whole booking to status 'A', so on request is bookable there
|
||||||
|
$this->assertFalse($this->calculator->isServiceUnavailable(1, $bookingDto, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOnRequestServiceIsUnavailableInEditMode(): void
|
||||||
|
{
|
||||||
|
$skiPass = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST);
|
||||||
|
$bookingDto = $this->createEditBookingDtoWithServices([$skiPass]);
|
||||||
|
|
||||||
|
$this->assertTrue($this->calculator->isServiceUnavailable(1, $bookingDto, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOnRequestServiceAlreadyHeldStaysAvailableInEditMode(): void
|
||||||
|
{
|
||||||
|
$skiPass = $this->createSkiPass(id: 1, available: 0, status: Constants::STATUS_ON_REQUEST);
|
||||||
|
$bookingDto = $this->createEditBookingDtoWithServices([$skiPass], heldServiceIds: [1]);
|
||||||
|
|
||||||
|
// Withdrawing a booked service breaks the Leistung/Teilnehmer counts, so it must be keepable
|
||||||
|
$this->assertFalse($this->calculator->isServiceUnavailable(1, $bookingDto, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAutoBookedOnRequestServiceStaysAvailableInEditMode(): void
|
||||||
|
{
|
||||||
|
$service = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST);
|
||||||
|
$service->autoBook = true;
|
||||||
|
$bookingDto = $this->createEditBookingDtoWithServices([$service]);
|
||||||
|
|
||||||
|
$this->assertFalse($this->calculator->isServiceUnavailable(1, $bookingDto, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMandatoryNonTransportOnRequestServiceStaysAvailableInEditMode(): void
|
||||||
|
{
|
||||||
|
$service = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST);
|
||||||
|
$service->mandatory = true;
|
||||||
|
$service->category = Constants::CATEGORY_ADDITIONAL;
|
||||||
|
$bookingDto = $this->createEditBookingDtoWithServices([$service]);
|
||||||
|
|
||||||
|
// Blocking a service the mandatory-services validator requires makes the form unsatisfiable
|
||||||
|
$this->assertFalse($this->calculator->isServiceUnavailable(1, $bookingDto, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMandatoryTransportOnRequestServiceIsStillUnavailableInEditMode(): void
|
||||||
|
{
|
||||||
|
$service = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST);
|
||||||
|
$service->mandatory = true;
|
||||||
|
$service->category = Constants::CATEGORY_TRANSPORTATION;
|
||||||
|
$bookingDto = $this->createEditBookingDtoWithServices([$service]);
|
||||||
|
|
||||||
|
// pflicht on a transport leg means "pick one of this group", not "compulsory"
|
||||||
|
$this->assertTrue($this->calculator->isServiceUnavailable(1, $bookingDto, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFilterAvailableServicesNeverEmptiesAGroupOnTheOnRequestRuleAlone(): void
|
||||||
|
{
|
||||||
|
$first = $this->createSkiPass(id: 1, available: 10, status: Constants::STATUS_ON_REQUEST);
|
||||||
|
$second = $this->createSkiPass(id: 2, available: 10, status: Constants::STATUS_ON_REQUEST);
|
||||||
|
|
||||||
|
$bookingDto = $this->createEditBookingDtoWithServices([$first, $second]);
|
||||||
|
|
||||||
|
$filtered = $this->calculator->filterAvailableServices(
|
||||||
|
$bookingDto->travel->additionalServices,
|
||||||
|
$bookingDto,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame([1, 2], array_keys($filtered));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFilterAvailableServicesStillEmptiesAGroupThatIsSoldOutOrBlocked(): void
|
||||||
|
{
|
||||||
|
$soldOut = $this->createSkiPass(id: 1, available: 0, status: Constants::STATUS_ON_REQUEST);
|
||||||
|
$blocked = $this->createSkiPass(id: 2, available: null, status: Constants::STATUS_BLOCKED);
|
||||||
|
|
||||||
|
$bookingDto = $this->createEditBookingDtoWithServices([$soldOut, $blocked]);
|
||||||
|
|
||||||
|
$filtered = $this->calculator->filterAvailableServices(
|
||||||
|
$bookingDto->travel->additionalServices,
|
||||||
|
$bookingDto,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame([], array_keys($filtered));
|
||||||
|
}
|
||||||
|
|
||||||
private function createParkingService(int $id, int $available): Service
|
private function createParkingService(int $id, int $available): Service
|
||||||
{
|
{
|
||||||
$service = new Service();
|
$service = new Service();
|
||||||
@@ -123,4 +213,27 @@ class ServiceAvailabilityCalculatorTest extends TestCase
|
|||||||
|
|
||||||
return $bookingDto;
|
return $bookingDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, Service> $services
|
||||||
|
* @param list<int> $heldServiceIds IDs participant 0 already holds in the booking
|
||||||
|
*/
|
||||||
|
private function createEditBookingDtoWithServices(array $services, array $heldServiceIds = []): BookingDto
|
||||||
|
{
|
||||||
|
$bookingDto = $this->createBookingDtoWithServices($services);
|
||||||
|
|
||||||
|
$heldServices = [];
|
||||||
|
foreach ($heldServiceIds as $serviceId) {
|
||||||
|
$held = clone $bookingDto->travel->additionalServices[$serviceId];
|
||||||
|
$held->mapping = [0];
|
||||||
|
$heldServices[$serviceId] = $held;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A non-null booking is what puts the DTO into edit mode
|
||||||
|
$booking = new Booking();
|
||||||
|
$booking->additionalServices = $heldServices;
|
||||||
|
$bookingDto->booking = $booking;
|
||||||
|
|
||||||
|
return $bookingDto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class AppRuntimeRoleLabelsTest extends TestCase
|
|||||||
public function testEffectiveRolesAreLabelled(): void
|
public function testEffectiveRolesAreLabelled(): void
|
||||||
{
|
{
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
['Teamer:in', 'Preisrechner'],
|
['Teamer:in', 'GRO-Expert:in'],
|
||||||
$this->runtime()->effectiveRoles([Role::TEAMER, Role::GROUPS_MANAGER]),
|
$this->runtime()->effectiveRoles([Role::TEAMER, Role::GROUPS_MANAGER]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -26,10 +26,10 @@ class AppRuntimeRoleLabelsTest extends TestCase
|
|||||||
|
|
||||||
public function testNominationsAreListedApartFromTheEffectiveRoles(): void
|
public function testNominationsAreListedApartFromTheEffectiveRoles(): void
|
||||||
{
|
{
|
||||||
$roles = [Role::TEAMER, Role::pending(Role::ADMIN)];
|
$roles = [Role::TEAMER, Role::pending(Role::HOUSE_MANAGER)];
|
||||||
|
|
||||||
self::assertSame(['Teamer:in'], $this->runtime()->effectiveRoles($roles));
|
self::assertSame(['Teamer:in'], $this->runtime()->effectiveRoles($roles));
|
||||||
self::assertSame(['Administration'], $this->runtime()->nominatedRoles($roles));
|
self::assertSame(['Hausleitung'], $this->runtime()->nominatedRoles($roles));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testUnknownRoleStaysVisible(): void
|
public function testUnknownRoleStaysVisible(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user