Merge pull request #960 from acelaya-forks/feature/api-roles-cli

Feature/api roles cli
This commit is contained in:
Alejandro Celaya 2021-01-11 20:35:48 +01:00 committed by GitHub
commit da9e9df4ba
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 467 additions and 85 deletions

View file

@ -30,7 +30,7 @@
"laminas/laminas-diactoros": "^2.1.3",
"laminas/laminas-inputfilter": "^2.10",
"laminas/laminas-paginator": "^2.8",
"laminas/laminas-servicemanager": "^3.4",
"laminas/laminas-servicemanager": "^3.6",
"laminas/laminas-stdlib": "^3.2",
"lcobucci/jwt": "^4.0",
"league/uri": "^6.2",

View file

@ -8,7 +8,6 @@ use Doctrine\DBAL\Connection;
use GeoIp2\Database\Reader;
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Laminas\ServiceManager\Factory\InvokableFactory;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory;
use Shlinkio\Shlink\Core\Domain\DomainService;
use Shlinkio\Shlink\Core\Service;
@ -32,7 +31,8 @@ return [
SymfonyCli\Helper\ProcessHelper::class => ProcessHelperFactory::class,
PhpExecutableFinder::class => InvokableFactory::class,
GeolocationDbUpdater::class => ConfigAbstractFactory::class,
Util\GeolocationDbUpdater::class => ConfigAbstractFactory::class,
ApiKey\RoleResolver::class => ConfigAbstractFactory::class,
Command\ShortUrl\GenerateShortUrlCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\ResolveUrlCommand::class => ConfigAbstractFactory::class,
@ -59,7 +59,8 @@ return [
],
ConfigAbstractFactory::class => [
GeolocationDbUpdater::class => [DbUpdater::class, Reader::class, LOCAL_LOCK_FACTORY],
Util\GeolocationDbUpdater::class => [DbUpdater::class, Reader::class, LOCAL_LOCK_FACTORY],
ApiKey\RoleResolver::class => [DomainService::class],
Command\ShortUrl\GenerateShortUrlCommand::class => [
Service\UrlShortener::class,
@ -75,10 +76,10 @@ return [
Visit\VisitLocator::class,
IpLocationResolverInterface::class,
LockFactory::class,
GeolocationDbUpdater::class,
Util\GeolocationDbUpdater::class,
],
Command\Api\GenerateKeyCommand::class => [ApiKeyService::class],
Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, ApiKey\RoleResolver::class],
Command\Api\DisableKeyCommand::class => [ApiKeyService::class],
Command\Api\ListKeysCommand::class => [ApiKeyService::class],

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\ApiKey;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Symfony\Component\Console\Input\InputInterface;
class RoleResolver implements RoleResolverInterface
{
private DomainServiceInterface $domainService;
public function __construct(DomainServiceInterface $domainService)
{
$this->domainService = $domainService;
}
public function determineRoles(InputInterface $input): array
{
$domainAuthority = $input->getOption('domain-only');
$author = $input->getOption('author-only');
$roleDefinitions = [];
if ($author) {
$roleDefinitions[] = RoleDefinition::forAuthoredShortUrls();
}
if ($domainAuthority !== null) {
$domain = $this->domainService->getOrCreate($domainAuthority);
$roleDefinitions[] = RoleDefinition::forDomain($domain);
}
return $roleDefinitions;
}
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\ApiKey;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Symfony\Component\Console\Input\InputInterface;
interface RoleResolverInterface
{
public const AUTHOR_ONLY_PARAM = 'author-only';
public const DOMAIN_ONLY_PARAM = 'domain-only';
/**
* @return RoleDefinition[]
*/
public function determineRoles(InputInterface $input): array;
}

View file

@ -5,7 +5,10 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Rest\ApiKey\Role;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
@ -13,6 +16,7 @@ use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function Shlinkio\Shlink\Core\arrayToString;
use function sprintf;
class GenerateKeyCommand extends Command
@ -20,15 +24,35 @@ class GenerateKeyCommand extends Command
public const NAME = 'api-key:generate';
private ApiKeyServiceInterface $apiKeyService;
private RoleResolverInterface $roleResolver;
public function __construct(ApiKeyServiceInterface $apiKeyService)
public function __construct(ApiKeyServiceInterface $apiKeyService, RoleResolverInterface $roleResolver)
{
$this->apiKeyService = $apiKeyService;
parent::__construct();
$this->apiKeyService = $apiKeyService;
$this->roleResolver = $roleResolver;
}
protected function configure(): void
{
$authorOnly = RoleResolverInterface::AUTHOR_ONLY_PARAM;
$domainOnly = RoleResolverInterface::DOMAIN_ONLY_PARAM;
$help = <<<HELP
The <info>%command.name%</info> generates a new valid API key.
<info>%command.full_name%</info>
You can optionally set its expiration date with <comment>--expirationDate</comment> or <comment>-e</comment>:
<info>%command.full_name% --expirationDate 2020-01-01</info>
You can also set roles to the API key:
* Can interact with short URLs created with this API key: <info>%command.full_name% --{$authorOnly}</info>
* Can interact with short URLs for one domain: <info>%command.full_name% --{$domainOnly}=example.com</info>
* Both: <info>%command.full_name% --{$authorOnly} --{$domainOnly}=example.com</info>
HELP;
$this
->setName(self::NAME)
->setDescription('Generates a new valid API key.')
@ -37,15 +61,42 @@ class GenerateKeyCommand extends Command
'e',
InputOption::VALUE_REQUIRED,
'The date in which the API key should expire. Use any valid PHP format.',
);
)
->addOption(
$authorOnly,
'a',
InputOption::VALUE_NONE,
sprintf('Adds the "%s" role to the new API key.', Role::AUTHORED_SHORT_URLS),
)
->addOption(
$domainOnly,
'd',
InputOption::VALUE_REQUIRED,
sprintf('Adds the "%s" role to the new API key, with the domain provided.', Role::DOMAIN_SPECIFIC),
)
->setHelp($help);
}
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$expirationDate = $input->getOption('expirationDate');
$apiKey = $this->apiKeyService->create(isset($expirationDate) ? Chronos::parse($expirationDate) : null);
$apiKey = $this->apiKeyService->create(
isset($expirationDate) ? Chronos::parse($expirationDate) : null,
...$this->roleResolver->determineRoles($input),
);
$io = new SymfonyStyle($input, $output);
$io->success(sprintf('Generated API key: "%s"', $apiKey->toString()));
if (! $apiKey->isAdmin()) {
ShlinkTable::fromOutput($io)->render(
['Role name', 'Role metadata'],
$apiKey->mapRoles(fn (string $name, array $meta) => [$name, arrayToString($meta, 0)]),
null,
'Roles',
);
}
(new SymfonyStyle($input, $output))->success(sprintf('Generated API key: "%s"', $apiKey));
return ExitCodes::EXIT_SUCCESS;
}
}

View file

@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\Api;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Rest\ApiKey\Role;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Command\Command;
@ -14,7 +15,8 @@ use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use function array_filter;
use function array_map;
use function Functional\map;
use function implode;
use function sprintf;
class ListKeysCommand extends Command
@ -50,7 +52,7 @@ class ListKeysCommand extends Command
{
$enabledOnly = $input->getOption('enabledOnly');
$rows = array_map(function (ApiKey $apiKey) use ($enabledOnly) {
$rows = map($this->apiKeyService->listKeys($enabledOnly), function (ApiKey $apiKey) use ($enabledOnly) {
$expiration = $apiKey->getExpirationDate();
$messagePattern = $this->determineMessagePattern($apiKey);
@ -60,13 +62,21 @@ class ListKeysCommand extends Command
$rowData[] = sprintf($messagePattern, $this->getEnabledSymbol($apiKey));
}
$rowData[] = $expiration !== null ? $expiration->toAtomString() : '-';
$rowData[] = $apiKey->isAdmin() ? 'Admin' : implode("\n", $apiKey->mapRoles(
fn (string $roleName, array $meta) =>
empty($meta)
? Role::toFriendlyName($roleName)
: sprintf('%s: %s', Role::toFriendlyName($roleName), Role::domainAuthorityFromMeta($meta)),
));
return $rowData;
}, $this->apiKeyService->listKeys($enabledOnly));
});
ShlinkTable::fromOutput($output)->render(array_filter([
'Key',
! $enabledOnly ? 'Is enabled' : null,
'Expiration date',
'Roles',
]), $rows);
return ExitCodes::EXIT_SUCCESS;
}
@ -80,8 +90,6 @@ class ListKeysCommand extends Command
return $apiKey->isExpired() ? self::WARNING_STRING_PATTERN : self::SUCCESS_STRING_PATTERN;
}
/**
*/
private function getEnabledSymbol(ApiKey $apiKey): string
{
return ! $apiKey->isEnabled() || $apiKey->isExpired() ? '---' : '+++';

View file

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\ApiKey;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\ApiKey\RoleResolver;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Symfony\Component\Console\Input\InputInterface;
class RoleResolverTest extends TestCase
{
use ProphecyTrait;
private RoleResolver $resolver;
private ObjectProphecy $domainService;
protected function setUp(): void
{
$this->domainService = $this->prophesize(DomainServiceInterface::class);
$this->resolver = new RoleResolver($this->domainService->reveal());
}
/**
* @test
* @dataProvider provideRoles
*/
public function properRolesAreResolvedBasedOnInput(
InputInterface $input,
array $expectedRoles,
int $expectedDomainCalls
): void {
$getDomain = $this->domainService->getOrCreate('example.com')->willReturn(
(new Domain('example.com'))->setId('1'),
);
$result = $this->resolver->determineRoles($input);
self::assertEquals($expectedRoles, $result);
$getDomain->shouldHaveBeenCalledTimes($expectedDomainCalls);
}
public function provideRoles(): iterable
{
$domain = (new Domain('example.com'))->setId('1');
$buildInput = function (array $definition): InputInterface {
$input = $this->prophesize(InputInterface::class);
foreach ($definition as $name => $value) {
$input->getOption($name)->willReturn($value);
}
return $input->reveal();
};
yield 'no roles' => [
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => null, RoleResolver::AUTHOR_ONLY_PARAM => false]),
[],
0,
];
yield 'domain role only' => [
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => 'example.com', RoleResolver::AUTHOR_ONLY_PARAM => false]),
[RoleDefinition::forDomain($domain)],
1,
];
yield 'author role only' => [
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => null, RoleResolver::AUTHOR_ONLY_PARAM => true]),
[RoleDefinition::forAuthoredShortUrls()],
0,
];
yield 'both roles' => [
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => 'example.com', RoleResolver::AUTHOR_ONLY_PARAM => true]),
[RoleDefinition::forAuthoredShortUrls(), RoleDefinition::forDomain($domain)],
1,
];
}
}

View file

@ -9,10 +9,12 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface;
use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Tester\CommandTester;
class GenerateKeyCommandTest extends TestCase
@ -21,11 +23,15 @@ class GenerateKeyCommandTest extends TestCase
private CommandTester $commandTester;
private ObjectProphecy $apiKeyService;
private ObjectProphecy $roleResolver;
public function setUp(): void
{
$this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class);
$command = new GenerateKeyCommand($this->apiKeyService->reveal());
$this->roleResolver = $this->prophesize(RoleResolverInterface::class);
$this->roleResolver->determineRoles(Argument::type(InputInterface::class))->willReturn([]);
$command = new GenerateKeyCommand($this->apiKeyService->reveal(), $this->roleResolver->reveal());
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);

View file

@ -8,6 +8,8 @@ use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Api\ListKeysCommand;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Application;
@ -29,42 +31,87 @@ class ListKeysCommandTest extends TestCase
$this->commandTester = new CommandTester($command);
}
/** @test */
public function everythingIsListedIfEnabledOnlyIsNotProvided(): void
/**
* @test
* @dataProvider provideKeysAndOutputs
*/
public function returnsExpectedOutput(array $keys, bool $enabledOnly, string $expected): void
{
$this->apiKeyService->listKeys(false)->willReturn([
new ApiKey(),
new ApiKey(),
new ApiKey(),
])->shouldBeCalledOnce();
$listKeys = $this->apiKeyService->listKeys($enabledOnly)->willReturn($keys);
$this->commandTester->execute([]);
$this->commandTester->execute(['--enabledOnly' => $enabledOnly]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString('Key', $output);
self::assertStringContainsString('Is enabled', $output);
self::assertStringContainsString(' +++ ', $output);
self::assertStringNotContainsString(' --- ', $output);
self::assertStringContainsString('Expiration date', $output);
self::assertEquals($expected, $output);
$listKeys->shouldHaveBeenCalledOnce();
}
/** @test */
public function onlyEnabledKeysAreListedIfEnabledOnlyIsProvided(): void
public function provideKeysAndOutputs(): iterable
{
$this->apiKeyService->listKeys(true)->willReturn([
(new ApiKey())->disable(),
new ApiKey(),
])->shouldBeCalledOnce();
yield 'all keys' => [
[ApiKey::withKey('foo'), ApiKey::withKey('bar'), ApiKey::withKey('baz')],
false,
<<<OUTPUT
+-----+------------+-----------------+-------+
| Key | Is enabled | Expiration date | Roles |
+-----+------------+-----------------+-------+
| foo | +++ | - | Admin |
| bar | +++ | - | Admin |
| baz | +++ | - | Admin |
+-----+------------+-----------------+-------+
$this->commandTester->execute([
'--enabledOnly' => true,
]);
$output = $this->commandTester->getDisplay();
OUTPUT,
];
yield 'enabled keys' => [
[ApiKey::withKey('foo')->disable(), ApiKey::withKey('bar')],
true,
<<<OUTPUT
+-----+-----------------+-------+
| Key | Expiration date | Roles |
+-----+-----------------+-------+
| foo | - | Admin |
| bar | - | Admin |
+-----+-----------------+-------+
self::assertStringContainsString('Key', $output);
self::assertStringNotContainsString('Is enabled', $output);
self::assertStringNotContainsString(' +++ ', $output);
self::assertStringNotContainsString(' --- ', $output);
self::assertStringContainsString('Expiration date', $output);
OUTPUT,
];
yield 'with roles' => [
[
ApiKey::withKey('foo'),
$this->apiKeyWithRoles('bar', [RoleDefinition::forAuthoredShortUrls()]),
$this->apiKeyWithRoles('baz', [RoleDefinition::forDomain((new Domain('example.com'))->setId('1'))]),
ApiKey::withKey('foo2'),
$this->apiKeyWithRoles('baz2', [
RoleDefinition::forAuthoredShortUrls(),
RoleDefinition::forDomain((new Domain('example.com'))->setId('1')),
]),
ApiKey::withKey('foo3'),
],
true,
<<<OUTPUT
+------+-----------------+--------------------------+
| Key | Expiration date | Roles |
+------+-----------------+--------------------------+
| foo | - | Admin |
| bar | - | Author only |
| baz | - | Domain only: example.com |
| foo2 | - | Admin |
| baz2 | - | Author only |
| | | Domain only: example.com |
| foo3 | - | Admin |
+------+-----------------+--------------------------+
OUTPUT,
];
}
private function apiKeyWithRoles(string $key, array $roles): ApiKey
{
$apiKey = ApiKey::withKey($key);
foreach ($roles as $role) {
$apiKey->registerRole($role);
}
return $apiKey;
}
}

View file

@ -10,7 +10,11 @@ use Fig\Http\Message\StatusCodeInterface;
use Laminas\InputFilter\InputFilter;
use PUGX\Shortid\Factory as ShortIdFactory;
use function Functional\reduce_left;
use function is_array;
use function print_r;
use function sprintf;
use function str_repeat;
const DEFAULT_DELETE_SHORT_URL_THRESHOLD = 15;
const DEFAULT_SHORT_CODES_LENGTH = 5;
@ -75,3 +79,21 @@ function getOptionalBoolFromInputFilter(InputFilter $inputFilter, string $fieldN
$value = $inputFilter->getValue($fieldName);
return $value !== null ? (bool) $value : null;
}
function arrayToString(array $array, int $indentSize = 4): string
{
$indent = str_repeat(' ', $indentSize);
$index = 0;
return reduce_left($array, static function ($messages, string $name, $_, string $acc) use (&$index, $indent) {
$index++;
return $acc . sprintf(
"%s%s'%s' => %s",
$index === 1 ? '' : "\n",
$indent,
$name,
is_array($messages) ? print_r($messages, true) : $messages,
);
}, '');
}

View file

@ -45,6 +45,9 @@ class DomainService implements DomainServiceInterface
];
}
/**
* @throws DomainNotFoundException
*/
public function getDomain(string $domainId): Domain
{
/** @var Domain|null $domain */
@ -55,4 +58,16 @@ class DomainService implements DomainServiceInterface
return $domain;
}
public function getOrCreate(string $authority): Domain
{
$repo = $this->em->getRepository(Domain::class);
/** @var Domain|null $domain */
$domain = $repo->findOneBy(['authority' => $authority]) ?? new Domain($authority);
$this->em->persist($domain);
$this->em->flush();
return $domain;
}
}

View file

@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Domain;
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
interface DomainServiceInterface
@ -15,5 +16,10 @@ interface DomainServiceInterface
*/
public function listDomains(?ApiKey $apiKey = null): array;
/**
* @throws DomainNotFoundException
*/
public function getDomain(string $domainId): Domain;
public function getOrCreate(string $authority): Domain;
}

View file

@ -11,9 +11,7 @@ use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
use Throwable;
use function array_keys;
use function Functional\reduce_left;
use function is_array;
use function print_r;
use function Shlinkio\Shlink\Core\arrayToString;
use function sprintf;
use const PHP_EOL;
@ -55,24 +53,16 @@ class ValidationException extends InvalidArgumentException implements ProblemDet
public function __toString(): string
{
return sprintf(
'%s %s in %s:%s%s%sStack trace:%s%s',
'%s %s in %s:%s%s%s%sStack trace:%s%s',
__CLASS__,
$this->getMessage(),
$this->getFile(),
$this->getLine(),
$this->invalidElementsToString(),
PHP_EOL,
arrayToString($this->getInvalidElements()),
PHP_EOL,
PHP_EOL,
$this->getTraceAsString(),
);
}
private function invalidElementsToString(): string
{
return reduce_left($this->getInvalidElements(), fn ($messages, string $name, $_, string $acc) => $acc . sprintf(
"\n '%s' => %s",
$name,
is_array($messages) ? print_r($messages, true) : $messages,
), '');
}
}

View file

@ -72,12 +72,12 @@ class DomainRepositoryTest extends DatabaseTestCase
$this->getEntityManager()->flush();
$authorAndDomainApiKey->registerRole(RoleDefinition::forDomain($fooDomain->getId()));
$authorAndDomainApiKey->registerRole(RoleDefinition::forDomain($fooDomain));
$fooDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($fooDomain->getId()));
$fooDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($fooDomain));
$this->getEntityManager()->persist($fooDomainApiKey);
$barDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($barDomain->getId()));
$barDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($barDomain));
$this->getEntityManager()->persist($fooDomainApiKey);
$this->getEntityManager()->flush();

View file

@ -335,9 +335,9 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$this->getEntityManager()->persist($apiKey);
$otherApiKey = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls());
$this->getEntityManager()->persist($otherApiKey);
$wrongDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($wrongDomain->getId()));
$wrongDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($wrongDomain));
$this->getEntityManager()->persist($wrongDomainApiKey);
$rightDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($rightDomain->getId()));
$rightDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($rightDomain));
$this->getEntityManager()->persist($rightDomainApiKey);
$shortUrl = new ShortUrl('foo', ShortUrlMeta::fromRawData(

View file

@ -114,7 +114,7 @@ class TagRepositoryTest extends DatabaseTestCase
$authorApiKey = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls());
$this->getEntityManager()->persist($authorApiKey);
$domainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($domain->getId()));
$domainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($domain));
$this->getEntityManager()->persist($domainApiKey);
$names = ['foo', 'bar', 'baz', 'another'];

View file

@ -221,7 +221,7 @@ class VisitRepositoryTest extends DatabaseTestCase
$this->getEntityManager()->persist($shortUrl3);
$this->createVisitsForShortUrl($shortUrl3, 7);
$domainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($domain->getId()));
$domainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($domain));
$this->getEntityManager()->persist($domainApiKey);
$this->getEntityManager()->flush();

View file

@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\Core\Domain;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Domain\DomainService;
@ -50,7 +51,7 @@ class DomainServiceTest extends TestCase
{
$default = new DomainItem('default.com', true);
$adminApiKey = new ApiKey();
$domainSpecificApiKey = ApiKey::withRoles(RoleDefinition::forDomain('123'));
$domainSpecificApiKey = ApiKey::withRoles(RoleDefinition::forDomain((new Domain(''))->setId('123')));
yield 'empty list without API key' => [[], [$default], null];
yield 'one item without API key' => [
@ -111,4 +112,33 @@ class DomainServiceTest extends TestCase
self::assertSame($domain, $result);
$find->shouldHaveBeenCalledOnce();
}
/**
* @test
* @dataProvider provideFoundDomains
*/
public function getOrCreateAlwaysPersistsDomain(?Domain $foundDomain): void
{
$authority = 'example.com';
$repo = $this->prophesize(DomainRepositoryInterface::class);
$repo->findOneBy(['authority' => $authority])->willReturn($foundDomain);
$getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal());
$persist = $this->em->persist($foundDomain !== null ? $foundDomain : Argument::type(Domain::class));
$flush = $this->em->flush();
$result = $this->domainService->getOrCreate($authority);
if ($foundDomain !== null) {
self::assertSame($result, $foundDomain);
}
$getRepo->shouldHaveBeenCalledOnce();
$persist->shouldHaveBeenCalledOnce();
$flush->shouldHaveBeenCalledOnce();
}
public function provideFoundDomains(): iterable
{
yield 'domain not found' => [null];
yield 'domain found' => [new Domain('')];
}
}

View file

@ -32,14 +32,14 @@ class ValidationExceptionTest extends TestCase
];
$barValue = print_r(['baz', 'foo'], true);
$expectedStringRepresentation = <<<EOT
'foo' => bar
'something' => {$barValue}
EOT;
'foo' => bar
'something' => {$barValue}
EOT;
$inputFilter = $this->prophesize(InputFilterInterface::class);
$getMessages = $inputFilter->getMessages()->willReturn($invalidData);
$e = ValidationException::fromInputFilter($inputFilter->reveal());
$e = ValidationException::fromInputFilter($inputFilter->reveal(), $prev);
self::assertEquals($invalidData, $e->getInvalidElements());
self::assertEquals(['invalidElements' => array_keys($invalidData)], $e->getAdditionalData());
@ -52,6 +52,6 @@ EOT;
public function provideExceptions(): iterable
{
return [[null, new RuntimeException(), new LogicException()]];
return [[null], [new RuntimeException()], [new LogicException()]];
}
}

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\ApiKey\Model;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Rest\ApiKey\Role;
final class RoleDefinition
@ -22,9 +23,12 @@ final class RoleDefinition
return new self(Role::AUTHORED_SHORT_URLS, []);
}
public static function forDomain(string $domainId): self
public static function forDomain(Domain $domain): self
{
return new self(Role::DOMAIN_SPECIFIC, ['domain_id' => $domainId]);
return new self(
Role::DOMAIN_SPECIFIC,
['domain_id' => $domain->getId(), 'authority' => $domain->getAuthority()],
);
}
public function roleName(): string

View file

@ -16,6 +16,10 @@ class Role
{
public const AUTHORED_SHORT_URLS = 'AUTHORED_SHORT_URLS';
public const DOMAIN_SPECIFIC = 'DOMAIN_SPECIFIC';
private const ROLE_FRIENDLY_NAMES = [
self::AUTHORED_SHORT_URLS => 'Author only',
self::DOMAIN_SPECIFIC => 'Domain only',
];
public static function toSpec(ApiKeyRole $role, bool $inlined): Specification
{
@ -35,4 +39,14 @@ class Role
{
return $meta['domain_id'] ?? '-1';
}
public static function domainAuthorityFromMeta(array $meta): string
{
return $meta['authority'] ?? '';
}
public static function toFriendlyName(string $roleName): string
{
return self::ROLE_FRIENDLY_NAMES[$roleName] ?? '';
}
}

View file

@ -115,6 +115,11 @@ class ApiKey extends AbstractEntity
return $role === null ? [] : $role->meta();
}
public function mapRoles(callable $fun): array
{
return $this->roles->map(fn (ApiKeyRole $role) => $fun($role->name(), $role->meta()))->getValues();
}
public function registerRole(RoleDefinition $roleDefinition): void
{
$roleName = $roleDefinition->roleName();

View file

@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Rest\Service;
use Cake\Chronos\Chronos;
use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use function sprintf;
@ -20,9 +21,13 @@ class ApiKeyService implements ApiKeyServiceInterface
$this->em = $em;
}
public function create(?Chronos $expirationDate = null): ApiKey
public function create(?Chronos $expirationDate = null, RoleDefinition ...$roleDefinitions): ApiKey
{
$key = new ApiKey($expirationDate);
foreach ($roleDefinitions as $definition) {
$key->registerRole($definition);
}
$this->em->persist($key);
$this->em->flush();
@ -31,7 +36,6 @@ class ApiKeyService implements ApiKeyServiceInterface
public function check(string $key): ApiKeyCheckResult
{
/** @var ApiKey|null $apiKey */
$apiKey = $this->getByKey($key);
return new ApiKeyCheckResult($apiKey);
}
@ -41,7 +45,6 @@ class ApiKeyService implements ApiKeyServiceInterface
*/
public function disable(string $key): ApiKey
{
/** @var ApiKey|null $apiKey */
$apiKey = $this->getByKey($key);
if ($apiKey === null) {
throw new InvalidArgumentException(sprintf('API key "%s" does not exist and can\'t be disabled', $key));

View file

@ -6,11 +6,12 @@ namespace Shlinkio\Shlink\Rest\Service;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
interface ApiKeyServiceInterface
{
public function create(?Chronos $expirationDate = null): ApiKey;
public function create(?Chronos $expirationDate = null, RoleDefinition ...$roleDefinitions): ApiKey;
public function check(string $key): ApiKeyCheckResult;

View file

@ -33,7 +33,7 @@ class ApiKeyFixture extends AbstractFixture implements DependentFixtureInterface
/** @var Domain $exampleDomain */
$exampleDomain = $this->getReference('example_domain');
$domainApiKey = $this->buildApiKey('domain_api_key', true);
$domainApiKey->registerRole(RoleDefinition::forDomain($exampleDomain->getId()));
$domainApiKey->registerRole(RoleDefinition::forDomain($exampleDomain));
$manager->persist($domainApiKey);
$manager->flush();

View file

@ -56,17 +56,49 @@ class RoleTest extends TestCase
/**
* @test
* @dataProvider provideMetas
* @dataProvider provideMetasWithDomainId
*/
public function getsExpectedDomainIdFromMeta(array $meta, string $expectedDomainId): void
{
self::assertEquals($expectedDomainId, Role::domainIdFromMeta($meta));
}
public function provideMetas(): iterable
public function provideMetasWithDomainId(): iterable
{
yield 'empty meta' => [[], '-1'];
yield 'meta without domain_id' => [['foo' => 'bar'], '-1'];
yield 'meta with domain_id' => [['domain_id' => '123'], '123'];
}
/**
* @test
* @dataProvider provideMetasWithAuthority
*/
public function getsExpectedAuthorityFromMeta(array $meta, string $expectedAuthority): void
{
self::assertEquals($expectedAuthority, Role::domainAuthorityFromMeta($meta));
}
public function provideMetasWithAuthority(): iterable
{
yield 'empty meta' => [[], ''];
yield 'meta without authority' => [['foo' => 'bar'], ''];
yield 'meta with authority' => [['authority' => 'example.com'], 'example.com'];
}
/**
* @test
* @dataProvider provideRoleNames
*/
public function getsExpectedRoleFriendlyName(string $roleName, string $expectedFriendlyName): void
{
self::assertEquals($expectedFriendlyName, Role::toFriendlyName($roleName));
}
public function provideRoleNames(): iterable
{
yield 'unknown' => ['unknown', ''];
yield Role::AUTHORED_SHORT_URLS => [Role::AUTHORED_SHORT_URLS, 'Author only'];
yield Role::DOMAIN_SPECIFIC => [Role::DOMAIN_SPECIFIC, 'Domain only'];
}
}

View file

@ -12,6 +12,8 @@ use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
@ -31,21 +33,29 @@ class ApiKeyServiceTest extends TestCase
/**
* @test
* @dataProvider provideCreationDate
* @param RoleDefinition[] $roles
*/
public function apiKeyIsProperlyCreated(?Chronos $date): void
public function apiKeyIsProperlyCreated(?Chronos $date, array $roles): void
{
$this->em->flush()->shouldBeCalledOnce();
$this->em->persist(Argument::type(ApiKey::class))->shouldBeCalledOnce();
$key = $this->service->create($date);
$key = $this->service->create($date, ...$roles);
self::assertEquals($date, $key->getExpirationDate());
foreach ($roles as $roleDefinition) {
self::assertTrue($key->hasRole($roleDefinition->roleName()));
}
}
public function provideCreationDate(): iterable
{
yield 'no expiration date' => [null];
yield 'expiration date' => [Chronos::parse('2030-01-01')];
yield 'no expiration date' => [null, []];
yield 'expiration date' => [Chronos::parse('2030-01-01'), []];
yield 'roles' => [null, [
RoleDefinition::forDomain((new Domain(''))->setId('123')),
RoleDefinition::forAuthoredShortUrls(),
]];
}
/**