feat: identify the authenticated account in the userinfo claims
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user