claims(); self::assertSame(['sub', 'email'], array_keys($claims)); self::assertSame('42', $claims['sub']); self::assertSame(self::LOGIN_EMAIL, $claims['email']); } public function testSubIsTheLocalAccountAndNotTheBusProPerson(): void { $claims = $this->claims(['ROLE_OAUTH2_ID']); // The ids identify the human, the subject identifies the account. A second account of the // same person reports these same two ids and must still be told apart. self::assertSame('42', $claims['sub']); self::assertSame(7, $claims['person_id']); self::assertSame(9, $claims['address_id']); } public function testEmailIsTheAuthenticatedAddressAndNotTheFirstContactOnTheBusProRecord(): void { $claims = $this->claims(['ROLE_OAUTH2_PROFILE']); self::assertSame(self::LOGIN_EMAIL, $claims['email']); self::assertSame(self::CONTACT_EMAIL, $claims['profile']['communication']['email']); } public function testRolesExportOnlyWhatIsEffective(): void { $claims = $this->claims(['ROLE_OAUTH2_ROLES'], [ Role::USER, Role::EMPLOYEE, Role::TEAMER, Role::pending(Role::MANAGER), ]); // ROLE_EMPLOYEE is exported because consumers must read staff status from here rather than // re-deriving it from an email domain — which, given the two addresses, would disagree. self::assertSame([Role::EMPLOYEE, Role::TEAMER], $claims['roles']); } public function testUpstreamFailureIsReportedRatherThanAnswered(): void { $apiClient = $this->createStub(ApiClient::class); $apiClient->method('getPersonalData')->willThrowException(new \App\BusProNet\Exception\ApiClientException('nope')); $response = $this->call($apiClient, []); self::assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); } /** * @param string[] $grantedScopeRoles * @param string[] $roles * * @return array */ private function claims(array $grantedScopeRoles = [], array $roles = [Role::TEAMER]): array { $apiClient = $this->createStub(ApiClient::class); $apiClient->method('getPersonalData')->willReturn($this->personalData()); $response = $this->call($apiClient, $grantedScopeRoles, $roles); self::assertSame(Response::HTTP_OK, $response->getStatusCode()); return json_decode((string) $response->getContent(), true); } /** * @param string[] $grantedScopeRoles * @param string[] $roles */ private function call(ApiClient $apiClient, array $grantedScopeRoles, array $roles = [Role::TEAMER]): Response { $crypt = $this->createStub(Crypt::class); $crypt->method('decrypt')->willReturn('md5-of-the-password'); $controller = new UserinfoController($apiClient, $crypt); $controller->setContainer($this->container($this->user($roles), $grantedScopeRoles)); return $controller->index(); } /** * @param string[] $grantedScopeRoles */ private function container(User $user, array $grantedScopeRoles): Container { $tokenStorage = new TokenStorage(); $tokenStorage->setToken(new UsernamePasswordToken($user, 'api', $user->getRoles())); $authorizationChecker = $this->createStub(AuthorizationCheckerInterface::class); $authorizationChecker->method('isGranted')->willReturnCallback( static fn (mixed $attribute): bool => \in_array($attribute, $grantedScopeRoles, true), ); // No 'serializer' service registered, so AbstractController::json() falls back to // JsonResponse — which is what this endpoint produces in production anyway. $container = new Container(); $container->set('security.token_storage', $tokenStorage); $container->set('security.authorization_checker', $authorizationChecker); return $container; } /** * @param string[] $roles */ private function user(array $roles): User { $user = (new User(self::LOGIN_EMAIL)) ->setPassword('encrypted') ->setRoles($roles) ; // The id is generated by Doctrine and has no setter, but it is what `sub` exports. $property = new \ReflectionProperty(User::class, 'id'); $property->setValue($user, 42); return $user; } private function personalData(): PersonalData { $personalData = new PersonalData(); $personalData->personId = 7; $personalData->addressId = 9; $personalData->firstName = 'Alex'; $personalData->name = 'Beispiel'; // Deliberately not the login address: BusPro returns the first contact row on the record, // which is whichever of the person's addresses happens to come first. $personalData->communication->email = self::CONTACT_EMAIL; return $personalData; } }