mirror of
https://github.com/shlinkio/shlink.git
synced 2025-02-18 00:09:54 +03:00
Merge pull request #960 from acelaya-forks/feature/api-roles-cli
Feature/api roles cli
This commit is contained in:
commit
da9e9df4ba
27 changed files with 467 additions and 85 deletions
|
@ -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",
|
||||
|
|
|
@ -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],
|
||||
|
||||
|
|
36
module/CLI/src/ApiKey/RoleResolver.php
Normal file
36
module/CLI/src/ApiKey/RoleResolver.php
Normal 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;
|
||||
}
|
||||
}
|
19
module/CLI/src/ApiKey/RoleResolverInterface.php
Normal file
19
module/CLI/src/ApiKey/RoleResolverInterface.php
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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() ? '---' : '+++';
|
||||
|
|
82
module/CLI/test/ApiKey/RoleResolverTest.php
Normal file
82
module/CLI/test/ApiKey/RoleResolverTest.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}, '');
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
), '');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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'];
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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('')];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()]];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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] ?? '';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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'];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
]];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
Loading…
Add table
Reference in a new issue