170 lines
6.2 KiB
PHP
170 lines
6.2 KiB
PHP
<?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;
|
|
}
|
|
}
|