Compare commits

..
15 Commits
Author SHA1 Message Date
fromme f48e53fdd0 fix: verify the oauth2 state before the error parameter 2026-09-23 17:48:24 +02:00
fromme 3f4586ce06 feat: harden the myep oauth2 client 2026-09-23 16:06:25 +02:00
frommeandClaude Opus 5 2a8649fc6d chore: run MariaDB 11.8 in ddev to match production
Development ran MySQL 8.4 while production runs MariaDB, so nothing engine-specific
was ever exercised on the engine it ships on.

DATABASE_URL carries serverVersion=mariadb-* now, which is what makes DBAL pick its
MariaDB platform. This matters most for the test environment: it reads DATABASE_URL
from the committed .env and not from .env.dev.local, so a MySQL version there had the
suite running on MySQL84Platform against a MariaDB server - and schema:validate
reporting every nullable and every json column as drift. With the platform right, a
db_test built from the mapping is in sync.

The post-start hooks create db_test and grant the db user, which ddev does not do on
its own, and sync the schema from the entity mapping. Without them the first test run
after a fresh start fails on the missing database.

The remaining changes in config.yaml are ddev's own regeneration of the commented
reference section.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-23 09:02:56 +02:00
fromme 161cfb9e62 chore: exclude secrets file from repository 2026-09-23 08:06:45 +02:00
fromme eac125517f fix: use a SortDirection instance for the availabilities ordering 2026-09-21 12:20:55 +02:00
fromme af46c57bd1 feat: normalize email addresses to lowercase on write 2026-09-21 12:20:55 +02:00
fromme d6327f033e fix: stagger cron triggered tasks between prod and staging 2026-09-20 13:32:48 +02:00
fromme 752db081b3 chore: update project dependencies 2026-09-20 13:32:23 +02:00
fromme 518f3a46d0 test: adopt the ROLE_TEAM_ADMIN label rename 2026-09-19 11:36:23 +02:00
fromme a05143bfbe fix: make BusProNet response failures diagnosable and non-fatal 2026-09-19 11:36:14 +02:00
fromme 29cedd5916 chore: disable unused ddev ssh-agent 2026-09-17 08:31:29 +02:00
fromme 5c57dc823f fix: add missing access checks 2026-09-16 18:03:04 +02:00
fromme d4908b6ab9 feat: rename ROLE_ADMIN to ROLE_TEAM_ADMIN since being too broad 2026-09-16 17:20:45 +02:00
fromme 39a343911a chore: update project dependencies 2026-09-16 17:15:31 +02:00
fromme 66d560f98d fix: pass query parameters individually to querybuilder 2026-09-16 17:14:24 +02:00
112 changed files with 1131 additions and 412 deletions
+50 -28
View File
@@ -1,3 +1,4 @@
name: myep-team
type: php
docroot: public
php_version: "8.4"
@@ -6,8 +7,20 @@ xdebug_enabled: false
additional_hostnames: []
additional_fqdns: []
database:
type: mysql
version: "8.4"
type: mariadb
version: "11.8"
hooks:
# The agent hooks in config.codex-claude.yaml are merged with these, not replaced by them.
post-start:
# ddev creates "db" alone, so the test database has to be created and granted here.
# Without it the first test run after a fresh start fails with "Access denied for user
# 'db'@'%' to database 'db_test'".
- exec: mysql -uroot -proot -hdb -e "CREATE DATABASE IF NOT EXISTS db_test CHARACTER SET utf8mb4; GRANT ALL ON db_test.* TO 'db'@'%';"
# Schema from the entity mapping, never from the migrations: the squashed history is not
# replayable from an empty database (Version20260824124850 alters a table that
# Version20260901090029 only creates later). No fixtures - a test persists its own rows.
- exec: php bin/console doctrine:schema:update --force --env=test --no-interaction
omit_containers: [ddev-ssh-agent]
use_dns_when_possible: true
timezone: Europe/Berlin
composer_version: "2"
@@ -15,7 +28,6 @@ web_environment: []
nodejs_version: "18"
corepack_enable: false
disable_upload_dirs_warning: true
xhgui_https_port: "8142"
xhgui_http_port: "8143"
@@ -26,16 +38,16 @@ xhgui_http_port: "8143"
# If the name is omitted, the project will take the name of the enclosing directory,
# which is useful if you want to have a copy of the project side by side with this one.
# type: <projecttype> # backdrop, cakephp, craftcms, drupal, drupal6, drupal7, drupal8, drupal9, drupal10, drupal11, generic, laravel, magento, magento2, php, shopware6, silverstripe, symfony, typo3, wordpress
# type: <projecttype> # asterios, backdrop, cakephp, codeigniter, craftcms, drupal, drupal6, drupal7, drupal8, drupal9, drupal10, drupal11, drupal12, generic, joomla, laravel, magento, magento2, maho, modx, php, shopware6, silverstripe, symfony, typo3, wordpress, wp-bedrock
# See https://docs.ddev.com/en/stable/users/quickstart/ for more
# information on the different project types
# docroot: <relative_path> # Relative path to the directory containing index.php.
# php_version: "8.3" # PHP version to use, "5.6" through "8.5"
# php_version: "8.4" # PHP version to use, "5.6" through "8.5"
# You can explicitly specify the webimage but this
# is not recommended, as the images are often closely tied to DDEV's' behavior,
# is not recommended, as the images are often closely tied to DDEV's behavior,
# so this can break upgrades.
# webimage: <docker_image>
@@ -45,10 +57,18 @@ xhgui_http_port: "8143"
# database:
# type: <dbtype> # mysql, mariadb, postgres
# version: <version> # database version, like "10.11" or "8.0"
# MariaDB versions can be 5.5-10.8, 10.11, 11.4, 11.8
# MySQL versions can be 5.5-8.0, 8.4
# MariaDB versions can be 5.5-10.8, 10.11, 11.4, 11.8, 12.3
# MySQL versions can be 5.5-8.0, 8.4, 9.7
# PostgreSQL versions can be 9-18
# You can explicitly specify the dbimage but this
# is not recommended, as the images are often closely tied to DDEV's behavior,
# so this can break upgrades.
# dbimage: <docker_image>
# Its unusual to change this option, and we dont recommend it without Docker experience and a good reason.
# Typically, this means additions to the existing db image using a .ddev/db-build/Dockerfile.*
# router_http_port: <port> # Port to be used for http (defaults to global configuration, usually 80)
# router_https_port: <port> # Port for https (defaults to global configuration, usually 443)
@@ -68,8 +88,7 @@ xhgui_http_port: "8143"
# bind_all_ports is used (normally with router disabled)
# xhprof_mode: [prepend|xhgui|global]
# Set to "xhgui" to enable XHGui features
# "xhgui" will become default in a future major release
# Default is "xhgui"
# webserver_type: nginx-fpm, apache-fpm, generic
@@ -87,22 +106,24 @@ xhgui_http_port: "8143"
# commands are executed.
# composer_version: "2"
# You can set it to "" or "2" (default) for Composer v2 or "1" for Composer v1
# You can set it to "" or "2" (default) for Composer v2
# to use the latest major version available at the time your container is built.
# It is also possible to use each other Composer version channel. This includes:
# It is also possible to use any other Composer version channel. This includes:
# - 2.2 (latest Composer LTS version)
# - stable
# - preview
# - snapshot
# Alternatively, an explicit Composer version may be specified, for example "2.2.18".
# To reinstall Composer after the image was built, run "ddev debug rebuild".
# To reinstall Composer after the image was built, run "ddev utility rebuild".
# nodejs_version: "22"
# nodejs_root: <relative_path>
# Relative path to the directory containing the Node.js version file from the
# project root. Only used with "nodejs_version: auto" or "nodejs_version: engine".
# nodejs_version: "24"
# change from the default system Node.js version to any other version.
# See https://docs.ddev.com/en/stable/users/configuration/config/#nodejs_version for more information
# and https://www.npmjs.com/package/n#specifying-nodejs-versions for the full documentation,
# Note that using of 'ddev nvm' is discouraged because "nodejs_version" is much easier to use,
# can specify any version, and is more robust than using 'nvm'.
# and https://www.npmjs.com/package/n#specifying-nodejs-versions for the full documentation.
# corepack_enable: false
# Change to 'true' to 'corepack enable' and gain access to latest versions of yarn/pnpm
@@ -162,9 +183,7 @@ xhgui_http_port: "8143"
# - "global": uses the value from the global config.
# - "none": disables performance optimization for this project.
# - "mutagen": enables Mutagen for this project.
# - "nfs": enables NFS for this project.
#
# See https://docs.ddev.com/en/stable/users/install/performance/#nfs
# See https://docs.ddev.com/en/stable/users/install/performance/#mutagen
# fail_on_hook_fail: False
@@ -194,10 +213,10 @@ xhgui_http_port: "8143"
# The mailpit port is not normally bound on the host at all, instead being routed
# through ddev-router, but it can be bound directly to localhost if specified here.
# webimage_extra_packages: [php7.4-tidy, php-bcmath]
# webimage_extra_packages: ['php${DDEV_PHP_VERSION}-tidy', 'php${DDEV_PHP_VERSION}-yac']
# Extra Debian packages that are needed in the webimage can be added here
# dbimage_extra_packages: [telnet,netcat]
# dbimage_extra_packages: [netcat, telnet, sudo]
# Extra Debian packages that are needed in the dbimage can be added here
# use_dns_when_possible: true
@@ -209,12 +228,15 @@ xhgui_http_port: "8143"
# project_tld: ddev.site
# The top-level domain used for project URLs
# The default "ddev.site" allows DNS lookup via a wildcard
# If you prefer you can change this to "ddev.local" to preserve
# pre-v1.9 behavior.
# ngrok_args: --basic-auth username:pass1234
# Provide extra flags to the "ngrok http" command, see
# https://ngrok.com/docs/agent/config/v3/#agent-configuration or run "ngrok http -h"
# share_default_provider: ngrok
# The default share provider to use for "ddev share"
# Defaults to global configuration, usually "ngrok"
# Can be "ngrok" or "cloudflared" or the name of a custom provider from .ddev/share-providers/
# share_provider_args: --basic-auth username:pass1234
# Provide extra flags to the share provider script
# See https://docs.ddev.com/en/stable/users/configuration/config/#share_provider_args
# disable_settings_management: false
# If true, DDEV will not create CMS-specific settings files like
@@ -276,7 +298,7 @@ xhgui_http_port: "8143"
# override_config: false
# By default, config.*.yaml files are *merged* into the configuration
# But this means that some things can't be overridden
# For example, if you have 'use_dns_when_possible: true'' you can't override it with a merge
# For example, if you have 'use_dns_when_possible: true' you can't override it with a merge
# and you can't erase existing hooks or all environment variables.
# However, with "override_config: true" in a particular config.*.yaml file,
# 'use_dns_when_possible: false' can override the existing values, and
@@ -286,7 +308,7 @@ xhgui_http_port: "8143"
# web_environment: []
# or
# additional_hostnames: []
# can have their intended affect. 'override_config' affects only behavior of the
# can have their intended effect. 'override_config' affects only behavior of the
# config.*.yaml file it exists in.
# Many DDEV commands can be extended to run tasks before or after the
+10 -2
View File
@@ -27,7 +27,10 @@ APP_SECRET=b3cb0285aea14bbffb57df0cc3aa84f4
# DATABASE_URL="mysql://app:[email protected]:3306/app?serverVersion=8.0.32&charset=utf8mb4"
# DATABASE_URL="mysql://app:[email protected]:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
# DATABASE_URL="postgresql://app:[email protected]:5432/app?serverVersion=15&charset=utf8"
DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=8.4.5&charset=utf8mb4"
# ddev runs MariaDB to match production. The serverVersion is what makes DBAL pick its
# MariaDB platform - left at a MySQL version it speaks MySQL to a MariaDB server, and the
# test suite then runs against a different platform than production.
DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=mariadb-11.8.9&charset=utf8mb4"
###< doctrine/doctrine-bundle ###
###> symfony/messenger ###
@@ -46,6 +49,11 @@ MAILING_MAILER_DSN=null://null
APP_BASE_URI=https://myep-team.ddev.site
# Regex matched against the Host header; requests for any other host are refused. The
# OAuth2 redirect_uri is generated from the request, so this is what pins it. Production
# sets its own value outside the repository, like APP_BASE_URI above.
APP_TRUSTED_HOSTS=^myep-team\.ddev\.site$
APP_BPN_USER=
APP_BPN_PASSWORD=
APP_BPN_IP=
@@ -55,7 +63,7 @@ APP_BPN_DEBUG=false
# This hotel code will be assigned to admin users together with ROLE_HOTEL_MANAGER
# in dev and staging environments for testing purposes
APP_BPN_DEFAULT_HOTEL_CODE=
APP_BPN_CRM_ID_ADMIN=1292
APP_BPN_CRM_ID_TEAM_ADMIN=1484
APP_BPN_CRM_ID_MANAGER=1293
APP_BPN_CRM_ID_TEAMER=1070
+2
View File
@@ -4,3 +4,5 @@ APP_SECRET='$ecretf0rt3st'
SYMFONY_DEPRECATIONS_HELPER=999999
PANTHER_APP_ENV=panther
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots
# the test client requests http://localhost/
APP_TRUSTED_HOSTS='^localhost$'
+2
View File
@@ -35,3 +35,5 @@ yarn-error.log
/.php-cs-fixer.php
/.php-cs-fixer.cache
###< friendsofphp/php-cs-fixer ###
/http-client.private.env.json
Generated
+176 -140
View File
@@ -1757,26 +1757,27 @@
},
{
"name": "guzzlehttp/guzzle",
"version": "7.15.5",
"version": "8.2.0",
"source": {
"type": "git",
"url": "https://github.com/guzzle/guzzle.git",
"reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a"
"reference": "93939470950a9b11e2e84204166ef5e048c55fe4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/ee80339fd9177ba44c49cdb653ff02a4d1106b9a",
"reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a",
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/93939470950a9b11e2e84204166ef5e048c55fe4",
"reference": "93939470950a9b11e2e84204166ef5e048c55fe4",
"shasum": ""
},
"require": {
"ext-json": "*",
"guzzlehttp/promises": "^2.5.3",
"guzzlehttp/psr7": "^2.13.1",
"php": "^7.2.5 || ^8.0",
"guzzlehttp/promises": "^3.0.2",
"guzzlehttp/psr7": "^3.1",
"php": "^7.4 || ^8.0",
"psr/http-client": "^1.0",
"symfony/deprecation-contracts": "^2.5 || ^3.0",
"symfony/polyfill-php80": "^1.25"
"psr/http-factory": "^1.0",
"symfony/polyfill-php80": "^1.25",
"symfony/polyfill-php82": "^1.27"
},
"provide": {
"psr/http-client-implementation": "1.0"
@@ -1784,10 +1785,10 @@
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"ext-curl": "*",
"guzzle/client-integration-tests": "3.0.3",
"guzzlehttp/test-server": "^0.7",
"guzzle/client-integration-tests": "4.0.1",
"guzzlehttp/test-server": "^1.0.1",
"php-http/message-factory": "^1.1",
"phpunit/phpunit": "^8.5.52 || ^9.6.34",
"phpunit/phpunit": "^9.6.34",
"psr/log": "^1.1 || ^2.0 || ^3.0"
},
"suggest": {
@@ -1803,9 +1804,6 @@
}
},
"autoload": {
"files": [
"src/functions_include.php"
],
"psr-4": {
"GuzzleHttp\\": "src/"
}
@@ -1865,7 +1863,7 @@
],
"support": {
"issues": "https://github.com/guzzle/guzzle/issues",
"source": "https://github.com/guzzle/guzzle/tree/7.15.5"
"source": "https://github.com/guzzle/guzzle/tree/8.2.0"
},
"funding": [
{
@@ -1881,29 +1879,28 @@
"type": "tidelift"
}
],
"time": "2026-08-24T09:21:06+00:00"
"time": "2026-09-06T13:55:09+00:00"
},
{
"name": "guzzlehttp/promises",
"version": "2.5.3",
"version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/guzzle/promises.git",
"reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1"
"reference": "42118e66a53c492effaf92bc357e931985d5c6f9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/promises/zipball/cde49999552d185d64715fe9c1f77a2aadd2f9f1",
"reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1",
"url": "https://api.github.com/repos/guzzle/promises/zipball/42118e66a53c492effaf92bc357e931985d5c6f9",
"reference": "42118e66a53c492effaf92bc357e931985d5c6f9",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0",
"symfony/deprecation-contracts": "^2.5 || ^3.0"
"php": "^7.4 || ^8.0"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"phpunit/phpunit": "^8.5.52 || ^9.6.34"
"phpunit/phpunit": "^9.6.34"
},
"type": "library",
"extra": {
@@ -1949,7 +1946,7 @@
],
"support": {
"issues": "https://github.com/guzzle/promises/issues",
"source": "https://github.com/guzzle/promises/tree/2.5.3"
"source": "https://github.com/guzzle/promises/tree/3.0.2"
},
"funding": [
{
@@ -1965,39 +1962,39 @@
"type": "tidelift"
}
],
"time": "2026-08-24T09:11:28+00:00"
"time": "2026-08-24T10:00:26+00:00"
},
{
"name": "guzzlehttp/psr7",
"version": "2.13.1",
"version": "3.1.0",
"source": {
"type": "git",
"url": "https://github.com/guzzle/psr7.git",
"reference": "95e7828100de18b4e269fb1703be530082d5166d"
"reference": "a3059ba1a84c9139c4ae03cf0f45bea276c97c74"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/psr7/zipball/95e7828100de18b4e269fb1703be530082d5166d",
"reference": "95e7828100de18b4e269fb1703be530082d5166d",
"url": "https://api.github.com/repos/guzzle/psr7/zipball/a3059ba1a84c9139c4ae03cf0f45bea276c97c74",
"reference": "a3059ba1a84c9139c4ae03cf0f45bea276c97c74",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0",
"psr/http-factory": "^1.0",
"psr/http-message": "^1.1 || ^2.0",
"ralouphie/getallheaders": "^3.0",
"symfony/deprecation-contracts": "^2.5 || ^3.0",
"symfony/polyfill-php80": "^1.25"
"php": "^7.4 || ^8.0",
"psr/http-factory": "^1.1",
"psr/http-message": "^2.0",
"symfony/polyfill-php80": "^1.25",
"symfony/polyfill-php82": "^1.27"
},
"provide": {
"psr/http-factory-implementation": "1.0",
"psr/http-message-implementation": "1.0"
"psr/http-factory-implementation": "1.1",
"psr/http-message-implementation": "2.0"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"http-interop/http-factory-tests": "1.1.0",
"jshttp/mime-db": "1.54.0.1",
"phpunit/phpunit": "^8.5.52 || ^9.6.34"
"php-http/psr7-integration-tests": "^1.5.1",
"phpunit/phpunit": "^9.6.34"
},
"suggest": {
"laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
@@ -2068,7 +2065,7 @@
],
"support": {
"issues": "https://github.com/guzzle/psr7/issues",
"source": "https://github.com/guzzle/psr7/tree/2.13.1"
"source": "https://github.com/guzzle/psr7/tree/3.1.0"
},
"funding": [
{
@@ -2084,7 +2081,7 @@
"type": "tidelift"
}
],
"time": "2026-08-24T09:13:11+00:00"
"time": "2026-08-24T11:02:13+00:00"
},
{
"name": "imagine/imagine",
@@ -2953,21 +2950,22 @@
},
{
"name": "league/oauth2-client",
"version": "2.9.0",
"version": "2.9.1",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/oauth2-client.git",
"reference": "26e8c5da4f3d78cede7021e09b1330a0fc093d5e"
"reference": "8cedfef9d01a8d1fd2ecfabb41734b17592c4b71"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/26e8c5da4f3d78cede7021e09b1330a0fc093d5e",
"reference": "26e8c5da4f3d78cede7021e09b1330a0fc093d5e",
"url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/8cedfef9d01a8d1fd2ecfabb41734b17592c4b71",
"reference": "8cedfef9d01a8d1fd2ecfabb41734b17592c4b71",
"shasum": ""
},
"require": {
"ext-json": "*",
"guzzlehttp/guzzle": "^6.5.8 || ^7.4.5",
"guzzlehttp/guzzle": "^6.5.8 || ^7.8.2 || ^8.0",
"guzzlehttp/psr7": "^1.9.1 || ^2.6.3 || ^3.0",
"php": "^7.1 || >=8.0.0 <8.6.0"
},
"require-dev": {
@@ -3012,9 +3010,9 @@
],
"support": {
"issues": "https://github.com/thephpleague/oauth2-client/issues",
"source": "https://github.com/thephpleague/oauth2-client/tree/2.9.0"
"source": "https://github.com/thephpleague/oauth2-client/tree/2.9.1"
},
"time": "2025-11-25T22:17:17+00:00"
"time": "2026-09-16T13:14:31+00:00"
},
{
"name": "liip/imagine-bundle",
@@ -4139,16 +4137,16 @@
},
{
"name": "phpoffice/phpspreadsheet",
"version": "5.9.0",
"version": "5.10.0",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339"
"reference": "eb18727acf6b1f4cc67145a52ab04f9fd4c28d53"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/05e99ebf61238a70227b4d9cc02d0030d34f6339",
"reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/eb18727acf6b1f4cc67145a52ab04f9fd4c28d53",
"reference": "eb18727acf6b1f4cc67145a52ab04f9fd4c28d53",
"shasum": ""
},
"require": {
@@ -4177,6 +4175,7 @@
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
"dompdf/dompdf": "^2.0 || ^3.0",
"ext-intl": "*",
"ext-openssl": "*",
"friendsofphp/php-cs-fixer": "^3.2",
"mitoteam/jpgraph": "^10.5",
"mpdf/mpdf": "^8.1.1",
@@ -4186,11 +4185,12 @@
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
"phpunit/phpunit": "^10.5 || ^11.0",
"squizlabs/php_codesniffer": "^3.7",
"tecnickcom/tcpdf": "^6.5"
"tecnickcom/tcpdf": ">=6.8.0 <7.0.0"
},
"suggest": {
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
"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",
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
@@ -4242,9 +4242,9 @@
],
"support": {
"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",
@@ -4920,50 +4920,6 @@
},
"time": "2021-10-29T13:26:27+00:00"
},
{
"name": "ralouphie/getallheaders",
"version": "3.0.3",
"source": {
"type": "git",
"url": "https://github.com/ralouphie/getallheaders.git",
"reference": "120b605dfeb996808c31b6477290a714d356e822"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822",
"reference": "120b605dfeb996808c31b6477290a714d356e822",
"shasum": ""
},
"require": {
"php": ">=5.6"
},
"require-dev": {
"php-coveralls/php-coveralls": "^2.1",
"phpunit/phpunit": "^5 || ^6.5"
},
"type": "library",
"autoload": {
"files": [
"src/getallheaders.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ralph Khattar",
"email": "[email protected]"
}
],
"description": "A polyfill for getallheaders.",
"support": {
"issues": "https://github.com/ralouphie/getallheaders/issues",
"source": "https://github.com/ralouphie/getallheaders/tree/develop"
},
"time": "2019-03-08T08:55:37+00:00"
},
{
"name": "scienta/doctrine-json-functions",
"version": "6.5.0",
@@ -8760,6 +8716,86 @@
],
"time": "2026-05-27T06:59:30+00:00"
},
{
"name": "symfony/polyfill-php82",
"version": "v1.38.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php82.git",
"reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b",
"reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Php82\\": ""
},
"classmap": [
"Resources/stubs"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "[email protected]"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill backporting some PHP 8.2+ features to lower PHP versions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php82/tree/v1.38.1"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-26T12:45:58+00:00"
},
{
"name": "symfony/polyfill-php83",
"version": "v1.41.0",
@@ -11358,16 +11394,16 @@
},
{
"name": "symfony/webpack-encore-bundle",
"version": "v2.4.1",
"version": "2.4.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/webpack-encore-bundle.git",
"reference": "cac8d6c722999c8add9272f9de6e8079628df4f5"
"reference": "0cbc3485f127cd9f85e395f8177607ef00535634"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/webpack-encore-bundle/zipball/cac8d6c722999c8add9272f9de6e8079628df4f5",
"reference": "cac8d6c722999c8add9272f9de6e8079628df4f5",
"url": "https://api.github.com/repos/symfony/webpack-encore-bundle/zipball/0cbc3485f127cd9f85e395f8177607ef00535634",
"reference": "0cbc3485f127cd9f85e395f8177607ef00535634",
"shasum": ""
},
"require": {
@@ -11410,7 +11446,7 @@
"description": "Integration of your Symfony app with Webpack Encore",
"support": {
"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": [
{
@@ -11430,7 +11466,7 @@
"type": "tidelift"
}
],
"time": "2026-06-24T07:21:58+00:00"
"time": "2026-09-17T12:45:02+00:00"
},
{
"name": "symfony/workflow",
@@ -11604,16 +11640,16 @@
},
{
"name": "twig/extra-bundle",
"version": "v3.24.0",
"version": "v3.29.0",
"source": {
"type": "git",
"url": "https://github.com/twigphp/twig-extra-bundle.git",
"reference": "6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9"
"reference": "aaa2993e19293a99240c4c61aa461d743b0dd569"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9",
"reference": "6a621fcb1f28aa9ea7b34a99047ae0cdf5b834c9",
"url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/aaa2993e19293a99240c4c61aa461d743b0dd569",
"reference": "aaa2993e19293a99240c4c61aa461d743b0dd569",
"shasum": ""
},
"require": {
@@ -11662,7 +11698,7 @@
"twig"
],
"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": [
{
@@ -11674,20 +11710,20 @@
"type": "tidelift"
}
],
"time": "2026-02-07T08:07:38+00:00"
"time": "2026-09-11T08:59:50+00:00"
},
{
"name": "twig/html-extra",
"version": "v3.28.0",
"version": "v3.29.0",
"source": {
"type": "git",
"url": "https://github.com/twigphp/html-extra.git",
"reference": "760893ed7bdd0a381e4e00004c6f6e26ad3881d7"
"reference": "7147611979df81edb78baf7d003860b72e335c3f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/twigphp/html-extra/zipball/760893ed7bdd0a381e4e00004c6f6e26ad3881d7",
"reference": "760893ed7bdd0a381e4e00004c6f6e26ad3881d7",
"url": "https://api.github.com/repos/twigphp/html-extra/zipball/7147611979df81edb78baf7d003860b72e335c3f",
"reference": "7147611979df81edb78baf7d003860b72e335c3f",
"shasum": ""
},
"require": {
@@ -11730,7 +11766,7 @@
"twig"
],
"support": {
"source": "https://github.com/twigphp/html-extra/tree/v3.28.0"
"source": "https://github.com/twigphp/html-extra/tree/v3.29.0"
},
"funding": [
{
@@ -11742,20 +11778,20 @@
"type": "tidelift"
}
],
"time": "2026-06-25T06:50:01+00:00"
"time": "2026-09-06T20:35:36+00:00"
},
{
"name": "twig/intl-extra",
"version": "v3.26.0",
"version": "v3.29.0",
"source": {
"type": "git",
"url": "https://github.com/twigphp/intl-extra.git",
"reference": "98f5ad5bff13230fcd2d834d9e79b50adf3ccda9"
"reference": "51c52470aca59f3a9715c88f6fe505133b88c752"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/twigphp/intl-extra/zipball/98f5ad5bff13230fcd2d834d9e79b50adf3ccda9",
"reference": "98f5ad5bff13230fcd2d834d9e79b50adf3ccda9",
"url": "https://api.github.com/repos/twigphp/intl-extra/zipball/51c52470aca59f3a9715c88f6fe505133b88c752",
"reference": "51c52470aca59f3a9715c88f6fe505133b88c752",
"shasum": ""
},
"require": {
@@ -11794,7 +11830,7 @@
"twig"
],
"support": {
"source": "https://github.com/twigphp/intl-extra/tree/v3.26.0"
"source": "https://github.com/twigphp/intl-extra/tree/v3.29.0"
},
"funding": [
{
@@ -11806,7 +11842,7 @@
"type": "tidelift"
}
],
"time": "2026-05-19T20:44:48+00:00"
"time": "2026-09-06T20:35:36+00:00"
},
{
"name": "twig/string-extra",
@@ -11877,16 +11913,16 @@
},
{
"name": "twig/twig",
"version": "v3.28.0",
"version": "v3.29.0",
"source": {
"type": "git",
"url": "https://github.com/twigphp/Twig.git",
"reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b"
"reference": "45a3c6e9224c3377a39c7b150bb29d5d97d2c75d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/twigphp/Twig/zipball/597c12ed286fb9d1701a36684ce6e0cbe28ebc8b",
"reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b",
"url": "https://api.github.com/repos/twigphp/Twig/zipball/45a3c6e9224c3377a39c7b150bb29d5d97d2c75d",
"reference": "45a3c6e9224c3377a39c7b150bb29d5d97d2c75d",
"shasum": ""
},
"require": {
@@ -11941,7 +11977,7 @@
],
"support": {
"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": [
{
@@ -11953,7 +11989,7 @@
"type": "tidelift"
}
],
"time": "2026-07-03T20:44:34+00:00"
"time": "2026-09-18T09:10:14+00:00"
},
{
"name": "ua-parser/uap-php",
@@ -12672,16 +12708,16 @@
},
{
"name": "friendsofphp/php-cs-fixer",
"version": "v3.95.25",
"version": "v3.95.26",
"source": {
"type": "git",
"url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
"reference": "2cdfc1f3daf173d83a1ebb6177949b58c95de6ff"
"reference": "11839dfaf25718e1617c522c3dde01ebf96c875f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/2cdfc1f3daf173d83a1ebb6177949b58c95de6ff",
"reference": "2cdfc1f3daf173d83a1ebb6177949b58c95de6ff",
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/11839dfaf25718e1617c522c3dde01ebf96c875f",
"reference": "11839dfaf25718e1617c522c3dde01ebf96c875f",
"shasum": ""
},
"require": {
@@ -12715,15 +12751,15 @@
"require-dev": {
"facile-it/paraunit": "^1.3.1 || ^2.11.0",
"infection/infection": "^0.32.7",
"justinrainbow/json-schema": "^6.10.0",
"justinrainbow/json-schema": "^6.12.0",
"keradus/cli-executor": "^2.3",
"php-coveralls/php-coveralls": "^2.9.1",
"php-cs-fixer/phpunit-constraint-isidenticalstring": "^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",
"symfony/polyfill-php85": "^1.38",
"symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.1",
"symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.1"
"phpunit/phpunit": "^9.6.36 || ^10.5.64 || ^11.5.56 || ^12.5.35 || ^13.3.3",
"symfony/polyfill-php85": "^1.41",
"symfony/var-dumper": "^5.4.48 || ^6.4.45 || ^7.4.18 || ^8.1.7",
"symfony/yaml": "^5.4.53 || ^6.4.45 || ^7.4.18 || ^8.1.6"
},
"suggest": {
"ext-dom": "For handling output formats in XML",
@@ -12764,7 +12800,7 @@
],
"support": {
"issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues",
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.25"
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.26"
},
"funding": [
{
@@ -12772,7 +12808,7 @@
"type": "github"
}
],
"time": "2026-09-08T11:11:37+00:00"
"time": "2026-09-19T19:04:32+00:00"
},
{
"name": "marcocesarato/php-conventional-changelog",
+5
View File
@@ -5,6 +5,11 @@ framework:
http_method_override: false
handle_all_throwables: true
# The OAuth2 redirect_uri handed to MyE&P is generated from the incoming request, so an
# unvalidated Host header would let a crafted request point the authorization code
# somewhere else. Regex patterns, matched against the host without the scheme or port.
trusted_hosts: ['%env(APP_TRUSTED_HOSTS)%']
# Enables session support. Note that the session will ONLY be started if you read or write from it.
# Remove or comment this section to explicitly disable session support.
session:
+1 -1
View File
@@ -9,7 +9,7 @@ security:
property: email
role_hierarchy:
ROLE_ADMIN: [ ROLE_ADMINISTRATIVE ]
ROLE_TEAM_ADMIN: [ ROLE_ADMINISTRATIVE ]
ROLE_MANAGER: [ ROLE_ADMINISTRATIVE ]
firewalls:
+44 -16
View File
@@ -12,23 +12,51 @@ zenstruck_schedule:
enabled: true
to: [email protected]
tasks:
- task: app:bpn-import
frequency: '0 3 * * *'
description: "Imports dates, products and hotels from BusProNet"
when@prod:
zenstruck_schedule:
tasks:
- task: app:bpn-import
frequency: '0 3 * * *'
description: "Imports dates, products and hotels from BusProNet"
- task: app:cron
frequency: '0 1 * * *'
description: "Executes several tasks triggered on a daily basis"
- task: app:cron
frequency: '0 1 * * *'
description: "Executes several tasks triggered on a daily basis"
- task: app:teamer-status
frequency: '30 1 * * *'
description: "Updates status of teamers depending on their disposition"
- task: app:teamer-status
frequency: '30 1 * * *'
description: "Updates status of teamers depending on their disposition"
- task: oneup:uploader:clear-orphans
frequency: '30 * * * *'
description: "Removes orphaned uploads"
- task: oneup:uploader:clear-orphans
frequency: '30 * * * *'
description: "Removes orphaned uploads"
- task: app:cleanup:xml-dumps
frequency: "0 2 * * *"
description: "Removes outdated XML dumps of requests/responses to BPN API for debugging"
- task: app:cleanup:xml-dumps
frequency: '0 2 * * *'
description: "Removes outdated XML dumps of requests/responses to BPN API for debugging"
when@staging:
zenstruck_schedule:
mailer:
subject_prefix: "[E&P-Team STAGING]"
tasks:
- task: app:bpn-import
frequency: '30 3 * * *'
description: "Imports dates, products and hotels from BusProNet"
- task: app:cron
frequency: '30 2 * * *'
description: "Executes several tasks triggered on a daily basis"
- task: app:teamer-status
frequency: '30 1 * * *'
description: "Updates status of teamers depending on their disposition"
- task: oneup:uploader:clear-orphans
frequency: '30 * * * *'
description: "Removes orphaned uploads"
- task: app:cleanup:xml-dumps
frequency: '0 2 * * *'
description: "Removes outdated XML dumps of requests/responses to BPN API for debugging"
+2 -2
View File
@@ -1,6 +1,6 @@
# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
parameters:
bpn_crm_id_admin: '%env(int:APP_BPN_CRM_ID_ADMIN)%'
bpn_crm_id_team_admin: '%env(int:APP_BPN_CRM_ID_TEAM_ADMIN)%'
bpn_crm_id_manager: '%env(int:APP_BPN_CRM_ID_MANAGER)%'
bpn_crm_id_teamer: '%env(int:APP_BPN_CRM_ID_TEAMER)%'
bpn_default_hotel_code: '%env(default::APP_BPN_DEFAULT_HOTEL_CODE)%'
@@ -162,7 +162,7 @@ services:
App\BusProNet\ResponseParser:
arguments:
$options:
bpn_crm_id_admin: '%bpn_crm_id_admin%'
bpn_crm_id_team_admin: '%bpn_crm_id_team_admin%'
bpn_crm_id_manager: '%bpn_crm_id_manager%'
bpn_crm_id_teamer: '%bpn_crm_id_teamer%'
bpn_crm_house_manager_ids: '%bpn_crm_house_manager_ids%'
+44 -12
View File
@@ -30,17 +30,35 @@ assign:
| Role | Label | Granted by | Revoked by | Hierarchy |
|------|-------|-----------|------------|-----------|
| `ROLE_ADMIN` | Admin | super admin, approving a CRM claim | the CRM, automatically | ⇒ `ROLE_ADMINISTRATIVE` |
| `ROLE_TEAM_ADMIN` | Admin | super admin, approving a CRM claim | the CRM, automatically | ⇒ `ROLE_ADMINISTRATIVE` |
| `ROLE_MANAGER` | Reisemanager | super admin, approving a CRM claim | the CRM, automatically | ⇒ `ROLE_ADMINISTRATIVE` |
| `ROLE_HOUSE_MANAGER` | Hausleitung | super admin, approving a CRM claim | the CRM, automatically | — |
| `ROLE_TEAMER` | Teamer | the CRM, automatically | the CRM, automatically | — |
### A note on the `TEAM_` prefix
`ROLE_TEAM_ADMIN`, `ROLE_TEAM_ADMIN_PENDING` and `ROLE_TEAM_SUPER_ADMIN` were renamed from
`ROLE_ADMIN`, `ROLE_ADMIN_PENDING` and `ROLE_SUPER_ADMIN`. The MyE&P identity provider is
shared with a sibling portal that uses `ROLE_ADMIN` for a different privilege, so the plain
name was ambiguous across the estate; the prefix makes it unambiguously *this* application's
admin. The German label is unchanged — it still reads "Admin" everywhere in the UI.
Two consequences worth remembering:
- **`ROLE_ADMINISTRATIVE` is a different role and was not renamed.** It is granted only by
the hierarchy, never stored, and it shares the old `ROLE_ADMIN` prefix — so any
search-and-replace over role names must match on a word boundary
(`ROLE_TEAM_ADMIN(?![A-Z_])`) or it will corrupt ~100 call sites silently.
- **`ELIGIBLE_ROLES` in `MyEpAuthenticator` is a wire contract**, not an internal name: it is
compared directly against the IdP's `roles` claim. It only works while MyE&P emits
`ROLE_TEAM_ADMIN`, so the two sides have to move together.
`User::PENDING_ROLES` holds a marker for each of the three administrative roles, keyed by
the role it stands for:
| Marker | Meaning |
|--------|---------|
| `ROLE_ADMIN_PENDING` | the CRM claims this person is an admin, nobody has confirmed it |
| `ROLE_TEAM_ADMIN_PENDING` | the CRM claims this person is an admin, nobody has confirmed it |
| `ROLE_MANAGER_PENDING` | likewise for Reisemanager |
| `ROLE_HOUSE_MANAGER_PENDING` | likewise for Hausleitung |
@@ -50,10 +68,10 @@ effects are cosmetic (rendered as "Admin (nicht freigeschaltet)") and organisati
put the user on the approval list). `ROLE_TEAMER` has no marker: it needs no approval.
Two further roles are synthesized by `User::getRoles()` and never stored: `ROLE_USER` for
everybody, and `ROLE_SUPER_ADMIN` when the separate `superAdmin` boolean column is set. A
everybody, and `ROLE_TEAM_SUPER_ADMIN` when the separate `superAdmin` boolean column is set. A
validation callback (`User::validateSuperAdmin()`) refuses `superAdmin` without
`ROLE_ADMIN` alongside it — super admin is an elevation, never a standalone grant. The sync
enforces the same rule from the other side: revoking `ROLE_ADMIN` clears the flag, or the one
`ROLE_TEAM_ADMIN` alongside it — super admin is an elevation, never a standalone grant. The sync
enforces the same rule from the other side: revoking `ROLE_TEAM_ADMIN` clears the flag, or the one
role that outranks every check in the application would outlive the role it depends on.
### Storage and accessors
@@ -63,7 +81,7 @@ slice it, and picking the right one matters:
| Accessor | Returns |
|----------|---------|
| `getRoles()` | the column **plus** synthesized `ROLE_USER` / `ROLE_SUPER_ADMIN` — what Symfony authorises against |
| `getRoles()` | the column **plus** synthesized `ROLE_USER` / `ROLE_TEAM_SUPER_ADMIN` — what Symfony authorises against |
| `getAssignedRoles()` | only the four real roles from the column — what the sync works on |
| `getPendingRoles()` | only the markers |
| `getNominatedRoles()` | the roles behind those markers, as `role => label` — what an approver acts on |
@@ -77,7 +95,7 @@ slice it, and picking the right one matters:
| CRM attribute | Recognised by | Sets |
|---------------|---------------|------|
| admin | attribute id `%bpn_crm_id_admin%`, selected | `isAdmin` |
| admin | attribute id `%bpn_crm_id_team_admin%`, selected | `isAdmin` |
| Reisemanager | attribute id `%bpn_crm_id_manager%`, selected | `isManager` |
| teamer | attribute id `%bpn_crm_id_teamer%`, selected | `isTeamer` |
| Hausleitung | attribute id listed in `%bpn_crm_house_manager_ids%`, selected | `isHouseManager` + the hotel code that id maps to |
@@ -111,12 +129,26 @@ roles were revoked.
>
> | Parameter | Attribute |
> |-----------|-----------|
> | `APP_BPN_CRM_ID_ADMIN` | `Admin` |
> | `APP_BPN_CRM_ID_TEAM_ADMIN` | the team-admin selection — **not** the old portal-wide `Admin` (1292) |
> | `APP_BPN_CRM_ID_MANAGER` | `Manager` |
> | `APP_BPN_CRM_ID_TEAMER` | `E&P Teamer - allg. Merkmal` |
>
> Matching is by id and never by label, so `Preisrechner Admin` does not trip the admin flag.
>
> `APP_BPN_CRM_ID_TEAM_ADMIN` was renamed from `APP_BPN_CRM_ID_ADMIN` with the
> `ROLE_TEAM_ADMIN` rename, and its **value has to change too**. The old value 1292 is the
> portal-wide admin selection, which still means `ROLE_ADMIN` in the sibling portal — keeping
> it would have left this app granting its admin off the very selection the rename was meant
> to stop sharing. It ships as `0` — a valid int that matches no attribute, so the container
> boots but nobody is granted the role — and must be set to the new selection's id.
>
> **This is a hard cutover.** `revokeUnclaimedRoles()` withdraws any granted role the CRM no
> longer claims, and `revokeSuperAdminWithoutRoleAdmin()` takes the super admin flag down with
> `ROLE_TEAM_ADMIN`. So the new selection must exist **and already be assigned to every admin**
> in BusPro before this is deployed; otherwise each of them is demoted on their next login and
> needs a super admin to re-approve. Admins who also hold `ROLE_TEAMER` degrade to teamer
> access; an admin without it is blocked outright by `disableForRevokedCrmRoles()`.
>
> `bpn_crm_house_manager_ids` (`config/services.yaml`) is deployment-critical for the same
> reason, and more sharply so: since roles are synced, an id missing from that map does not
> merely fail to nominate a Hausleitung, it **revokes** the role from everyone holding it, one
@@ -137,7 +169,7 @@ roles were revoked.
`BpnAuthenticator::getOrCreateLocalUser()``UserDataHandler::createLocalUser()` writes
`collectRoles()` verbatim, together with the hotel codes from the Hausleitung attributes.
A CRM admin who is not also a teamer therefore starts with `['ROLE_ADMIN_PENDING']` and no
A CRM admin who is not also a teamer therefore starts with `['ROLE_TEAM_ADMIN_PENDING']` and no
privileges at all: they can authenticate, but `UserChecker` refuses the session until a
super admin approves them.
@@ -148,7 +180,7 @@ the roles to **`syncRoles()`**, which is the whole policy in four steps:
1. **revoke** every granted role the CRM no longer claims. This is what makes BusPro the
source of truth, and it applies to `ROLE_TEAMER` as much as to the administrative roles.
2. **clear the super admin flag** when `ROLE_ADMIN` was among them — `ROLE_SUPER_ADMIN` is
2. **clear the super admin flag** when `ROLE_TEAM_ADMIN` was among them — `ROLE_TEAM_SUPER_ADMIN` is
synthesized from a separate column and would otherwise survive its own precondition.
3. **`refreshPendingRoles()`** recomputes the marker set from the current claims. A marker
whose real role is already granted is dropped — an approved role is never marked again.
@@ -189,7 +221,7 @@ runs again on submit to catch a sync that revoked the claim while the dialog was
A denial is not recorded anywhere: as long as the CRM keeps claiming the role, the
nomination is back on the next login.
**Super admin** is only offered to somebody who already holds `ROLE_ADMIN` — approve first,
**Super admin** is only offered to somebody who already holds `ROLE_TEAM_ADMIN` — approve first,
elevate afterwards. The one exception is a flag that outlived its role, which stays editable
so the account can be saved at all while `User::validateSuperAdmin()` is violated; the sync
clears it (see 2), so it should never occur in practice.
@@ -243,7 +275,7 @@ administrative users, teamers are an admin's business:
| Surface | Who | Notes |
|---------|-----|-------|
| `/admin/teamer/disable-user/{uuid}` and `/administrative/teamer/enable-user/{uuid}` | `ROLE_ADMIN` | teamers; public reason mandatory, internal optional |
| `/admin/teamer/disable-user/{uuid}` and `/administrative/teamer/enable-user/{uuid}` | `ROLE_TEAM_ADMIN` | teamers; public reason mandatory, internal optional |
| "Account gesperrt" checkbox on the user edit form | super admin (`UserVoter`) | everyone else; both reasons optional |
Both go through `User::setDisabled()`, which is a no-op when the state is unchanged — saving
+24 -5
View File
@@ -162,17 +162,36 @@ class ApiClient
$this->config['max_retries']
);
$this->send($socket, $body);
$response = $this->receive($socket);
$this->disconnect($socket);
// message length (10 bytes) is prepended to actual message
$xml = substr($response, 10);
try {
// the length header is consumed by receive(), this is the payload
$xml = $this->receive($socket);
} finally {
$this->disconnect($socket);
}
if (true === $this->config['debug']) {
$this->dumpXmlToFile('response', $requestId, $xml);
}
return $this->responseParser->parseXmlString($type, $xml);
try {
return $this->responseParser->parseXmlString($type, $xml);
} 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.
if (true !== $this->config['debug']) {
$this->dumpXmlToFile('response', $requestId, $xml);
}
$this->logger->error('Unable to parse BusProNet response', [
'request_id' => $requestId,
'type' => $type,
'response_length' => strlen($xml),
'error_message' => $e->getMessage(),
]);
throw $e;
}
}
private function dumpXmlToFile(string $type, string $requestId, string $body): void
+47 -4
View File
@@ -58,15 +58,58 @@ trait ApiClientTrait
fwrite($socket, $send);
}
/**
* Reads a single response message. The protocol prepends the payload length as a
* 10 byte header, so read exactly that many bytes rather than guessing at EOF:
* a peer that closes mid-stream would otherwise yield a silently truncated body.
*
* @throws ApiClientException
*/
private function receive($socket): string
{
$response = '';
$header = $this->readBytes($socket, 10);
while (false === feof($socket)) {
$response .= fread($socket, 4096);
if (10 !== strlen($header)) {
throw new ApiClientException(sprintf('Incomplete response header, got %d of 10 bytes', strlen($header)));
}
return $response;
$expectedLength = (int) trim($header);
if (1 > $expectedLength) {
throw new ApiClientException(sprintf('Response announced an empty body (header "%s")', trim($header)));
}
$body = $this->readBytes($socket, $expectedLength);
if (strlen($body) !== $expectedLength) {
throw new ApiClientException(sprintf('Truncated response, got %d of %d announced bytes', strlen($body), $expectedLength));
}
return $body;
}
/**
* @throws ApiClientException
*/
private function readBytes($socket, int $length): string
{
$buffer = '';
while (strlen($buffer) < $length && false === feof($socket)) {
$chunk = fread($socket, min(4096, $length - strlen($buffer)));
if (false === $chunk || '' === $chunk) {
if (true === (stream_get_meta_data($socket)['timed_out'] ?? false)) {
throw new ApiClientException(sprintf('Timed out reading response after %d of %d bytes', strlen($buffer), $length));
}
break;
}
$buffer .= $chunk;
}
return $buffer;
}
private function disconnect($socket): void
@@ -6,6 +6,7 @@ use App\BusProNet\ApiClient;
use App\BusProNet\ApiClientException;
use App\BusProNet\Model\Country;
use App\BusProNet\Model\NotificationResponse;
use App\BusProNet\ResponseParserException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
@@ -35,7 +36,8 @@ class CountryDataProvider
return $response->getItems();
});
} catch (ApiClientException $e) {
} catch (ApiClientException|ResponseParserException $e) {
$this->logger->error('Unable to fetch country base data from BusProNet: '.$e->getMessage());
$countries = [];
}
@@ -6,6 +6,7 @@ use App\BusProNet\ApiClient;
use App\BusProNet\ApiClientException;
use App\BusProNet\Model\Hotel;
use App\BusProNet\Model\NotificationResponse;
use App\BusProNet\ResponseParserException;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface;
@@ -36,7 +37,8 @@ class HotelDataProvider
return $response->getItems();
});
} catch (ApiClientException|InvalidArgumentException $e) {
} catch (ApiClientException|InvalidArgumentException|ResponseParserException $e) {
$this->logger->error('Unable to fetch hotel base data from BusProNet: '.$e->getMessage());
$hotels = [];
}
@@ -6,6 +6,7 @@ use App\BusProNet\ApiClient;
use App\BusProNet\ApiClientException;
use App\BusProNet\Model\NotificationResponse;
use App\BusProNet\Model\Pickup;
use App\BusProNet\ResponseParserException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
@@ -35,7 +36,8 @@ class PickupDataProvider
return $response->getItems();
});
} catch (ApiClientException $e) {
} catch (ApiClientException|ResponseParserException $e) {
$this->logger->error('Unable to fetch pickup base data from BusProNet: '.$e->getMessage());
$pickups = [];
}
+3 -1
View File
@@ -39,7 +39,9 @@ class Communication
public function setEmail(?string $email): static
{
$this->email = $email;
// BusPro compares addresses case-insensitively; normalize on the way in so the DTO
// carries the same shape the entities store.
$this->email = null === $email ? null : mb_strtolower(trim($email));
return $this;
}
+49 -8
View File
@@ -30,10 +30,7 @@ class ResponseParser
*/
public function parseXmlString(string $type, string $content): mixed
{
$xml = simplexml_load_string($content);
if (false === $xml) {
throw new ResponseParserException('Unable to parse XML response');
}
$xml = $this->loadXml($content);
// Override type when present in XML to catch error responses
$responseType = $type;
@@ -65,7 +62,7 @@ class ResponseParser
return $this->createHotelsResponse($xml);
}
throw new ResponseParserException('Unable to parse XML response');
throw new ResponseParserException(sprintf('Unrecognised BusProNet response type "%s"', $responseType));
}
public function createNotificationResponse(\SimpleXMLElement $xml): NotificationResponse
@@ -200,7 +197,7 @@ class ResponseParser
$isHouseManager = true;
$hotelCodes[] = $houseManagerCode;
}
if ($this->config['bpn_crm_id_admin'] === $attribute->getId() && true === $attribute->isSelected()) {
if ($this->config['bpn_crm_id_team_admin'] === $attribute->getId() && true === $attribute->isSelected()) {
$isAdmin = true;
}
if ($this->config['bpn_crm_id_manager'] === $attribute->getId() && true === $attribute->isSelected()) {
@@ -302,14 +299,58 @@ class ResponseParser
return new BaseDataResponse($hotels);
}
/**
* The BusProNet endpoint can answer with an empty or truncated body. Keep the libxml
* reason instead of collapsing every shape of broken response into one message.
*
* @throws ResponseParserException
*/
private function loadXml(string $content): \SimpleXMLElement
{
if ('' === trim($content)) {
throw new ResponseParserException(sprintf('Unable to parse XML response (%d bytes): empty response', strlen($content)));
}
$previousUseErrors = libxml_use_internal_errors(true);
libxml_clear_errors();
try {
$xml = simplexml_load_string($content);
if (false === $xml) {
throw new ResponseParserException(sprintf('Unable to parse XML response (%d bytes): %s', strlen($content), $this->describeLibxmlErrors()));
}
return $xml;
} finally {
libxml_clear_errors();
libxml_use_internal_errors($previousUseErrors);
}
}
private function describeLibxmlErrors(): string
{
$messages = [];
foreach (libxml_get_errors() as $error) {
$messages[] = sprintf('%s (line %d, column %d)', trim($error->message), $error->line, $error->column);
}
if ([] === $messages) {
return 'unknown XML error';
}
return implode('; ', array_unique($messages));
}
private function resolveOptions(array $options): array
{
$optionsResolver = new OptionsResolver();
$optionsResolver->setRequired(['bpn_crm_id_admin', 'bpn_crm_id_manager', 'bpn_crm_id_teamer', 'bpn_crm_house_manager_ids']);
$optionsResolver->setRequired(['bpn_crm_id_team_admin', 'bpn_crm_id_manager', 'bpn_crm_id_teamer', 'bpn_crm_house_manager_ids']);
$optionsResolver->setDefaults([
'bpn_default_hotel_code' => null,
]);
$optionsResolver->setAllowedTypes('bpn_crm_id_admin', 'int');
$optionsResolver->setAllowedTypes('bpn_crm_id_team_admin', 'int');
$optionsResolver->setAllowedTypes('bpn_crm_id_manager', 'int');
$optionsResolver->setAllowedTypes('bpn_crm_id_teamer', 'int');
$optionsResolver->setAllowedTypes('bpn_crm_house_manager_ids', 'array');
+7 -7
View File
@@ -69,7 +69,7 @@ class UserDataHandler
$claimedRoles = [];
if ($crmAttributes->isAdmin()) {
$claimedRoles[] = 'ROLE_ADMIN';
$claimedRoles[] = 'ROLE_TEAM_ADMIN';
}
if ($crmAttributes->isManager()) {
@@ -370,7 +370,7 @@ class UserDataHandler
* The whole policy, in the order it has to run:
*
* 1. revoke what is no longer claimed - the identity source leads;
* 2. drop the super admin flag along with ROLE_ADMIN, or the highest privilege in the
* 2. drop the super admin flag along with ROLE_TEAM_ADMIN, or the highest privilege in the
* application would outlive the role it depends on;
* 3. refresh the pending markers, after the revocation so that a role just revoked is
* not immediately marked again - it is unclaimed in both steps;
@@ -424,7 +424,7 @@ class UserDataHandler
*
* Only the roles of User::ROLES are touched: getAssignedRoles() excludes the pending
* markers as well as the implicit ROLE_USER, and the markers are dealt with by
* refreshPendingRoles(). ROLE_SUPER_ADMIN is not a stored role at all but a flag, so
* refreshPendingRoles(). ROLE_TEAM_SUPER_ADMIN is not a stored role at all but a flag, so
* it is handled separately below.
*
* @param string[] $claimedRoles
@@ -450,22 +450,22 @@ class UserDataHandler
}
/**
* Takes the super admin flag down with ROLE_ADMIN.
* Takes the super admin flag down with ROLE_TEAM_ADMIN.
*
* The flag is stored on its own and getRoles() turns it into ROLE_SUPER_ADMIN whatever
* The flag is stored on its own and getRoles() turns it into ROLE_TEAM_SUPER_ADMIN whatever
* else the user holds, so without this a person the CRM no longer calls an admin would
* keep the one role that outranks every check in the application. User::validateSuperAdmin()
* enforces the same rule on the edit form, but only there.
*/
private function revokeSuperAdminWithoutRoleAdmin(User $user): void
{
if (false === $user->isSuperAdmin() || true === $user->hasRole('ROLE_ADMIN')) {
if (false === $user->isSuperAdmin() || true === $user->hasRole('ROLE_TEAM_ADMIN')) {
return;
}
$user->setSuperAdmin(false);
$this->logger->info('Revoke super admin flag along with ROLE_ADMIN', [
$this->logger->info('Revoke super admin flag along with ROLE_TEAM_ADMIN', [
'user_id' => $user->getId(),
'user_email' => $user->getEmail(),
]);
+21 -2
View File
@@ -56,11 +56,30 @@ class BpnImportCommand extends Command
return Command::FAILURE;
}
// Resolve the BusProNet base data once, before touching a single row. A destination
// cannot be written without its hotel, so an unavailable hotel list would otherwise
// skip every record and still report success. Holding both lists locally also keeps
// the loop off the providers: on a failed fetch nothing is cached, and a per-lookup
// ->get() would re-open the socket for every pickup of all 174 files.
$hotels = $this->hotelDataProvider->getAll();
$pickups = $this->pickupDataProvider->getAll();
if ([] === $hotels) {
$io->error('Hotel-Stammdaten konnten nicht von BusProNet geladen werden Import abgebrochen.');
$this->logger->error('BPN import aborted: hotel base data unavailable');
return Command::FAILURE;
}
$io->info('Found '.$totalCount.' XML files');
$addedCount = 0;
$updatedCount = 0;
$warnings = [];
if ([] === $pickups) {
$warnings[] = 'Zustiegs-Stammdaten konnten nicht von BusProNet geladen werden';
}
$progressBar = $io->createProgressBar($totalCount);
$progressBar->setFormat(" %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s%\n %message%");
$progressBar->setMessage('Starting');
@@ -92,7 +111,7 @@ class BpnImportCommand extends Command
foreach ($destinationXml->xpath('zustiege/zustieg') as $pickupXml) {
$pickupBusProId = (int) $pickupXml->attributes()['idbuspro'];
if ($pickupBusProId) {
if (null === $pickup = $this->pickupDataProvider->get($pickupBusProId)) {
if (null === $pickup = $pickups[$pickupBusProId] ?? null) {
$warnings[] = 'Pickup with busProId '.$pickupBusProId.' not found';
continue;
}
@@ -109,7 +128,7 @@ class BpnImportCommand extends Command
// Iterate over all hotel entries
foreach ($destinationXml->xpath('hotel') as $hotelXml) {
$hotelBusProId = (int) $hotelXml->attributes()['idbuspro'];
if (null === $hotel = $this->hotelDataProvider->get($hotelBusProId)) {
if (null === $hotel = $hotels[$hotelBusProId] ?? null) {
$warnings[] = 'Hotel with busProId '.$hotelBusProId.' not found';
continue;
}
+2 -4
View File
@@ -91,10 +91,8 @@ class TeamerStatusCommand extends Command
$qb->expr()->eq('disposition.status', ':disposition_status')
))
->groupBy('teamer.id')
->setParameters([
'teamer_status' => Teamer::STATUS_NEW,
'disposition_status' => Disposition::STATUS_COMPLETED,
])
->setParameter('teamer_status', Teamer::STATUS_NEW)
->setParameter('disposition_status', Disposition::STATUS_COMPLETED)
->getQuery()
->getResult()
;
@@ -16,7 +16,7 @@ class UserController extends AbstractController
}
#[Route('/admin/autocomplete/user', name: 'app_admin_autocomplete_user')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): JsonResponse
{
try {
@@ -22,7 +22,7 @@ class ApproveController extends AbstractController
}
#[Route('/admin/feedback/approve/{uuid}', name: 'app_admin_feedback_approve')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Feedback $feedback, Request $request): Response
{
$form = $this->createForm(FeedbackApproveType::class, $feedback);
@@ -24,7 +24,7 @@ class ProvideController extends AbstractController
}
#[Route('/admin/feedback/provide', name: 'app_admin_feedback_provide')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): Response
{
$form = $this->getFeedbackForm();
@@ -58,7 +58,7 @@ class ProvideController extends AbstractController
}
#[Route('/admin/feedback/provide/form', name: 'app_admin_feedback_provide_form')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function form(Request $request): Response
{
$form = $this->getFeedbackForm();
+1 -1
View File
@@ -21,7 +21,7 @@ class IndexController extends AbstractController
}
#[Route('/admin', name: 'app_admin_index')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(): Response
{
$applicationRepository = $this->entityManager->getRepository(Application::class);
+1 -1
View File
@@ -20,7 +20,7 @@ class IndexController extends AbstractController
}
#[Route('/admin/log', name: 'app_admin_log_index')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): Response
{
$qb = $this
@@ -22,7 +22,7 @@ class CreateController extends AbstractController
}
#[Route('/admin/system/availability/create', name: 'app_admin_system_availability_create')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): Response
{
$availability = new Availability();
@@ -21,7 +21,7 @@ class DeleteController extends AbstractController
}
#[Route('/admin/system/availability/delete/{id}', name: 'app_admin_system_availability_delete', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Availability $availability, Request $request): Response
{
if (true === $request->isMethod('POST')) {
@@ -22,7 +22,7 @@ class DuplicateController extends AbstractController
}
#[Route('/admin/system/availability/duplicate/{id}', name: 'app_admin_system_availability_duplicate')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Availability $availability, Request $request): Response
{
$copy = Availability::duplicate($availability);
@@ -22,7 +22,7 @@ class EditController extends AbstractController
}
#[Route('/admin/system/availability/edit/{id}', name: 'app_admin_system_availability_edit')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Availability $availability, Request $request): Response
{
$form = $this->createForm(AvailabilityType::class, $availability);
@@ -19,7 +19,7 @@ class IndexController extends AbstractController
}
#[Route('/admin/system/availability', name: 'app_admin_system_availability_index')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): Response
{
$query = $this
@@ -28,7 +28,7 @@ class EditController extends AbstractController
}
#[Route('/admin/system/email-text/edit/{key}', name: 'app_admin_system_email_text_edit')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(EmailTextKey $key, Request $request): Response
{
$definition = $this->catalog->get($key);
@@ -18,7 +18,7 @@ class IndexController extends AbstractController
}
#[Route('/admin/system/email-text', name: 'app_admin_system_email_text_index')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(): Response
{
// The list is driven by the catalogue, not by the table: a mail that has never
@@ -25,7 +25,7 @@ class PreviewController extends AbstractController
* a real mail is being assembled.
*/
#[Route('/admin/system/email-text/preview/{key}', name: 'app_admin_system_email_text_preview')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(EmailTextKey $key): Response
{
$definition = $this->catalog->get($key);
@@ -27,7 +27,7 @@ class PreviewDraftController extends AbstractController
* the submitted values instead of the stored ones.
*/
#[Route('/admin/system/email-text/preview-draft/{key}', name: 'app_admin_system_email_text_preview_draft', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(EmailTextKey $key, Request $request): Response
{
$definition = $this->catalog->get($key);
@@ -25,7 +25,7 @@ class ResetController extends AbstractController
}
#[Route('/admin/system/email-text/reset/{key}', name: 'app_admin_system_email_text_reset')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(EmailTextKey $key, Request $request): Response
{
$emailText = $this->emailTextRepository->findByKey($key);
@@ -22,7 +22,7 @@ class CreateController extends AbstractController
}
#[Route('/admin/system/fee/create', name: 'app_admin_system_fee_create')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): Response
{
$fee = new Fee();
@@ -21,7 +21,7 @@ class DeleteController extends AbstractController
}
#[Route('/admin/system/fee/delete/{id}', name: 'app_admin_system_fee_delete')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Fee $fee, Request $request): Response
{
if (true === $request->isMethod('POST')) {
@@ -22,7 +22,7 @@ class DuplicateController extends AbstractController
}
#[Route('/admin/system/fee/duplicate/{id}', name: 'app_admin_system_fee_duplicate')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Fee $fee, Request $request): Response
{
$copy = Fee::duplicate($fee);
@@ -22,7 +22,7 @@ class EditController extends AbstractController
}
#[Route('/admin/system/fee/edit/{id}', name: 'app_admin_system_fee_edit')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Fee $fee, Request $request): Response
{
$form = $this->createForm(FeeType::class, $fee);
@@ -15,7 +15,7 @@ class IndexController extends AbstractController
}
#[Route('/admin/system/fee', name: 'app_admin_system_fee_index')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(): Response
{
$fees = $this->feeRepository->getList();
@@ -21,7 +21,7 @@ class CreateController extends AbstractController
}
#[Route('/admin/system/feedback-set/create', name: 'app_admin_system_feedback_set_create')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): Response
{
$feedbackSet = new FeedbackSet();
@@ -21,7 +21,7 @@ class DeleteController extends AbstractController
}
#[Route('/admin/system/feedback-set/delete/{id}', name: 'app_admin_system_feedback_set_delete')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
#[IsGranted('DELETE', subject: 'feedbackSet')]
public function index(FeedbackSet $feedbackSet, Request $request): Response
{
@@ -21,7 +21,7 @@ class EditController extends AbstractController
}
#[Route('/admin/system/feedback-set/edit/{id}', name: 'app_admin_system_feedback_set_edit')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(FeedbackSet $feedbackSet, Request $request): Response
{
$form = $this->createForm(FeedbackSetType::class, $feedbackSet);
@@ -15,7 +15,7 @@ class IndexController extends AbstractController
}
#[Route('/admin/system/feedback-set', name: 'app_admin_system_feedback_set_index')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(): Response
{
$feedbackSets = $this
@@ -21,7 +21,7 @@ class CreateController extends AbstractController
}
#[Route('/admin/system/job-profile/create', name: 'app_admin_system_job_profile_create')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): Response
{
$jobProfile = new JobProfile();
@@ -21,7 +21,7 @@ class DeleteController extends AbstractController
}
#[Route('/admin/system/job-profile/delete/{id}', name: 'app_admin_system_job_profile_delete')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(JobProfile $jobProfile, Request $request): Response
{
if (true === $request->isMethod('POST')) {
@@ -21,7 +21,7 @@ class EditController extends AbstractController
}
#[Route('/admin/system/job-profile/edit/{id}', name: 'app_admin_system_job_profile_edit')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(JobProfile $jobProfile, Request $request): Response
{
$form = $this->createForm(JobProfileType::class, $jobProfile);
@@ -15,7 +15,7 @@ class IndexController extends AbstractController
}
#[Route('/admin/system/job-profile', name: 'app_admin_system_job_profile_index')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(): Response
{
$jobProfiles = $this
@@ -22,7 +22,7 @@ class CreateController extends AbstractController
}
#[Route('/admin/system/training/create', name: 'app_admin_system_training_create')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): Response
{
$training = new Training();
@@ -21,7 +21,7 @@ class DeleteController extends AbstractController
}
#[Route('/admin/system/training/delete/{id}', name: 'app_admin_system_training_delete')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Training $training, Request $request): Response
{
if (true === $request->isMethod('POST')) {
@@ -22,7 +22,7 @@ class EditController extends AbstractController
}
#[Route('/admin/system/training/edit/{id}', name: 'app_admin_system_training_edit')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Training $training, Request $request): Response
{
$form = $this->createForm(TrainingType::class, $training);
@@ -15,7 +15,7 @@ class IndexController extends AbstractController
}
#[Route('/admin/system/training', name: 'app_admin_system_training_index')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(): Response
{
$trainings = $this
@@ -15,7 +15,7 @@ class IndexController extends AbstractController
}
#[Route('/admin/system/user', name: 'app_admin_system_user_index')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(): Response
{
$users = $this->userRepository->getAdministrativeUsers();
@@ -11,7 +11,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class CrmSelectionsController extends AbstractController
{
#[Route('/admin/teamer/crm-selections/{uuid}', name: 'app_admin_teamer_crm_selections')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Teamer $teamer): Response
{
return $this->render('admin/teamer/crm_selections.html.twig', [
@@ -23,7 +23,7 @@ class DeleteAccountController extends AbstractController
}
#[Route('/admin/teamer/delete-account/{uuid}', name: 'app_admin_teamer_delete_account')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Teamer $teamer, Request $request): Response
{
if (true === $request->isMethod(Request::METHOD_POST)) {
@@ -44,7 +44,7 @@ class DeleteAccountController extends AbstractController
}
#[Route('/admin/teamer/restore-account/{uuid}', name: 'app_admin_teamer_restore_account')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function restore(Teamer $teamer, Request $request): Response
{
if (true === $request->isMethod(Request::METHOD_POST)) {
@@ -22,7 +22,7 @@ class DisableUserController extends AbstractController
}
#[Route('/admin/teamer/disable-user/{uuid}', name: 'app_admin_teamer_disable_user')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Teamer $teamer, Request $request): Response
{
$user = $teamer->getUser();
@@ -50,7 +50,7 @@ class DisableUserController extends AbstractController
}
#[Route('/administrative/teamer/enable-user/{uuid}', name: 'app_admin_teamer_enable_user')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function enable(Teamer $teamer, Request $request): Response
{
$user = $teamer->getUser();
@@ -39,7 +39,7 @@ class MailingController extends AbstractController
* the real send goes through the confirmation modal below.
*/
#[Route('/admin/teamer/mailing', name: 'app_admin_teamer_mailing')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Request $request): Response
{
$form = $this->createMailingForm($request);
@@ -82,7 +82,7 @@ class MailingController extends AbstractController
* one that was parked.
*/
#[Route('/admin/teamer/mailing/draft', name: 'app_admin_teamer_mailing_draft', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function draft(Request $request): Response
{
$mailingDto = $this->createMailingForm($request)->getData();
@@ -93,7 +93,7 @@ class MailingController extends AbstractController
}
#[Route('/admin/teamer/mailing/discard', name: 'app_admin_teamer_mailing_discard')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function discard(): Response
{
$this->draftHandler->resetDraft();
@@ -102,7 +102,7 @@ class MailingController extends AbstractController
}
#[Route('/admin/teamer/mailing/confirm', name: 'app_admin_teamer_mailing_confirm', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function confirm(Request $request): Response
{
$form = $this->createMailingForm($request);
@@ -119,7 +119,7 @@ class MailingController extends AbstractController
}
#[Route('/admin/teamer/mailing/send', name: 'app_admin_teamer_mailing_send', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function send(Request $request): Response
{
$form = $this->createMailingForm($request);
@@ -21,7 +21,7 @@ class RemarksController extends AbstractController
}
#[Route('/admin/teamer/remarks/{uuid}', name: 'app_admin_teamer_remarks_internal')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Teamer $teamer, Request $request): Response
{
$returnUrl = $this->getReturnUrl($request, 'app_administrative_teamer_index');
@@ -25,7 +25,7 @@ class SkillsController extends AbstractController
}
#[Route('/admin/teamer/skills/{uuid}', name: 'app_admin_teamer_skills')]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(Teamer $teamer, Request $request): Response
{
$trainings = $this->trainingRepository->getList();
@@ -34,7 +34,7 @@ class CreateController extends AbstractController
path: '/admin/teamer/skills/training-attendance/create/{training_id}/{teamer_id}',
name: 'app_admin_teamer_skills_training_attendance_create'
)]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(
#[MapEntity(mapping: ['training_id' => 'id'])]
Training $training,
@@ -30,7 +30,7 @@ class DeleteController extends AbstractController
path: '/admin/teamer/skills/training-attendance/delete/{uuid}',
name: 'app_admin_teamer_skills_training_attendance_delete'
)]
#[IsGranted('ROLE_ADMIN')]
#[IsGranted('ROLE_TEAM_ADMIN')]
public function index(TrainingAttendance $attendance, Request $request): Response
{
if (true === $request->isMethod('POST')) {
@@ -25,7 +25,7 @@ class CallOffController extends AbstractController
}
#[Route('/administrative/assignment/call-off/{uuid}', name: 'app_administrative_assignment_call_off')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
#[IsGranted('CALL_OFF', subject: 'assignment')]
public function index(Assignment $assignment, Request $request): Response
{
if (true === $request->isMethod('POST')) {
@@ -11,6 +11,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class PublishController extends AbstractController
{
@@ -23,6 +24,7 @@ class PublishController extends AbstractController
}
#[Route('/administrative/assignment/publish/{uuid}', name: 'app_administrative_assignment_publish')]
#[IsGranted('PUBLISH', subject: 'assignment')]
public function index(Assignment $assignment, Request $request): Response
{
$returnUrl = $this->getReturnUrl($request, 'app_administrative_assignment_index');
@@ -13,6 +13,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class EditController extends AbstractController
{
@@ -27,6 +28,7 @@ class EditController extends AbstractController
}
#[Route('/administrative/destination/edit/{id}', name: 'app_administrative_system_destination_edit')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(Destination $destination, Request $request): Response
{
$destinationDto = DestinationDto::fromEntity($destination);
@@ -32,6 +32,7 @@ class IndexController extends AbstractController
}
#[Route('/administrative/system/faq/sort', name: 'app_administrative_system_faq_sort', methods: ['POST'])]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function sort(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
@@ -55,6 +55,7 @@ class ApplicationFilterController extends AbstractController
}
#[Route('/common/application/filter/reset', name: 'app_common_application_filter_reset')]
#[IsGranted('ROLE_USER')]
public function reset(Request $request): Response
{
$this->filterHandler->resetFilterSettings();
@@ -53,6 +53,7 @@ class AssignmentFilterController extends AbstractController
}
#[Route('/common/assignment/filter/reset', name: 'app_common_assignment_filter_reset')]
#[IsGranted('ROLE_USER')]
public function reset(Request $request): Response
{
$this->filterHandler->resetFilterSettings();
@@ -47,6 +47,7 @@ class DocumentFilterController extends AbstractController
}
#[Route('/common/document/filter/reset', name: 'app_common_document_filter_reset')]
#[IsGranted('ROLE_USER')]
public function reset(Request $request): Response
{
$this->filterHandler->resetFilterSettings();
@@ -39,6 +39,7 @@ class FeedbackFilterController extends AbstractController
}
#[Route('/common/feedback/filter/reset', name: 'app_common_feedback_filter_reset')]
#[IsGranted('ROLE_USER')]
public function reset(Request $request): Response
{
$this->filterHandler->resetFilterSettings();
@@ -42,6 +42,7 @@ class TeamerFilterController extends AbstractController
}
#[Route('/common/teamer/filter/reset', name: 'app_common_teamer_filter_reset')]
#[IsGranted('ROLE_USER')]
public function reset(Request $request): Response
{
$this->filterHandler->resetFilterSettings();
@@ -52,6 +52,7 @@ class TimelineFilterController extends AbstractController
}
#[Route('/common/timeline/filter/reset', name: 'app_common_timeline_filter_reset')]
#[IsGranted('ROLE_USER')]
public function reset(Request $request): Response
{
$this->filterHandler->resetFilterSettings();
@@ -8,6 +8,7 @@ use App\Repository\UploadRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class IndexController extends AbstractController
{
@@ -18,6 +19,7 @@ class IndexController extends AbstractController
}
#[Route('/management', name: 'app_manager_index')]
#[IsGranted('ROLE_MANAGER')]
public function index(): Response
{
$dispositions = $this->dispositionRepository->getNew();
+3 -4
View File
@@ -22,10 +22,9 @@ class OAuth2Controller extends AbstractController
{
$this->denyUnlessFeatureIsActive();
$provider = $this->client->getProvider();
$url = $provider->getAuthorizationUrl();
$state = $provider->getState();
$request->getSession()->set('oauth2state', $state);
// the state and the PKCE verifier belong to the client, which is what consumes
// them again on the callback
$url = $this->client->createAuthorizationUrl($request);
return $this->redirect($url);
}
@@ -7,6 +7,7 @@ use App\Repository\ContactRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class ContactController extends AbstractController
{
@@ -15,6 +16,7 @@ class ContactController extends AbstractController
}
#[Route('/teamer/contact', name: 'app_teamer_contact')]
#[IsGranted('ROLE_USER')]
public function index(): Response
{
$allContacts = $this
+1 -1
View File
@@ -75,7 +75,7 @@ class Contact
public function setEmail(string $email): static
{
$this->email = $email;
$this->email = mb_strtolower(trim($email));
return $this;
}
+1 -1
View File
@@ -108,7 +108,7 @@ class Communication
public function setEmail(?string $email): static
{
$this->email = $email;
$this->email = null === $email ? null : mb_strtolower(trim($email));
return $this;
}
+1 -1
View File
@@ -95,7 +95,7 @@ class Teamer implements TimestampableEntityInterface, SoftDeletableEntityInterfa
private ?string $remarksInternal = null;
#[ORM\ManyToMany(targetEntity: Availability::class, inversedBy: 'teamers')]
#[ORM\OrderBy(['dateFrom' => 'ASC'])]
#[ORM\OrderBy(['dateFrom' => \SortDirection::Ascending])]
private Collection $availabilities;
#[ORM\OneToMany(mappedBy: 'owner', targetEntity: Availability::class)]
+7 -7
View File
@@ -22,7 +22,7 @@ class User implements UserInterface, TimestampableEntityInterface, SoftDeletable
* Assignable roles and their labels.
*/
public const ROLES = [
'ROLE_ADMIN' => 'Admin',
'ROLE_TEAM_ADMIN' => 'Team Admin',
'ROLE_MANAGER' => 'Reisemanager',
'ROLE_HOUSE_MANAGER' => 'Hausleitung',
'ROLE_TEAMER' => 'Teamer',
@@ -35,7 +35,7 @@ class User implements UserInterface, TimestampableEntityInterface, SoftDeletable
* a super admin.
*/
public const PENDING_ROLES = [
'ROLE_ADMIN' => 'ROLE_ADMIN_PENDING',
'ROLE_TEAM_ADMIN' => 'ROLE_TEAM_ADMIN_PENDING',
'ROLE_MANAGER' => 'ROLE_MANAGER_PENDING',
'ROLE_HOUSE_MANAGER' => 'ROLE_HOUSE_MANAGER_PENDING',
];
@@ -194,7 +194,7 @@ class User implements UserInterface, TimestampableEntityInterface, SoftDeletable
$roles = ['ROLE_USER', ...$this->roles];
if (true === $this->isSuperAdmin()) {
$roles[] = 'ROLE_SUPER_ADMIN';
$roles[] = 'ROLE_TEAM_SUPER_ADMIN';
}
return array_unique($roles);
@@ -223,7 +223,7 @@ class User implements UserInterface, TimestampableEntityInterface, SoftDeletable
/**
* The manually assignable roles held by the user, i.e. without the implicit ROLE_USER
* and ROLE_SUPER_ADMIN added by getRoles() and without any pending marker. Used to
* and ROLE_TEAM_SUPER_ADMIN added by getRoles() and without any pending marker. Used to
* edit role assignments: saving them resolves the pending approvals.
*/
public function getAssignedRoles(): array
@@ -283,12 +283,12 @@ class User implements UserInterface, TimestampableEntityInterface, SoftDeletable
}
/**
* Super admin is an elevation of ROLE_ADMIN, never a standalone grant.
* Super admin is an elevation of ROLE_TEAM_ADMIN, never a standalone grant.
*/
#[Assert\Callback]
public function validateSuperAdmin(ExecutionContextInterface $context): void
{
if (true === $this->superAdmin && false === in_array('ROLE_ADMIN', $this->roles, true)) {
if (true === $this->superAdmin && false === in_array('ROLE_TEAM_ADMIN', $this->roles, true)) {
$context
->buildViolation('Nur Admins können zu Superadmins ernannt werden.')
->atPath('superAdmin')
@@ -311,7 +311,7 @@ class User implements UserInterface, TimestampableEntityInterface, SoftDeletable
public function getDefaultRoute(): string
{
if ($this->hasRole('ROLE_ADMIN')) {
if ($this->hasRole('ROLE_TEAM_ADMIN')) {
return 'app_admin_index';
} elseif ($this->hasRole('ROLE_MANAGER')) {
return 'app_manager_index';
+2 -2
View File
@@ -67,7 +67,7 @@ class UserType extends AbstractType
});
// Super admin is an elevation of an existing role, never a grant of its own, so the
// field exists only for somebody who already holds ROLE_ADMIN - offering it to
// field exists only for somebody who already holds ROLE_TEAM_ADMIN - offering it to
// anyone else would only produce the violation from User::validateSuperAdmin().
// A flag already set without the role is the exception: it has to stay editable, or
// that user could not be saved at all until BusPro claims them an admin again.
@@ -78,7 +78,7 @@ class UserType extends AbstractType
return;
}
if (false === in_array('ROLE_ADMIN', $user->getAssignedRoles(), true) && false === $user->isSuperAdmin()) {
if (false === in_array('ROLE_TEAM_ADMIN', $user->getAssignedRoles(), true) && false === $user->isSuperAdmin()) {
return;
}
+3 -3
View File
@@ -19,7 +19,7 @@ abstract class AbstractMenuBuilder
* Note that "app_admin_" does not match the shared "app_administrative_" routes.
*/
protected const AREA_ROUTE_PREFIXES = [
'app_admin_' => 'ROLE_ADMIN',
'app_admin_' => 'ROLE_TEAM_ADMIN',
'app_manager_' => 'ROLE_MANAGER',
'app_house_manager_' => 'ROLE_HOUSE_MANAGER',
'app_teamer_' => 'ROLE_TEAMER',
@@ -29,7 +29,7 @@ abstract class AbstractMenuBuilder
* Priority order of the area roles, must stay in sync with User::getDefaultRoute().
*/
protected const ROLE_PRIORITY = [
'ROLE_ADMIN',
'ROLE_TEAM_ADMIN',
'ROLE_MANAGER',
'ROLE_HOUSE_MANAGER',
'ROLE_TEAMER',
@@ -140,7 +140,7 @@ abstract class AbstractMenuBuilder
protected function addAdminItem(ItemInterface $menu): void
{
if ($this->security->isGranted('ROLE_ADMIN')) {
if ($this->security->isGranted('ROLE_TEAM_ADMIN')) {
$this->addDivider($menu);
$menu->addChild('zum Adminbereich', [
'route' => 'app_admin_index',
+2 -2
View File
@@ -32,7 +32,7 @@ class MenuBuilder extends AbstractMenuBuilder
public function createMainMenu(array $options): ItemInterface
{
return match ($this->resolveArea()) {
'ROLE_ADMIN' => $this->adminMenuBuilder->createMainMenu($options),
'ROLE_TEAM_ADMIN' => $this->adminMenuBuilder->createMainMenu($options),
'ROLE_MANAGER' => $this->managerMenuBuilder->createMainMenu($options),
'ROLE_HOUSE_MANAGER' => $this->houseManagerMenuBuilder->createMainMenu($options),
'ROLE_TEAMER' => $this->teamerMenuBuilder->createMainMenu($options),
@@ -43,7 +43,7 @@ class MenuBuilder extends AbstractMenuBuilder
public function createTeamerMenu(array $options): ItemInterface
{
return match ($this->resolveArea()) {
'ROLE_ADMIN' => $this->adminMenuBuilder->createTeamerMenu($options),
'ROLE_TEAM_ADMIN' => $this->adminMenuBuilder->createTeamerMenu($options),
'ROLE_MANAGER' => $this->managerMenuBuilder->createTeamerMenu($options),
default => $this->createRootElement(),
};
+2 -1
View File
@@ -148,7 +148,8 @@ class AvailabilityRepository extends ServiceEntityRepository
$qb->expr()->eq('availability.dateTo', ':dateTo'),
$qb->expr()->isNull('availability.owner')
))
->setParameters($fields)
->setParameter('dateFrom', $fields['dateFrom'])
->setParameter('dateTo', $fields['dateTo'])
->getQuery()
->getResult()
;
@@ -7,7 +7,7 @@ use App\Entity\User;
abstract class AbstractRequiredTeamerCheck implements RequiredTeamerCheckInterface
{
private const EXCLUDED_ROLES = [
'ROLE_ADMIN',
'ROLE_TEAM_ADMIN',
'ROLE_MANAGER',
'ROLE_HOUSE_MANAGER',
];
+1 -1
View File
@@ -43,7 +43,7 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
public function authenticate(Request $request): Passport
{
$email = trim($request->request->getString('_username'));
$email = mb_strtolower(trim($request->request->getString('_username')));
$passwordPlain = trim($request->request->getString('_password'));
// Very lame hashing applied here as required by BPN
+16 -2
View File
@@ -6,6 +6,7 @@ use App\BusProNet\UserDataHandler;
use App\Entity\Teamer;
use App\Entity\User;
use App\RequiredTeamerCheck\RequiredTeamerCheckRegistry;
use App\Security\OAuth2\AuthorizationDeniedException;
use App\Security\OAuth2\AuthorizationRequestException;
use App\Security\OAuth2\MyEpClient;
use Doctrine\ORM\EntityManagerInterface;
@@ -34,8 +35,14 @@ class MyEpAuthenticator extends AbstractAuthenticator
* The roles that entitle someone to log in here at all. Anything else MyE&P reports
* is dropped rather than stored, so that no role this application assigns a meaning
* to can be set from the outside.
*
* This list exists a second time on MyE&P, as the required_roles of this application's
* oauth2_client row: MyE&P refuses the authorization request outright when the account
* holds none of them, and explains why on its own page. The two are one policy written
* twice and must be changed together — widening only one either strands a user at MyE&P
* with no explanation this side can give, or lets one through to be refused here.
*/
private const ELIGIBLE_ROLES = ['ROLE_ADMIN', 'ROLE_TEAMER', 'ROLE_MANAGER', 'ROLE_HOUSE_MANAGER'];
private const ELIGIBLE_ROLES = ['ROLE_TEAM_ADMIN', 'ROLE_TEAMER', 'ROLE_MANAGER', 'ROLE_HOUSE_MANAGER'];
public function __construct(
private readonly MyEpClient $client,
@@ -63,11 +70,18 @@ class MyEpAuthenticator extends AbstractAuthenticator
{
try {
$accessToken = $this->client->fetchAccessToken($request);
} catch (AuthorizationDeniedException $e) {
// MyE&P said why it sent no code, so this is not a failure to report as one
$this->logger->info('Login via MyE&P was denied', [
'error' => $e->getError(),
'error_description' => $e->getErrorDescription(),
]);
throw new CustomUserMessageAuthenticationException('Login via MyE&P wurde abgebrochen');
} catch (AuthorizationRequestException|IdentityProviderException $e) {
$this->logger->error('Login via MyE&P failed due to unobtainable access token', [
'exception' => $e,
]);
throw new CustomUserMessageAuthenticationException('Invalid token');
throw new CustomUserMessageAuthenticationException('Login via MyE&P nicht möglich');
}
try {
@@ -0,0 +1,34 @@
<?php
namespace App\Security\OAuth2;
use Symfony\Component\HttpFoundation\Request;
/**
* MyE&P redirected back with an `error` instead of a code — the person cancelled, or the
* authorization was refused.
*
* A subclass of AuthorizationRequestException so that a caller which does not care why the
* callback carried no code still catches it, and one that does can tell this apart from a
* malformed or replayed callback and say so.
*/
class AuthorizationDeniedException extends AuthorizationRequestException
{
public function __construct(
private readonly string $error,
private readonly ?string $errorDescription,
Request $request,
) {
parent::__construct(sprintf('Authorization denied: %s', $error), 400, $request);
}
public function getError(): string
{
return $this->error;
}
public function getErrorDescription(): ?string
{
return $this->errorDescription;
}
}
+82 -11
View File
@@ -13,41 +13,108 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class MyEpClient
{
private const SESSION_KEY_STATE = 'oauth2state';
private const SESSION_KEY_PKCE = 'oauth2pkce';
private array $config;
public function __construct(
private readonly UrlGeneratorInterface $urlGenerator,
private readonly LoggerInterface $logger,
// the client secret rides in here, marked sensitive so it is redacted from stack
// traces rather than printed in full on an exception page or in a log
#[\SensitiveParameter]
array $options,
) {
$this->config = $this->resolveConfig($options);
}
/**
* Starts the authorization code flow and stores what the callback has to prove.
*
* The state and the PKCE verifier are generated by getAuthorizationUrl() and are only
* readable afterwards, so both are taken from the provider rather than built here.
*/
public function createAuthorizationUrl(Request $request): string
{
$provider = $this->getProvider();
$url = $provider->getAuthorizationUrl();
$session = $request->getSession();
$session->set(self::SESSION_KEY_STATE, $provider->getState());
$session->set(self::SESSION_KEY_PKCE, $provider->getPkceCode());
return $url;
}
/**
* @throws IdentityProviderException
* @throws AuthorizationDeniedException
* @throws AuthorizationRequestException
*/
public function fetchAccessToken(Request $request): AccessTokenInterface
{
$session = $request->getSession();
// Both are single-use, so they are consumed before anything can fail: a callback
// that throws must not leave a state behind that a second attempt could replay.
$expectedState = $session->remove(self::SESSION_KEY_STATE);
$pkceCode = $session->remove(self::SESSION_KEY_PKCE);
$state = $request->query->get('state');
// Before anything else, including the error parameter: RFC 6749 4.1.2.1 asks for the
// state to be verified on an error response too, and there is a practical reason to.
// The session cookie is SameSite=lax, so a top-level navigation to this route carries
// the victim's cookie, and the two removes above have already consumed their pending
// flow. Refusing here means a callback that cannot prove the state no longer destroys
// a login it did not start.
//
// hash_equals() rather than !==, the state being the CSRF secret of the flow. The
// string checks come first: a callback with no state, or a session that never held
// one, must fail here rather than reach a comparison with null.
if (
false === is_string($state)
|| false === is_string($expectedState)
|| false === hash_equals($expectedState, $state)
) {
$this->logger->error('OAuth2 login request missing state or mismatch');
throw new AuthorizationRequestException('Missing state or mismatch', 400, $request);
}
// Now that the callback is known to belong to this flow: MyE&P has said why it is
// not sending a code, and every later check would report the wrong cause. The role
// gate renders on MyE&P rather than redirecting, but a cancelled or refused
// authorization arrives here. Not logged — this is no failure of ours, and the
// caller decides what to make of it from the exception.
$error = $request->query->get('error');
if (true === is_string($error) && '' !== $error) {
$description = $request->query->get('error_description');
$description = is_string($description) && '' !== $description ? $description : null;
throw new AuthorizationDeniedException($error, $description, $request);
}
if (null === $code = $request->query->get('code')) {
$this->logger->error('OAuth2 login request missing code');
throw new AuthorizationRequestException('Missing code', 400, $request);
}
$session = $request->getSession();
if (
null === $request->query->get('state')
|| $request->query->get('state') !== $session->get('oauth2state')
) {
$session->remove('oauth2state');
$this->logger->error('OAuth2 login request missing state or mismatch');
throw new AuthorizationRequestException('Missing state or mismatch', 400, $request);
// A challenge went out with the authorization request, so MyE&P rejects an exchange
// without the verifier. Refused here instead, where the reason is still known — the
// usual cause is a session replaced between the two legs of the flow.
if (false === is_string($pkceCode) || '' === $pkceCode) {
$this->logger->error('OAuth2 login request without the PKCE verifier of its session');
throw new AuthorizationRequestException('Missing PKCE verifier', 400, $request);
}
$session->remove('oauth2state');
$provider = $this->getProvider();
return $this->getProvider()->getAccessToken('authorization_code', [
// replays the verifier whose challenge was sent with the authorization request
$provider->setPkceCode($pkceCode);
return $provider->getAccessToken('authorization_code', [
'code' => $code,
]);
}
@@ -68,6 +135,10 @@ class MyEpClient
'urlResourceOwnerDetails' => $this->config['myep_oauth2_url_resource_owner_details'],
'scopes' => $this->config['myep_oauth2_scopes'],
'scopeSeparator' => ' ',
// The redirect URI above is generated from the incoming request, so it is only
// as trustworthy as the Host header. PKCE binds the authorization code to this
// flow by a second, header-independent means.
'pkceMethod' => AbstractProvider::PKCE_METHOD_S256,
]);
}
+2 -2
View File
@@ -60,7 +60,7 @@ class DispositionVoter extends Voter
static::CONTRACT, static::INVOICE => false === $disposition->isSkipFormalities()
&& ($this->security->isGranted('ROLE_ADMINISTRATIVE')
|| $this->assertTeamerAccess($token, $disposition)),
static::DELETE => $this->security->isGranted('ROLE_ADMIN'),
static::DELETE => $this->security->isGranted('ROLE_TEAM_ADMIN'),
static::FEEDBACK => false === $disposition->isSkipFormalities()
&& ($this->security->isGranted('ROLE_ADMINISTRATIVE')
|| $this->assertHouseManagerAccess($token, $disposition)),
@@ -162,7 +162,7 @@ class DispositionVoter extends Voter
private function assertAdminDocumentUploadAllowed(Disposition $disposition): bool
{
if (false === $this->security->isGranted('ROLE_ADMINISTRATIVE')
&& false === $this->security->isGranted('ROLE_ADMIN')) {
&& false === $this->security->isGranted('ROLE_TEAM_ADMIN')) {
return false;
}
+1 -1
View File
@@ -24,7 +24,7 @@ class FeedbackVoter extends Voter
/** @var Feedback $feedback */
$feedback = $subject;
if (in_array('ROLE_ADMIN', $token->getRoleNames())) {
if (in_array('ROLE_TEAM_ADMIN', $token->getRoleNames())) {
return true;
}
+1 -1
View File
@@ -52,7 +52,7 @@ class ImpersonationVoter extends Voter
}
// Admin is the only role allowed to impersonate
if (false === $this->security->isGranted('ROLE_ADMIN')) {
if (false === $this->security->isGranted('ROLE_TEAM_ADMIN')) {
return false;
}
+1 -1
View File
@@ -33,7 +33,7 @@ class AppRuntime implements RuntimeExtensionInterface
$user = $teamer->getUser();
if (null !== $user) {
if ($user->hasRole('ROLE_ADMIN')) {
if ($user->hasRole('ROLE_TEAM_ADMIN')) {
$labelItems[] = 'Admin';
}
+1 -1
View File
@@ -1,6 +1,6 @@
{{ form_start(form) }}
<div class="flex flex-col space-y-4 pb-8">
{# only present for somebody who already holds ROLE_ADMIN, see UserType #}
{# only present for somebody who already holds ROLE_TEAM_ADMIN, see UserType #}
{% if form.superAdmin is defined %}
{{ form_row(form.superAdmin) }}
{% endif %}
@@ -9,7 +9,7 @@
<div class="pb-8">
{% include '_partials/_assignment_info_compact.html.twig' %}
</div>
{% if is_granted('ROLE_ADMIN') %}
{% if is_granted('ROLE_TEAM_ADMIN') %}
<div class="col-span-2">
<h2 class="font-bold text-lg pb-4">
Bewerbungen
@@ -52,7 +52,7 @@
<th>
{{ knp_pagination_sortable(pagination, 'Busbegleitung', 'assignment.pickup') }}
</th>
{% if is_granted('ROLE_ADMIN') %}
{% if is_granted('ROLE_TEAM_ADMIN') %}
<th>
Bewerbungen
</th>
@@ -131,7 +131,7 @@
-
{% endif %}
</td>
{% if is_granted('ROLE_ADMIN') %}
{% if is_granted('ROLE_TEAM_ADMIN') %}
<td>
<a href="{{ path('app_administrative_assignment_detail', { 'uuid': assignment.uuid, 'r': return_url() }) }}" class="flex items-center space-x-2">
<span>{{ assignment.applications|length }}</span>
@@ -102,7 +102,7 @@
{{ knp_pagination_render(pagination) }}
</div>
</div>
{% if is_granted('ROLE_ADMIN') %}
{% if is_granted('ROLE_TEAM_ADMIN') %}
<a href="{{ path('app_admin_feedback_provide') }}" class="btn" title="Feedback erfassen">
Neu
</a>
@@ -26,7 +26,7 @@
Reset
</a>
{% endif %}
{% if is_granted('ROLE_ADMIN') %}
{% if is_granted('ROLE_TEAM_ADMIN') %}
<a href="{{ path('app_admin_teamer_mailing', { 'r': return_url() }) }}"
class="btn btn--small"
title="Mail an die gefilterte Liste schreiben">
@@ -113,7 +113,7 @@
</button>
{% endif %}
{% if teamer.deleted %}
{% if is_granted('ROLE_ADMIN') %}
{% if is_granted('ROLE_TEAM_ADMIN') %}
<button type="button"
role="menuitem"
tabindex="-1"
@@ -125,7 +125,7 @@
</button>
{% endif %}
{% elseif teamer.user is not null and teamer.user.disabled %}
{% if is_granted('ROLE_ADMIN') %}
{% if is_granted('ROLE_TEAM_ADMIN') %}
<button type="button"
role="menuitem"
tabindex="-1"
@@ -136,7 +136,7 @@
</button>
{% endif %}
{% else %}
{% if is_granted('ROLE_ADMIN') %}
{% if is_granted('ROLE_TEAM_ADMIN') %}
<button type="button"
class="text-red-500"
role="menuitem"
@@ -152,7 +152,7 @@
{{ icon('mask') }}
</a>
{% endif %}
{% if is_granted('ROLE_ADMIN') %}
{% if is_granted('ROLE_TEAM_ADMIN') %}
<button type="button"
class="text-red-500"
role="menuitem"
@@ -80,7 +80,7 @@
</li>
{% endfor %}
</ul>
{% if is_granted('ROLE_ADMIN') %}
{% if is_granted('ROLE_TEAM_ADMIN') %}
<h2 class="text-lg font-bold">
Interne Anmerkungen
</h2>
+61 -1
View File
@@ -10,6 +10,7 @@ use App\BusProNet\Model\Pickup;
use App\BusProNet\Model\ProfileResponse;
use App\BusProNet\Model\ProfileUpdateResponse;
use App\BusProNet\ResponseParser;
use App\BusProNet\ResponseParserException;
use PHPUnit\Framework\TestCase;
class ResponseParserTest extends TestCase
@@ -178,6 +179,65 @@ class ResponseParserTest extends TestCase
$this->assertEquals('Gaststätte', $hotel->getType());
}
/**
* A broken response used to collapse into one opaque "Unable to parse XML response",
* which is what made the production import failure undiagnosable. Each shape must now
* name itself.
*/
public function testParseEmptyResponse(): void
{
$parser = $this->getParserInstance();
$this->expectException(ResponseParserException::class);
$this->expectExceptionMessage('empty response');
$parser->parseXmlString(ApiClient::TYPE_BASE_DATA_PICKUPS, '');
}
public function testParseTruncatedResponse(): void
{
$content = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="STAMMZUSTIEGE"></satz><zustieg id="1"';
$parser = $this->getParserInstance();
try {
$parser->parseXmlString(ApiClient::TYPE_BASE_DATA_PICKUPS, $content);
$this->fail('Expected a ResponseParserException');
} catch (ResponseParserException $e) {
$this->assertStringContainsString('('.strlen($content).' bytes)', $e->getMessage());
$this->assertStringContainsString('line 1', $e->getMessage());
}
}
public function testParseWellFormedResponseOfAnUnknownType(): void
{
$content = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="STAMMIRGENDWAS"></satz></ergebnis>';
$parser = $this->getParserInstance();
$this->expectException(ResponseParserException::class);
$this->expectExceptionMessage('Unrecognised BusProNet response type "STAMMIRGENDWAS"');
$parser->parseXmlString(ApiClient::TYPE_BASE_DATA_PICKUPS, $content);
}
public function testParsingDoesNotLeakLibxmlErrorState(): void
{
$previous = libxml_use_internal_errors(false);
try {
try {
$this->getParserInstance()->parseXmlString(ApiClient::TYPE_BASE_DATA_PICKUPS, '<ergebnis');
} catch (ResponseParserException) {
}
$this->assertFalse(libxml_use_internal_errors(false));
$this->assertSame([], libxml_get_errors());
} finally {
libxml_use_internal_errors($previous);
}
}
private function loadFixture(string $filename): string
{
return file_get_contents(__DIR__.'/../Resources/'.$filename);
@@ -186,7 +246,7 @@ class ResponseParserTest extends TestCase
private function getParserInstance(): ResponseParser
{
return new ResponseParser([
'bpn_crm_id_admin' => 1292,
'bpn_crm_id_team_admin' => 1292,
'bpn_crm_id_manager' => 1293,
'bpn_crm_id_teamer' => 1070,
// Deliberately a small excerpt of the configured map: the fixtures only carry
+66 -35
View File
@@ -44,7 +44,7 @@ class UserDataHandlerTest extends TestCase
{
yield 'admin only yields the pending marker' => [
(new CrmAttributesResponse())->setAdmin(true),
[User::PENDING_ROLES['ROLE_ADMIN']],
[User::PENDING_ROLES['ROLE_TEAM_ADMIN']],
];
yield 'manager only yields the pending marker' => [
@@ -64,12 +64,12 @@ class UserDataHandlerTest extends TestCase
yield 'admin and teamer' => [
(new CrmAttributesResponse())->setAdmin(true)->setTeamer(true),
[User::PENDING_ROLES['ROLE_ADMIN'], 'ROLE_TEAMER'],
[User::PENDING_ROLES['ROLE_TEAM_ADMIN'], 'ROLE_TEAMER'],
];
yield 'admin and manager yield both markers' => [
(new CrmAttributesResponse())->setAdmin(true)->setManager(true),
[User::PENDING_ROLES['ROLE_ADMIN'], User::PENDING_ROLES['ROLE_MANAGER']],
[User::PENDING_ROLES['ROLE_TEAM_ADMIN'], User::PENDING_ROLES['ROLE_MANAGER']],
];
yield 'manager and house manager yield both markers, the roles stand on their own' => [
@@ -100,9 +100,9 @@ class UserDataHandlerTest extends TestCase
public static function toPendingRolesProvider(): iterable
{
yield 'admin' => [
['ROLE_ADMIN'],
['ROLE_TEAM_ADMIN'],
(new CrmAttributesResponse())->setAdmin(true),
[User::PENDING_ROLES['ROLE_ADMIN']],
[User::PENDING_ROLES['ROLE_TEAM_ADMIN']],
];
yield 'manager and house manager are marked independently' => [
@@ -118,9 +118,9 @@ class UserDataHandlerTest extends TestCase
];
yield 'admin and house manager' => [
['ROLE_ADMIN', 'ROLE_HOUSE_MANAGER'],
['ROLE_TEAM_ADMIN', 'ROLE_HOUSE_MANAGER'],
(new CrmAttributesResponse())->setAdmin(true)->setHouseManager(true),
[User::PENDING_ROLES['ROLE_ADMIN'], User::PENDING_ROLES['ROLE_HOUSE_MANAGER']],
[User::PENDING_ROLES['ROLE_TEAM_ADMIN'], User::PENDING_ROLES['ROLE_HOUSE_MANAGER']],
];
yield 'teamer has no marker' => [
@@ -144,7 +144,7 @@ class UserDataHandlerTest extends TestCase
->setEmail('[email protected]')
->setBusProAddressId(1)
->setBusProPersonId(2)
->setRoles(['ROLE_ADMIN'])
->setRoles(['ROLE_TEAM_ADMIN'])
->setHotelCodes(['XYZ'])
;
@@ -172,7 +172,7 @@ class UserDataHandlerTest extends TestCase
$profileResponse,
true,
['team' => ['selected' => true]],
['ROLE_ADMIN', 'ROLE_TEAMER'],
['ROLE_TEAM_ADMIN', 'ROLE_TEAMER'],
['DKS'],
);
@@ -182,7 +182,7 @@ class UserDataHandlerTest extends TestCase
// the CRM leads: the still claimed role survives, the houses are replaced by its own
$this->assertSame(['DKS'], $user->getHotelCodes());
$this->assertTrue($user->hasRole('ROLE_ADMIN'));
$this->assertTrue($user->hasRole('ROLE_TEAM_ADMIN'));
$this->assertSame('New', $teamer->getFirstName());
$this->assertSame('Lastname', $teamer->getLastName());
@@ -200,6 +200,37 @@ class UserDataHandlerTest extends TestCase
$this->assertSame(['team' => ['selected' => true]], $teamer->getCrmSelections());
}
/**
* BusPro compares addresses case-insensitively and MyE&P lowercases them on login, so a
* mixed-case address coming back from the API must land lowercased on both the user and
* the teamer - otherwise the login identity and the mailing address drift apart.
*/
public function testUpdateLocalUserNormalizesTheAddressBusProReports(): void
{
$user = (new User())
->setFirstName('Old')
->setLastName('Name')
->setEmail('[email protected]')
->setBusProAddressId(1)
->setBusProPersonId(2)
;
$teamer = (new Teamer())
->setCommunication((new Communication())->setEmail('[email protected]'))
;
$user->setTeamer($teamer);
$profileResponse = $this->createProfileResponse()
->setCommunication((new BusProCommunication())->setEmail('[email protected]'))
;
$handler = new UserDataHandler($this->entityManager, $this->logger);
$handler->updateLocalUser($user, $profileResponse, true, [], ['ROLE_TEAMER'], []);
$this->assertSame('[email protected]', $user->getEmail());
$this->assertSame('[email protected]', $teamer->getCommunication()?->getEmail());
}
/**
* @dataProvider pendingRolesProvider
*/
@@ -248,9 +279,9 @@ class UserDataHandlerTest extends TestCase
yield 'a claim beyond the approved role stays pending' => [
['ROLE_MANAGER'],
['ROLE_ADMIN', 'ROLE_MANAGER'],
['ROLE_TEAM_ADMIN', 'ROLE_MANAGER'],
['ROLE_MANAGER'],
[User::PENDING_ROLES['ROLE_ADMIN']],
[User::PENDING_ROLES['ROLE_TEAM_ADMIN']],
];
yield 'the claimed role changes' => [
@@ -261,7 +292,7 @@ class UserDataHandlerTest extends TestCase
];
yield 'a granted role is revoked once the CRM stops claiming it' => [
['ROLE_ADMIN', 'ROLE_TEAMER'],
['ROLE_TEAM_ADMIN', 'ROLE_TEAMER'],
['ROLE_TEAMER'],
['ROLE_TEAMER'],
[],
@@ -282,14 +313,14 @@ class UserDataHandlerTest extends TestCase
->setFirstName('First')
->setLastName('Last')
->setEmail('[email protected]')
->setRoles([User::PENDING_ROLES['ROLE_ADMIN']])
->setRoles([User::PENDING_ROLES['ROLE_TEAM_ADMIN']])
;
$handler = new UserDataHandler($this->entityManager, $this->logger);
$handler->updateLocalUser($user, $this->createProfileResponse(), true, [], ['ROLE_ADMIN', 'ROLE_TEAMER']);
$handler->updateLocalUser($user, $this->createProfileResponse(), true, [], ['ROLE_TEAM_ADMIN', 'ROLE_TEAMER']);
$this->assertSame(['ROLE_TEAMER'], $user->getAssignedRoles());
$this->assertSame([User::PENDING_ROLES['ROLE_ADMIN']], $user->getPendingRoles());
$this->assertSame([User::PENDING_ROLES['ROLE_TEAM_ADMIN']], $user->getPendingRoles());
}
/**
@@ -302,13 +333,13 @@ class UserDataHandlerTest extends TestCase
->setFirstName('First')
->setLastName('Last')
->setEmail('[email protected]')
->setRoles(['ROLE_ADMIN', 'ROLE_TEAMER'])
->setRoles(['ROLE_TEAM_ADMIN', 'ROLE_TEAMER'])
;
$handler = new UserDataHandler($this->entityManager, $this->logger);
$handler->updateLocalUser($user, $this->createProfileResponse(), false, [], ['ROLE_ADMIN']);
$handler->updateLocalUser($user, $this->createProfileResponse(), false, [], ['ROLE_TEAM_ADMIN']);
$this->assertSame(['ROLE_ADMIN'], $user->getAssignedRoles());
$this->assertSame(['ROLE_TEAM_ADMIN'], $user->getAssignedRoles());
}
public function testUpdateLocalUserGrantsTheTeamerRoleOnlyOnce(): void
@@ -327,8 +358,8 @@ class UserDataHandlerTest extends TestCase
}
/**
* ROLE_SUPER_ADMIN is not a stored role but a flag getRoles() turns into one, so
* revoking ROLE_ADMIN has to take it down explicitly - otherwise the highest privilege
* ROLE_TEAM_SUPER_ADMIN is not a stored role but a flag getRoles() turns into one, so
* revoking ROLE_TEAM_ADMIN has to take it down explicitly - otherwise the highest privilege
* in the application would outlive the role it depends on.
*/
public function testUpdateLocalUserTakesTheSuperAdminFlagDownWithRoleAdmin(): void
@@ -337,7 +368,7 @@ class UserDataHandlerTest extends TestCase
->setFirstName('First')
->setLastName('Last')
->setEmail('[email protected]')
->setRoles(['ROLE_ADMIN', 'ROLE_TEAMER'])
->setRoles(['ROLE_TEAM_ADMIN', 'ROLE_TEAMER'])
->setSuperAdmin(true)
;
@@ -346,7 +377,7 @@ class UserDataHandlerTest extends TestCase
$this->assertSame(['ROLE_TEAMER'], $user->getAssignedRoles());
$this->assertFalse($user->isSuperAdmin());
$this->assertFalse($user->hasRole('ROLE_SUPER_ADMIN'));
$this->assertFalse($user->hasRole('ROLE_TEAM_SUPER_ADMIN'));
}
public function testUpdateLocalUserKeepsTheSuperAdminFlagOfAStillClaimedAdmin(): void
@@ -355,15 +386,15 @@ class UserDataHandlerTest extends TestCase
->setFirstName('First')
->setLastName('Last')
->setEmail('[email protected]')
->setRoles(['ROLE_ADMIN', 'ROLE_TEAMER'])
->setRoles(['ROLE_TEAM_ADMIN', 'ROLE_TEAMER'])
->setSuperAdmin(true)
;
$handler = new UserDataHandler($this->entityManager, $this->logger);
$handler->updateLocalUser($user, $this->createProfileResponse(), false, [], ['ROLE_ADMIN']);
$handler->updateLocalUser($user, $this->createProfileResponse(), false, [], ['ROLE_TEAM_ADMIN']);
$this->assertTrue($user->isSuperAdmin());
$this->assertTrue($user->hasRole('ROLE_SUPER_ADMIN'));
$this->assertTrue($user->hasRole('ROLE_TEAM_SUPER_ADMIN'));
}
public function testUpdateLocalUserReplacesTheHotelCodesWithTheOnesTheCrmReports(): void
@@ -394,7 +425,7 @@ class UserDataHandlerTest extends TestCase
->setEmail('[email protected]')
->setRoles([
'ROLE_TEAMER',
User::PENDING_ROLES['ROLE_ADMIN'],
User::PENDING_ROLES['ROLE_TEAM_ADMIN'],
User::PENDING_ROLES['ROLE_HOUSE_MANAGER'],
])
;
@@ -403,8 +434,8 @@ class UserDataHandlerTest extends TestCase
$handler = new UserDataHandler($this->entityManager, $this->logger);
$this->assertTrue($handler->approveRole($user, 'ROLE_ADMIN'));
$this->assertSame(['ROLE_TEAMER', 'ROLE_ADMIN'], $user->getAssignedRoles());
$this->assertTrue($handler->approveRole($user, 'ROLE_TEAM_ADMIN'));
$this->assertSame(['ROLE_TEAMER', 'ROLE_TEAM_ADMIN'], $user->getAssignedRoles());
// the other nomination is untouched: one decision at a time
$this->assertSame([User::PENDING_ROLES['ROLE_HOUSE_MANAGER']], $user->getPendingRoles());
@@ -437,13 +468,13 @@ class UserDataHandlerTest extends TestCase
{
yield 'the CRM never claimed it' => [['ROLE_TEAMER'], 'ROLE_MANAGER'];
yield 'a different role is nominated' => [[User::PENDING_ROLES['ROLE_MANAGER']], 'ROLE_ADMIN'];
yield 'a different role is nominated' => [[User::PENDING_ROLES['ROLE_MANAGER']], 'ROLE_TEAM_ADMIN'];
yield 'already granted, so there is no marker left' => [['ROLE_ADMIN'], 'ROLE_ADMIN'];
yield 'already granted, so there is no marker left' => [['ROLE_TEAM_ADMIN'], 'ROLE_TEAM_ADMIN'];
yield 'teamer has no nomination to approve' => [['ROLE_TEAMER'], 'ROLE_TEAMER'];
yield 'not a role at all' => [[User::PENDING_ROLES['ROLE_ADMIN']], 'ROLE_SUPER_ADMIN'];
yield 'not a role at all' => [[User::PENDING_ROLES['ROLE_TEAM_ADMIN']], 'ROLE_TEAM_SUPER_ADMIN'];
}
public function testDisableForRevokedCrmRolesBlocksTheUserAndDropsThePendingMarkers(): void
@@ -452,7 +483,7 @@ class UserDataHandlerTest extends TestCase
->setFirstName('First')
->setLastName('Last')
->setEmail('[email protected]')
->setRoles(['ROLE_ADMIN', User::PENDING_ROLES['ROLE_MANAGER']])
->setRoles(['ROLE_TEAM_ADMIN', User::PENDING_ROLES['ROLE_MANAGER']])
;
$this->entityManager
@@ -468,7 +499,7 @@ class UserDataHandlerTest extends TestCase
$this->assertSame('Automatisch gesperrt: keine Rollen in BusPro.', $user->getDisabledReasonInternal());
// the granted role is kept so the user stays reviewable, the marker is not
$this->assertSame(['ROLE_ADMIN'], $user->getAssignedRoles());
$this->assertSame(['ROLE_TEAM_ADMIN'], $user->getAssignedRoles());
$this->assertSame([], $user->getPendingRoles());
}
@@ -480,7 +511,7 @@ class UserDataHandlerTest extends TestCase
->setFirstName('First')
->setLastName('Last')
->setEmail('[email protected]')
->setRoles(['ROLE_ADMIN'])
->setRoles(['ROLE_TEAM_ADMIN'])
->setDisabledAt($disabledAt)
->setDisabledReason('Wegen Fehlverhaltens gesperrt.')
->setDisabledReasonInternal('Siehe Vorgang 4711.')
+22
View File
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Tests\Entity;
use App\Entity\Contact;
use PHPUnit\Framework\TestCase;
class ContactTest extends TestCase
{
/**
* Office contacts are normalized the same way User::setEmail() normalizes, so that every
* stored address has one shape regardless of which form wrote it.
*/
public function testSetEmailNormalizesCaseAndSurroundingWhitespace(): void
{
$contact = (new Contact())->setEmail(' [email protected] ');
$this->assertSame('[email protected]', $contact->getEmail());
}
}

Some files were not shown because too many files have changed in this diff Show More