New CLI command to create the initial API key idempotently

This commit is contained in:
Alejandro Celaya
2023-09-21 09:29:59 +02:00
parent 6db46b50e9
commit 637d8334f4
14 changed files with 84 additions and 129 deletions

View File

@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest;
use Mezzio\Application;
use Shlinkio\Shlink\Core\Config\EnvVars;
use const PHP_SAPI;
return [
// We will try to load the initial API key only for openswoole and RoadRunner.
// For php-fpm, the check against the database would happen on every request, resulting in a very bad performance.
'initial_api_key' => PHP_SAPI !== 'cli' ? null : EnvVars::INITIAL_API_KEY->loadFromEnv(),
'dependencies' => [
'delegators' => [
Application::class => [
ApiKey\InitialApiKeyDelegator::class,
],
],
],
];

View File

@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\ApiKey;
use Doctrine\ORM\EntityManager;
use Mezzio\Application;
use Psr\Container\ContainerInterface;
use Shlinkio\Shlink\Rest\ApiKey\Repository\ApiKeyRepositoryInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class InitialApiKeyDelegator
{
public function __invoke(ContainerInterface $container, string $serviceName, callable $callback): Application
{
$initialApiKey = $container->get('config')['initial_api_key'] ?? null;
if (! empty($initialApiKey)) {
$this->createInitialApiKey($initialApiKey, $container);
}
return $callback();
}
private function createInitialApiKey(string $initialApiKey, ContainerInterface $container): void
{
/** @var ApiKeyRepositoryInterface $repo */
$repo = $container->get(EntityManager::class)->getRepository(ApiKey::class);
$repo->createInitialApiKey($initialApiKey);
}
}

View File

@@ -11,10 +11,13 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
class ApiKeyRepository extends EntitySpecificationRepository implements ApiKeyRepositoryInterface
{
public function createInitialApiKey(string $apiKey): void
/**
* Will create provided API key with admin permissions, only if there's no other API keys yet
*/
public function createInitialApiKey(string $apiKey): ?ApiKey
{
$em = $this->getEntityManager();
$em->wrapInTransaction(function () use ($apiKey, $em): void {
return $em->wrapInTransaction(function () use ($apiKey, $em): ?ApiKey {
// Ideally this would be a SELECT COUNT(...), but MsSQL and Postgres do not allow locking on aggregates
// Because of that we check if at least one result exists
$firstResult = $em->createQueryBuilder()->select('a.id')
@@ -24,10 +27,16 @@ class ApiKeyRepository extends EntitySpecificationRepository implements ApiKeyRe
->setLockMode(LockMode::PESSIMISTIC_WRITE)
->getOneOrNullResult();
if ($firstResult === null) {
$em->persist(ApiKey::fromMeta(ApiKeyMeta::fromParams(key: $apiKey)));
$em->flush();
// Do not create an initial API key if other keys already exist
if ($firstResult !== null) {
return null;
}
$new = ApiKey::fromMeta(ApiKeyMeta::fromParams(key: $apiKey));
$em->persist($new);
$em->flush();
return $new;
});
}
}

View File

@@ -6,11 +6,12 @@ namespace Shlinkio\Shlink\Rest\ApiKey\Repository;
use Doctrine\Persistence\ObjectRepository;
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepositoryInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
interface ApiKeyRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
{
/**
* Will create provided API key only if there's no API keys yet
*/
public function createInitialApiKey(string $apiKey): void;
public function createInitialApiKey(string $apiKey): ?ApiKey;
}

View File

@@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Rest\Service;
use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\ApiKey\Repository\ApiKeyRepositoryInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use function sprintf;
@@ -27,6 +28,13 @@ class ApiKeyService implements ApiKeyServiceInterface
return $apiKey;
}
public function createInitial(string $key): ?ApiKey
{
/** @var ApiKeyRepositoryInterface $repo */
$repo = $this->em->getRepository(ApiKey::class);
return $repo->createInitialApiKey($key);
}
public function check(string $key): ApiKeyCheckResult
{
$apiKey = $this->getByKey($key);

View File

@@ -12,6 +12,8 @@ interface ApiKeyServiceInterface
{
public function create(ApiKeyMeta $apiKeyMeta): ApiKey;
public function createInitial(string $key): ?ApiKey;
public function check(string $key): ApiKeyCheckResult;
/**

View File

@@ -22,10 +22,10 @@ class ApiKeyRepositoryTest extends DatabaseTestCase
public function initialApiKeyIsCreatedOnlyOfNoApiKeysExistYet(): void
{
self::assertCount(0, $this->repo->findAll());
$this->repo->createInitialApiKey('initial_value');
self::assertNotNull($this->repo->createInitialApiKey('initial_value'));
self::assertCount(1, $this->repo->findAll());
self::assertCount(1, $this->repo->findBy(['key' => 'initial_value']));
$this->repo->createInitialApiKey('another_one');
self::assertNull($this->repo->createInitialApiKey('another_one'));
self::assertCount(1, $this->repo->findAll());
self::assertCount(0, $this->repo->findBy(['key' => 'another_one']));
}

View File

@@ -1,57 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest\ApiKey;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\EntityManagerInterface;
use Mezzio\Application;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface;
use Shlinkio\Shlink\Rest\ApiKey\InitialApiKeyDelegator;
use Shlinkio\Shlink\Rest\ApiKey\Repository\ApiKeyRepositoryInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class InitialApiKeyDelegatorTest extends TestCase
{
private InitialApiKeyDelegator $delegator;
private MockObject & ContainerInterface $container;
protected function setUp(): void
{
$this->delegator = new InitialApiKeyDelegator();
$this->container = $this->createMock(ContainerInterface::class);
}
#[Test, DataProvider('provideConfigs')]
public function apiKeyIsInitializedWhenAppropriate(array $config, int $expectedCalls): void
{
$app = $this->createMock(Application::class);
$apiKeyRepo = $this->createMock(ApiKeyRepositoryInterface::class);
$apiKeyRepo->expects($this->exactly($expectedCalls))->method('createInitialApiKey');
$em = $this->createMock(EntityManagerInterface::class);
$em->expects($this->exactly($expectedCalls))->method('getRepository')->with(ApiKey::class)->willReturn(
$apiKeyRepo,
);
$this->container->expects($this->exactly($expectedCalls + 1))->method('get')->willReturnMap([
['config', $config],
[EntityManager::class, $em],
]);
$result = ($this->delegator)($this->container, '', fn () => $app);
self::assertSame($result, $app);
}
public static function provideConfigs(): iterable
{
yield 'no api key' => [[], 0];
yield 'null api key' => [['initial_api_key' => null], 0];
yield 'empty api key' => [['initial_api_key' => ''], 0];
yield 'valid api key' => [['initial_api_key' => 'the_initial_key'], 1];
}
}

View File

@@ -24,11 +24,10 @@ class ConfigProviderTest extends TestCase
{
$config = ($this->configProvider)();
self::assertCount(5, $config);
self::assertCount(4, $config);
self::assertArrayHasKey('dependencies', $config);
self::assertArrayHasKey('auth', $config);
self::assertArrayHasKey('entity_manager', $config);
self::assertArrayHasKey('initial_api_key', $config);
self::assertArrayHasKey(ConfigAbstractFactory::class, $config);
}