feat: identify the authenticated account in the userinfo claims

This commit is contained in:
2026-09-19 10:54:06 +02:00
parent 2cb4871268
commit ec83ad598f
5 changed files with 241 additions and 5 deletions
@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Api;
use App\BusProNet\ApiClient;
use App\BusProNet\Model\PersonalData;
use App\Controller\Api\UserinfoController;
use App\Entity\User;
use App\Security\Crypt;
use App\Security\Role;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\Container;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* The claims are a published contract (docs/api-consumer-guide.md), and the identity claims in
* particular: a BusPro person may sign in under any address on its record, every one of those is
* a separate account with its own roles, and BusPro answers all of them identically. So what
* separates the accounts is asserted here rather than left to the consumers to discover.
*/
class UserinfoControllerTest extends TestCase
{
private const LOGIN_EMAIL = '[email protected]';
private const CONTACT_EMAIL = '[email protected]';
public function testSubAndEmailAreExportedWithoutAnyOptionalScope(): void
{
$claims = $this->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<string, mixed>
*/
private function claims(array $grantedScopeRoles = [], array $roles = [Role::TEAMER]): array
{
$apiClient = $this->createStub(ApiClient::class);
$apiClient->method('getPersonalData')->willReturn($this->personalData());
$response = $this->call($apiClient, $grantedScopeRoles, $roles);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
return json_decode((string) $response->getContent(), true);
}
/**
* @param string[] $grantedScopeRoles
* @param string[] $roles
*/
private function call(ApiClient $apiClient, array $grantedScopeRoles, array $roles = [Role::TEAMER]): Response
{
$crypt = $this->createStub(Crypt::class);
$crypt->method('decrypt')->willReturn('md5-of-the-password');
$controller = new UserinfoController($apiClient, $crypt);
$controller->setContainer($this->container($this->user($roles), $grantedScopeRoles));
return $controller->index();
}
/**
* @param string[] $grantedScopeRoles
*/
private function container(User $user, array $grantedScopeRoles): Container
{
$tokenStorage = new TokenStorage();
$tokenStorage->setToken(new UsernamePasswordToken($user, 'api', $user->getRoles()));
$authorizationChecker = $this->createStub(AuthorizationCheckerInterface::class);
$authorizationChecker->method('isGranted')->willReturnCallback(
static fn (mixed $attribute): bool => \in_array($attribute, $grantedScopeRoles, true),
);
// No 'serializer' service registered, so AbstractController::json() falls back to
// JsonResponse — which is what this endpoint produces in production anyway.
$container = new Container();
$container->set('security.token_storage', $tokenStorage);
$container->set('security.authorization_checker', $authorizationChecker);
return $container;
}
/**
* @param string[] $roles
*/
private function user(array $roles): User
{
$user = (new User(self::LOGIN_EMAIL))
->setPassword('encrypted')
->setRoles($roles)
;
// The id is generated by Doctrine and has no setter, 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;
}
}