From 93512b83452946cc71dd856de9fa48e2f8a5f71f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Mon, 5 Oct 2026 17:06:37 +0200 Subject: [PATCH] feat: add employee-only supervisor role nominated by crm selection 1487 --- docs/buspronet-schema/crm-selection-queries.md | 3 ++- .../XmlParser/CrmAttributesResponseParser.php | 1 + src/Security/Role.php | 5 +++++ .../CrmAttributesResponseParserTest.php | 18 ++++++++++++++++++ .../Controller/Api/UserinfoControllerTest.php | 14 ++++++++++++++ tests/Security/RoleTest.php | 9 +++++++++ 6 files changed, 49 insertions(+), 1 deletion(-) diff --git a/docs/buspronet-schema/crm-selection-queries.md b/docs/buspronet-schema/crm-selection-queries.md index 8546158f..bc3aed06 100644 --- a/docs/buspronet-schema/crm-selection-queries.md +++ b/docs/buspronet-schema/crm-selection-queries.md @@ -24,6 +24,7 @@ Symfony roles: | `1478` | `ROLE_GROUPS_ADMIN` | | `1483` | `ROLE_CUSTOMER_EXPERT` | | `1484` | `ROLE_TEAM_ADMIN` (not consumed here — exported through `/api/userinfo` for myep-team) | +| `1487` | `ROLE_SUPERVISOR` (not consumed here — exported through `/api/userinfo` for ephub) | | label `Hausleitung {CODE}` | `ROLE_HOUSE_MANAGER` + hotel code `{CODE}` | | nothing matched | `ROLE_CUSTOMER` | @@ -45,7 +46,7 @@ filtered before anything is stored: | --- | --- | --- | | `ROLE_TEAMER` | granted | granted | | `ROLE_HOUSE_MANAGER` | nominated | nominated | -| `ROLE_ADMIN`, `ROLE_TEAM_ADMIN`, `ROLE_MANAGER`, `ROLE_GROUPS_ADMIN`, `ROLE_GROUPS_MANAGER`, `ROLE_CUSTOMER_EXPERT` (`Role::EMPLOYEE_ONLY`) | **ignored** | nominated | +| `ROLE_ADMIN`, `ROLE_TEAM_ADMIN`, `ROLE_MANAGER`, `ROLE_GROUPS_ADMIN`, `ROLE_GROUPS_MANAGER`, `ROLE_CUSTOMER_EXPERT`, `ROLE_SUPERVISOR` (`Role::EMPLOYEE_ONLY`) | **ignored** | nominated | - **Staff address**: the login email's domain is listed in `employee_email_domains` (`config/services.yaml`, exact match, never a suffix). Such an account also gets diff --git a/src/BusProNet/XmlParser/CrmAttributesResponseParser.php b/src/BusProNet/XmlParser/CrmAttributesResponseParser.php index ba2c3fc1..0d940943 100644 --- a/src/BusProNet/XmlParser/CrmAttributesResponseParser.php +++ b/src/BusProNet/XmlParser/CrmAttributesResponseParser.php @@ -41,6 +41,7 @@ class CrmAttributesResponseParser 1478 => Role::GROUPS_ADMIN, 1483 => Role::CUSTOMER_EXPERT, 1484 => Role::TEAM_ADMIN, + 1487 => Role::SUPERVISOR, ]; /** diff --git a/src/Security/Role.php b/src/Security/Role.php index 49f9a1f1..f9d1970e 100644 --- a/src/Security/Role.php +++ b/src/Security/Role.php @@ -46,6 +46,7 @@ final class Role public const GROUPS_MANAGER = 'ROLE_GROUPS_MANAGER'; public const CUSTOMER_EXPERT = 'ROLE_CUSTOMER_EXPERT'; public const EMPLOYEE = 'ROLE_EMPLOYEE'; + public const SUPERVISOR = 'ROLE_SUPERVISOR'; /** * Appended to an administrative role to mark it as claimed by the CRM but not yet approved. @@ -72,6 +73,7 @@ final class Role self::GROUPS_MANAGER, self::CUSTOMER_EXPERT, self::EMPLOYEE, + self::SUPERVISOR, ]; /** @@ -102,6 +104,7 @@ final class Role self::GROUPS_ADMIN, self::GROUPS_MANAGER, self::CUSTOMER_EXPERT, + self::SUPERVISOR, ]; /** @@ -121,6 +124,7 @@ final class Role self::GROUPS_ADMIN, self::GROUPS_MANAGER, self::CUSTOMER_EXPERT, + self::SUPERVISOR, ]; /** @@ -333,6 +337,7 @@ final class Role self::GROUPS_MANAGER => 'GRO-Expert:in', self::CUSTOMER_EXPERT => 'KO-Expert:in', self::EMPLOYEE => 'Mitarbeiter:in', + self::SUPERVISOR => 'Leitung', ]; foreach (self::ADMINISTRATIVE as $role) { diff --git a/tests/BusProNet/XmlParser/CrmAttributesResponseParserTest.php b/tests/BusProNet/XmlParser/CrmAttributesResponseParserTest.php index d77bc6da..de73f8c7 100644 --- a/tests/BusProNet/XmlParser/CrmAttributesResponseParserTest.php +++ b/tests/BusProNet/XmlParser/CrmAttributesResponseParserTest.php @@ -67,6 +67,24 @@ class CrmAttributesResponseParserTest extends TestCase self::assertNotContains('ROLE_TEAM_ADMIN', $roles); } + /** + * ROLE_SUPERVISOR is not consumed here either — it is exported through /api/userinfo for + * ephub. + */ + public function testParseAssignsSupervisorRoleWhenSelected(): void + { + $roles = $this->parseRoles($this->selectionXml(1487, true)); + + self::assertContains('ROLE_SUPERVISOR', $roles); + } + + public function testParseAssignsNoSupervisorRoleWhenNotSelected(): void + { + $roles = $this->parseRoles($this->selectionXml(1487, false)); + + self::assertNotContains('ROLE_SUPERVISOR', $roles); + } + public function testParseAssignsNoCustomerExpertRoleWhenNotSelected(): void { $roles = $this->parseRoles($this->selectionXml(1483, false)); diff --git a/tests/Controller/Api/UserinfoControllerTest.php b/tests/Controller/Api/UserinfoControllerTest.php index a2013436..99ef02b6 100644 --- a/tests/Controller/Api/UserinfoControllerTest.php +++ b/tests/Controller/Api/UserinfoControllerTest.php @@ -102,6 +102,20 @@ class UserinfoControllerTest extends TestCase self::assertSame([Role::EMPLOYEE, Role::TEAM_ADMIN], $claims['roles']); } + /** + * Same for ephub, which reads ROLE_SUPERVISOR out of this claim. + */ + public function testSupervisorIsExportedOnceApproved(): void + { + $claims = $this->claims(['ROLE_OAUTH2_ROLES'], [ + Role::USER, + Role::EMPLOYEE, + Role::SUPERVISOR, + ]); + + self::assertSame([Role::EMPLOYEE, Role::SUPERVISOR], $claims['roles']); + } + public function testUpstreamFailureIsReportedRatherThanAnswered(): void { $accountClient = $this->createStub(AccountClient::class); diff --git a/tests/Security/RoleTest.php b/tests/Security/RoleTest.php index 318f9f35..69a7d79b 100644 --- a/tests/Security/RoleTest.php +++ b/tests/Security/RoleTest.php @@ -44,6 +44,15 @@ class RoleTest extends TestCase self::assertSame([Role::EMPLOYEE, Role::CUSTOMER_EXPERT], $roles); } + public function testSupervisorClaimOnlyProducesANomination(): void + { + $roles = Role::sync([], [Role::EMPLOYEE, Role::SUPERVISOR]); + + self::assertSame([Role::EMPLOYEE, Role::pending(Role::SUPERVISOR)], $roles); + self::assertSame([Role::SUPERVISOR => 'Leitung'], Role::nominatedFrom($roles)); + self::assertSame([Role::EMPLOYEE, Role::SUPERVISOR], Role::approve($roles, Role::SUPERVISOR)); + } + public function testApprovedRoleSurvivesTheNextSyncAndIsNotMarkedAgain(): void { $roles = Role::sync([Role::TEAMER, Role::HOUSE_MANAGER], [Role::HOUSE_MANAGER, Role::TEAMER]);