From 90551ff3bc75033e10de93dbc0844239f5e700ef Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 8 Nov 2020 11:28:27 +0100 Subject: [PATCH 01/46] Added used API key to request --- data/migrations/Version20180913205455.php | 2 +- .../Action/ShortUrl/CreateShortUrlAction.php | 2 +- .../SingleStepCreateShortUrlAction.php | 6 ++--- module/Rest/src/Entity/ApiKey.php | 5 ++++ .../Middleware/AuthenticationMiddleware.php | 12 +++++---- module/Rest/src/Service/ApiKeyCheckResult.php | 27 +++++++++++++++++++ module/Rest/src/Service/ApiKeyService.php | 6 ++--- .../src/Service/ApiKeyServiceInterface.php | 4 +-- .../ShortUrl/CreateShortUrlActionTest.php | 25 +++++++---------- .../SingleStepCreateShortUrlActionTest.php | 15 +++++++---- .../AuthenticationMiddlewareTest.php | 13 +++++---- .../Rest/test/Service/ApiKeyServiceTest.php | 14 +++++++--- 12 files changed, 87 insertions(+), 44 deletions(-) create mode 100644 module/Rest/src/Service/ApiKeyCheckResult.php diff --git a/data/migrations/Version20180913205455.php b/data/migrations/Version20180913205455.php index 8afa316b..c2bc2070 100644 --- a/data/migrations/Version20180913205455.php +++ b/data/migrations/Version20180913205455.php @@ -58,7 +58,7 @@ final class Version20180913205455 extends AbstractMigration } try { - return (string) IpAddress::fromString($addr)->getObfuscatedCopy(); + return (string) IpAddress::fromString($addr)->getAnonymizedCopy(); } catch (InvalidArgumentException $e) { return null; } diff --git a/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php index 28941579..b3db6460 100644 --- a/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php @@ -28,7 +28,7 @@ class CreateShortUrlAction extends AbstractCreateShortUrlAction ]); } - $payload[ShortUrlMetaInputFilter::API_KEY] = AuthenticationMiddleware::apiKeyFromRequest($request); + $payload[ShortUrlMetaInputFilter::API_KEY] = AuthenticationMiddleware::apiKeyFromRequest($request)->toString(); $meta = ShortUrlMeta::fromRawData($payload); return new CreateShortUrlData($payload['longUrl'], (array) ($payload['tags'] ?? []), $meta); diff --git a/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php index fe8c44aa..996e59a6 100644 --- a/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php @@ -34,10 +34,10 @@ class SingleStepCreateShortUrlAction extends AbstractCreateShortUrlAction protected function buildShortUrlData(Request $request): CreateShortUrlData { $query = $request->getQueryParams(); - $apiKey = $query['apiKey'] ?? ''; $longUrl = $query['longUrl'] ?? null; - if (! $this->apiKeyService->check($apiKey)) { + $apiKeyResult = $this->apiKeyService->check($query['apiKey'] ?? ''); + if (! $apiKeyResult->isValid()) { throw ValidationException::fromArray([ 'apiKey' => 'No API key was provided or it is not valid', ]); @@ -50,7 +50,7 @@ class SingleStepCreateShortUrlAction extends AbstractCreateShortUrlAction } return new CreateShortUrlData($longUrl, [], ShortUrlMeta::fromRawData([ - ShortUrlMetaInputFilter::API_KEY => $apiKey, + ShortUrlMetaInputFilter::API_KEY => $apiKeyResult->apiKey()->toString(), ])); } } diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index 1d372c9c..a800d530 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -54,4 +54,9 @@ class ApiKey extends AbstractEntity { return $this->key; } + + public function toString(): string + { + return $this->key; + } } diff --git a/module/Rest/src/Middleware/AuthenticationMiddleware.php b/module/Rest/src/Middleware/AuthenticationMiddleware.php index add9f513..1eff50d2 100644 --- a/module/Rest/src/Middleware/AuthenticationMiddleware.php +++ b/module/Rest/src/Middleware/AuthenticationMiddleware.php @@ -11,6 +11,7 @@ use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; +use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Exception\MissingAuthenticationException; use Shlinkio\Shlink\Rest\Exception\VerifyAuthenticationException; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; @@ -43,20 +44,21 @@ class AuthenticationMiddleware implements MiddlewareInterface, StatusCodeInterfa return $handler->handle($request); } - $apiKey = self::apiKeyFromRequest($request); + $apiKey = $request->getHeaderLine(self::API_KEY_HEADER); if (empty($apiKey)) { throw MissingAuthenticationException::fromExpectedTypes([self::API_KEY_HEADER]); } - if (! $this->apiKeyService->check($apiKey)) { + $result = $this->apiKeyService->check($apiKey); + if (! $result->isValid()) { throw VerifyAuthenticationException::forInvalidApiKey(); } - return $handler->handle($request); + return $handler->handle($request->withAttribute(ApiKey::class, $result->apiKey())); } - public static function apiKeyFromRequest(Request $request): string + public static function apiKeyFromRequest(Request $request): ApiKey { - return $request->getHeaderLine(self::API_KEY_HEADER); + return $request->getAttribute(ApiKey::class); } } diff --git a/module/Rest/src/Service/ApiKeyCheckResult.php b/module/Rest/src/Service/ApiKeyCheckResult.php new file mode 100644 index 00000000..8ec3f65e --- /dev/null +++ b/module/Rest/src/Service/ApiKeyCheckResult.php @@ -0,0 +1,27 @@ +apiKey = $apiKey; + } + + public function isValid(): bool + { + return $this->apiKey !== null && $this->apiKey->isValid(); + } + + public function apiKey(): ?ApiKey + { + return $this->apiKey; + } +} diff --git a/module/Rest/src/Service/ApiKeyService.php b/module/Rest/src/Service/ApiKeyService.php index baa545c0..6fb61be9 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -29,11 +29,11 @@ class ApiKeyService implements ApiKeyServiceInterface return $key; } - public function check(string $key): bool + public function check(string $key): ApiKeyCheckResult { /** @var ApiKey|null $apiKey */ $apiKey = $this->getByKey($key); - return $apiKey !== null && $apiKey->isValid(); + return new ApiKeyCheckResult($apiKey); } /** @@ -63,7 +63,7 @@ class ApiKeyService implements ApiKeyServiceInterface return $apiKeys; } - public function getByKey(string $key): ?ApiKey + private function getByKey(string $key): ?ApiKey { /** @var ApiKey|null $apiKey */ $apiKey = $this->em->getRepository(ApiKey::class)->findOneBy([ diff --git a/module/Rest/src/Service/ApiKeyServiceInterface.php b/module/Rest/src/Service/ApiKeyServiceInterface.php index a08d8c60..e8c6d0ea 100644 --- a/module/Rest/src/Service/ApiKeyServiceInterface.php +++ b/module/Rest/src/Service/ApiKeyServiceInterface.php @@ -12,7 +12,7 @@ interface ApiKeyServiceInterface { public function create(?Chronos $expirationDate = null): ApiKey; - public function check(string $key): bool; + public function check(string $key): ApiKeyCheckResult; /** * @throws InvalidArgumentException @@ -23,6 +23,4 @@ interface ApiKeyServiceInterface * @return ApiKey[] */ public function listKeys(bool $enabledOnly = false): array; - - public function getByKey(string $key): ?ApiKey; } diff --git a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php index 91e6014c..082b1783 100644 --- a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php @@ -16,6 +16,7 @@ use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\Rest\Action\ShortUrl\CreateShortUrlAction; +use Shlinkio\Shlink\Rest\Entity\ApiKey; use function strpos; @@ -48,19 +49,19 @@ class CreateShortUrlActionTest extends TestCase * @test * @dataProvider provideRequestBodies */ - public function properShortcodeConversionReturnsData(array $body, ShortUrlMeta $expectedMeta, ?string $apiKey): void + public function properShortcodeConversionReturnsData(array $body, array $expectedMeta): void { + $apiKey = new ApiKey(); $shortUrl = new ShortUrl(''); + $expectedMeta['apiKey'] = $apiKey->toString(); + $shorten = $this->urlShortener->shorten( Argument::type('string'), Argument::type('array'), - $expectedMeta, + ShortUrlMeta::fromRawData($expectedMeta), )->willReturn($shortUrl); - $request = ServerRequestFactory::fromGlobals()->withParsedBody($body); - if ($apiKey !== null) { - $request = $request->withHeader('X-Api-Key', $apiKey); - } + $request = ServerRequestFactory::fromGlobals()->withParsedBody($body)->withAttribute(ApiKey::class, $apiKey); $response = $this->action->handle($request); @@ -81,14 +82,8 @@ class CreateShortUrlActionTest extends TestCase 'domain' => 'my-domain.com', ]; - yield 'no data' => [['longUrl' => 'http://www.domain.com/foo/bar'], ShortUrlMeta::createEmpty(), null]; - yield 'all data' => [$fullMeta, ShortUrlMeta::fromRawData($fullMeta), null]; - yield 'all data and API key' => (static function (array $meta): array { - $apiKey = 'abc123'; - $meta['apiKey'] = $apiKey; - - return [$meta, ShortUrlMeta::fromRawData($meta), $apiKey]; - })($fullMeta); + yield 'no data' => [['longUrl' => 'http://www.domain.com/foo/bar'], []]; + yield 'all data' => [$fullMeta, $fullMeta]; } /** @@ -103,7 +98,7 @@ class CreateShortUrlActionTest extends TestCase $request = (new ServerRequest())->withParsedBody([ 'longUrl' => 'http://www.domain.com/foo/bar', 'domain' => $domain, - ]); + ])->withAttribute(ApiKey::class, new ApiKey()); $this->expectException(ValidationException::class); $urlToShortCode->shouldNotBeCalled(); diff --git a/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php index 62005c8d..eb1d6cd2 100644 --- a/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php @@ -15,6 +15,8 @@ use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Shlinkio\Shlink\Rest\Action\ShortUrl\SingleStepCreateShortUrlAction; +use Shlinkio\Shlink\Rest\Entity\ApiKey; +use Shlinkio\Shlink\Rest\Service\ApiKeyCheckResult; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; class SingleStepCreateShortUrlActionTest extends TestCase @@ -44,7 +46,7 @@ class SingleStepCreateShortUrlActionTest extends TestCase public function errorResponseIsReturnedIfInvalidApiKeyIsProvided(): void { $request = (new ServerRequest())->withQueryParams(['apiKey' => 'abc123']); - $findApiKey = $this->apiKeyService->check('abc123')->willReturn(false); + $findApiKey = $this->apiKeyService->check('abc123')->willReturn(new ApiKeyCheckResult()); $this->expectException(ValidationException::class); $findApiKey->shouldBeCalledOnce(); @@ -56,7 +58,7 @@ class SingleStepCreateShortUrlActionTest extends TestCase public function errorResponseIsReturnedIfNoUrlIsProvided(): void { $request = (new ServerRequest())->withQueryParams(['apiKey' => 'abc123']); - $findApiKey = $this->apiKeyService->check('abc123')->willReturn(true); + $findApiKey = $this->apiKeyService->check('abc123')->willReturn(new ApiKeyCheckResult(new ApiKey())); $this->expectException(ValidationException::class); $findApiKey->shouldBeCalledOnce(); @@ -67,18 +69,21 @@ class SingleStepCreateShortUrlActionTest extends TestCase /** @test */ public function properDataIsPassedWhenGeneratingShortCode(): void { + $apiKey = new ApiKey(); + $key = $apiKey->toString(); + $request = (new ServerRequest())->withQueryParams([ - 'apiKey' => 'abc123', + 'apiKey' => $key, 'longUrl' => 'http://foobar.com', ]); - $findApiKey = $this->apiKeyService->check('abc123')->willReturn(true); + $findApiKey = $this->apiKeyService->check($key)->willReturn(new ApiKeyCheckResult($apiKey)); $generateShortCode = $this->urlShortener->shorten( Argument::that(function (string $argument): string { Assert::assertEquals('http://foobar.com', $argument); return $argument; }), [], - ShortUrlMeta::fromRawData(['apiKey' => 'abc123']), + ShortUrlMeta::fromRawData(['apiKey' => $key]), )->willReturn(new ShortUrl('')); $resp = $this->action->handle($request); diff --git a/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php b/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php index db721780..39559f67 100644 --- a/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php +++ b/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php @@ -18,9 +18,11 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; use Shlinkio\Shlink\Rest\Action\HealthAction; +use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Exception\MissingAuthenticationException; use Shlinkio\Shlink\Rest\Exception\VerifyAuthenticationException; use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; +use Shlinkio\Shlink\Rest\Service\ApiKeyCheckResult; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; use function Laminas\Stratigility\middleware; @@ -114,7 +116,7 @@ class AuthenticationMiddlewareTest extends TestCase ) ->withHeader('X-Api-Key', $apiKey); - $this->apiKeyService->check($apiKey)->willReturn(false)->shouldBeCalledOnce(); + $this->apiKeyService->check($apiKey)->willReturn(new ApiKeyCheckResult())->shouldBeCalledOnce(); $this->handler->handle($request)->shouldNotBeCalled(); $this->expectException(VerifyAuthenticationException::class); $this->expectExceptionMessage('Provided API key does not exist or is invalid'); @@ -125,16 +127,17 @@ class AuthenticationMiddlewareTest extends TestCase /** @test */ public function validApiKeyFallsBackToNextMiddleware(): void { - $apiKey = 'abc123'; + $apiKey = new ApiKey(); + $key = $apiKey->toString(); $request = ServerRequestFactory::fromGlobals() ->withAttribute( RouteResult::class, RouteResult::fromRoute(new Route('bar', $this->getDummyMiddleware()), []), ) - ->withHeader('X-Api-Key', $apiKey); + ->withHeader('X-Api-Key', $key); - $handle = $this->handler->handle($request)->willReturn(new Response()); - $checkApiKey = $this->apiKeyService->check($apiKey)->willReturn(true); + $handle = $this->handler->handle($request->withAttribute(ApiKey::class, $apiKey))->willReturn(new Response()); + $checkApiKey = $this->apiKeyService->check($key)->willReturn(new ApiKeyCheckResult($apiKey)); $this->middleware->process($request, $this->handler->reveal()); diff --git a/module/Rest/test/Service/ApiKeyServiceTest.php b/module/Rest/test/Service/ApiKeyServiceTest.php index 656541f0..6d228661 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -59,7 +59,10 @@ class ApiKeyServiceTest extends TestCase ->shouldBeCalledOnce(); $this->em->getRepository(ApiKey::class)->willReturn($repo->reveal()); - self::assertFalse($this->service->check('12345')); + $result = $this->service->check('12345'); + + self::assertFalse($result->isValid()); + self::assertSame($invalidKey, $result->apiKey()); } public function provideInvalidApiKeys(): iterable @@ -72,12 +75,17 @@ class ApiKeyServiceTest extends TestCase /** @test */ public function checkReturnsTrueWhenConditionsAreFavorable(): void { + $apiKey = new ApiKey(); + $repo = $this->prophesize(EntityRepository::class); - $repo->findOneBy(['key' => '12345'])->willReturn(new ApiKey()) + $repo->findOneBy(['key' => '12345'])->willReturn($apiKey) ->shouldBeCalledOnce(); $this->em->getRepository(ApiKey::class)->willReturn($repo->reveal()); - self::assertTrue($this->service->check('12345')); + $result = $this->service->check('12345'); + + self::assertTrue($result->isValid()); + self::assertSame($apiKey, $result->apiKey()); } /** @test */ From ecf22ae4b6dc3e9606561d646840b51f8064d1f5 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 2 Jan 2021 17:14:42 +0100 Subject: [PATCH 02/46] Added happyr/doctrine-specification to support dunamically applying specs to queries --- composer.json | 9 ++----- module/Core/src/Repository/TagRepository.php | 24 +++++++++---------- .../src/Repository/TagRepositoryInterface.php | 6 +++-- module/Core/src/Tag/TagService.php | 5 ++-- module/Core/src/Tag/TagServiceInterface.php | 3 ++- .../Core/test/Service/Tag/TagServiceTest.php | 2 +- module/Rest/src/Action/Tag/ListTagsAction.php | 4 +++- module/Rest/src/Entity/ApiKey.php | 9 +++++++ .../test/Action/Tag/ListTagsActionTest.php | 8 +++++-- phpunit-db.xml | 3 +++ phpunit.xml.dist | 3 +++ 11 files changed, 48 insertions(+), 28 deletions(-) diff --git a/composer.json b/composer.json index b2828ef9..e0333d85 100644 --- a/composer.json +++ b/composer.json @@ -25,6 +25,7 @@ "endroid/qr-code": "^3.6", "geoip2/geoip2": "^2.9", "guzzlehttp/guzzle": "^7.0", + "happyr/doctrine-specification": "2.0.x-dev as 2.0", "laminas/laminas-config": "^3.3", "laminas/laminas-config-aggregator": "^1.1", "laminas/laminas-diactoros": "^2.1.3", @@ -125,13 +126,7 @@ ], "test:unit": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox", "test:unit:ci": "@test:unit --coverage-xml=build/coverage-unit/coverage-xml --log-junit=build/coverage-unit/junit.xml", - "test:db": [ - "@test:db:sqlite:ci", - "@test:db:mysql", - "@test:db:maria", - "@test:db:postgres", - "@test:db:ms" - ], + "test:db": "@parallel test:db:sqlite:ci test:db:mysql test:db:maria test:db:postgres test:db:ms", "test:db:sqlite": "APP_ENV=test php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-db.xml", "test:db:sqlite:ci": "@test:db:sqlite --coverage-php build/coverage-db.cov --coverage-xml=build/coverage-db/coverage-xml --log-junit=build/coverage-db/junit.xml", "test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite", diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php index 05b2481c..3b3c5beb 100644 --- a/module/Core/src/Repository/TagRepository.php +++ b/module/Core/src/Repository/TagRepository.php @@ -4,13 +4,14 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Repository; -use Doctrine\ORM\EntityRepository; +use Happyr\DoctrineSpecification\EntitySpecificationRepository; +use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use function Functional\map; -class TagRepository extends EntityRepository implements TagRepositoryInterface +class TagRepository extends EntitySpecificationRepository implements TagRepositoryInterface { public function deleteByName(array $names): int { @@ -28,17 +29,16 @@ class TagRepository extends EntityRepository implements TagRepositoryInterface /** * @return TagInfo[] */ - public function findTagsWithInfo(): array + public function findTagsWithInfo(?Specification $spec = null): array { - $dql = <<getEntityManager()->createQuery($dql); + $qb = $this->getQueryBuilder($spec, 't'); + $qb->select('t AS tag', 'COUNT(DISTINCT s.id) AS shortUrlsCount', 'COUNT(DISTINCT v.id) AS visitsCount') + ->leftJoin('t.shortUrls', 's') + ->leftJoin('s.visits', 'v') + ->groupBy('t') + ->orderBy('t.name', 'ASC'); + + $query = $qb->getQuery(); return map( $query->getResult(), diff --git a/module/Core/src/Repository/TagRepositoryInterface.php b/module/Core/src/Repository/TagRepositoryInterface.php index 37179e21..a486ef55 100644 --- a/module/Core/src/Repository/TagRepositoryInterface.php +++ b/module/Core/src/Repository/TagRepositoryInterface.php @@ -5,14 +5,16 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Repository; use Doctrine\Persistence\ObjectRepository; +use Happyr\DoctrineSpecification\EntitySpecificationRepositoryInterface; +use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; -interface TagRepositoryInterface extends ObjectRepository +interface TagRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface { public function deleteByName(array $names): int; /** * @return TagInfo[] */ - public function findTagsWithInfo(): array; + public function findTagsWithInfo(?Specification $spec = null): array; } diff --git a/module/Core/src/Tag/TagService.php b/module/Core/src/Tag/TagService.php index 4e0261a5..786e102d 100644 --- a/module/Core/src/Tag/TagService.php +++ b/module/Core/src/Tag/TagService.php @@ -13,6 +13,7 @@ use Shlinkio\Shlink\Core\Repository\TagRepository; use Shlinkio\Shlink\Core\Repository\TagRepositoryInterface; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Util\TagManagerTrait; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class TagService implements TagServiceInterface { @@ -38,11 +39,11 @@ class TagService implements TagServiceInterface /** * @return TagInfo[] */ - public function tagsInfo(): array + public function tagsInfo(?ApiKey $apiKey = null): array { /** @var TagRepositoryInterface $repo */ $repo = $this->em->getRepository(Tag::class); - return $repo->findTagsWithInfo(); + return $repo->findTagsWithInfo($apiKey !== null ? $apiKey->spec() : null); } /** diff --git a/module/Core/src/Tag/TagServiceInterface.php b/module/Core/src/Tag/TagServiceInterface.php index 3c8c6e69..bd96a225 100644 --- a/module/Core/src/Tag/TagServiceInterface.php +++ b/module/Core/src/Tag/TagServiceInterface.php @@ -9,6 +9,7 @@ use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; +use Shlinkio\Shlink\Rest\Entity\ApiKey; interface TagServiceInterface { @@ -20,7 +21,7 @@ interface TagServiceInterface /** * @return TagInfo[] */ - public function tagsInfo(): array; + public function tagsInfo(?ApiKey $apiKey = null): array; /** * @param string[] $tagNames diff --git a/module/Core/test/Service/Tag/TagServiceTest.php b/module/Core/test/Service/Tag/TagServiceTest.php index 16fd8683..c4203c85 100644 --- a/module/Core/test/Service/Tag/TagServiceTest.php +++ b/module/Core/test/Service/Tag/TagServiceTest.php @@ -51,7 +51,7 @@ class TagServiceTest extends TestCase { $expected = [new TagInfo(new Tag('foo'), 1, 1), new TagInfo(new Tag('bar'), 3, 10)]; - $find = $this->repo->findTagsWithInfo()->willReturn($expected); + $find = $this->repo->findTagsWithInfo(null)->willReturn($expected); $result = $this->service->tagsInfo(); diff --git a/module/Rest/src/Action/Tag/ListTagsAction.php b/module/Rest/src/Action/Tag/ListTagsAction.php index 0832f17c..64ddad33 100644 --- a/module/Rest/src/Action/Tag/ListTagsAction.php +++ b/module/Rest/src/Action/Tag/ListTagsAction.php @@ -10,6 +10,7 @@ use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; +use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; use function Functional\map; @@ -38,7 +39,8 @@ class ListTagsAction extends AbstractRestAction ]); } - $tagsInfo = $this->tagService->tagsInfo(); + $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); + $tagsInfo = $this->tagService->tagsInfo($apiKey); $data = map($tagsInfo, fn (TagInfo $info) => (string) $info->tag()); return new JsonResponse([ diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index a800d530..210c5b3a 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Entity; use Cake\Chronos\Chronos; +use Happyr\DoctrineSpecification\Spec; +use Happyr\DoctrineSpecification\Specification\Specification; use Ramsey\Uuid\Uuid; use Shlinkio\Shlink\Common\Entity\AbstractEntity; @@ -59,4 +61,11 @@ class ApiKey extends AbstractEntity { return $this->key; } + + /** + */ + public function spec(): Specification + { + return Spec::andX(); + } } diff --git a/module/Rest/test/Action/Tag/ListTagsActionTest.php b/module/Rest/test/Action/Tag/ListTagsActionTest.php index 2f675536..c8b65a48 100644 --- a/module/Rest/test/Action/Tag/ListTagsActionTest.php +++ b/module/Rest/test/Action/Tag/ListTagsActionTest.php @@ -13,6 +13,7 @@ use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\Tag\ListTagsAction; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class ListTagsActionTest extends TestCase { @@ -62,10 +63,13 @@ class ListTagsActionTest extends TestCase new TagInfo(new Tag('foo'), 1, 1), new TagInfo(new Tag('bar'), 3, 10), ]; - $tagsInfo = $this->tagService->tagsInfo()->willReturn($stats); + $apiKey = new ApiKey(); + $tagsInfo = $this->tagService->tagsInfo($apiKey)->willReturn($stats); + $req = ServerRequestFactory::fromGlobals()->withQueryParams(['withStats' => 'true']) + ->withAttribute(ApiKey::class, $apiKey); /** @var JsonResponse $resp */ - $resp = $this->action->handle(ServerRequestFactory::fromGlobals()->withQueryParams(['withStats' => 'true'])); + $resp = $this->action->handle($req); $payload = $resp->getPayload(); self::assertEquals([ diff --git a/phpunit-db.xml b/phpunit-db.xml index a995448f..030f777b 100644 --- a/phpunit-db.xml +++ b/phpunit-db.xml @@ -16,6 +16,9 @@ ./module/*/src/Repository ./module/*/src/**/Repository ./module/*/src/**/**/Repository + ./module/*/src/Spec + ./module/*/src/**/Spec + ./module/*/src/**/**/Spec diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 68f5263a..9c8e02df 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -25,6 +25,9 @@ ./module/Core/src/Repository ./module/Core/src/**/Repository ./module/Core/src/**/**/Repository + ./module/Core/src/Spec + ./module/Core/src/**/Spec + ./module/Core/src/**/**/Spec From 7e6882960e69aaf3c1bf8ded90c907226d0334ff Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 2 Jan 2021 19:35:16 +0100 Subject: [PATCH 03/46] Added a system to set roles to API keys --- composer.json | 1 - data/migrations/Version20210102174433.php | 52 +++++++++++++++++++ .../Shlinkio.Shlink.Rest.Entity.ApiKey.php | 5 ++ ...Shlinkio.Shlink.Rest.Entity.ApiKeyRole.php | 42 +++++++++++++++ module/Rest/src/Entity/ApiKey.php | 7 ++- module/Rest/src/Entity/ApiKeyRole.php | 31 +++++++++++ 6 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 data/migrations/Version20210102174433.php create mode 100644 module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKeyRole.php create mode 100644 module/Rest/src/Entity/ApiKeyRole.php diff --git a/composer.json b/composer.json index e0333d85..09b26c42 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,6 @@ "cakephp/chronos": "^2.0", "cocur/slugify": "^4.0", "doctrine/cache": "^1.9", - "doctrine/dbal": "^2.10", "doctrine/migrations": "^3.0.2", "doctrine/orm": "^2.8", "endroid/qr-code": "^3.6", diff --git a/data/migrations/Version20210102174433.php b/data/migrations/Version20210102174433.php new file mode 100644 index 00000000..95ee62fe --- /dev/null +++ b/data/migrations/Version20210102174433.php @@ -0,0 +1,52 @@ +skipIf($schema->hasTable(self::TABLE_NAME)); + + $table = $schema->createTable(self::TABLE_NAME); + $table->addColumn('id', Types::BIGINT, [ + 'unsigned' => true, + 'autoincrement' => true, + 'notnull' => true, + ]); + $table->setPrimaryKey(['id']); + + $table->addColumn('role_name', Types::STRING, [ + 'length' => 256, + 'notnull' => true, + ]); + $table->addColumn('meta', Types::JSON, [ + 'notnull' => true, + ]); + + $table->addColumn('api_key_id', Types::BIGINT, [ + 'unsigned' => true, + 'notnull' => true, + ]); + $table->addForeignKeyConstraint('api_keys', ['api_key_id'], ['id'], [ + 'onDelete' => 'CASCADE', + 'onUpdate' => 'RESTRICT', + ]); + $table->addUniqueIndex(['role_name', 'api_key_id'], 'UQ_role_plus_api_key'); + } + + public function down(Schema $schema): void + { + $this->skipIf(! $schema->hasTable(self::TABLE_NAME)); + $schema->getTable(self::TABLE_NAME)->dropIndex('UQ_role_plus_api_key'); + $schema->dropTable(self::TABLE_NAME); + } +} diff --git a/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKey.php b/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKey.php index a5084cee..2cb2df2b 100644 --- a/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKey.php +++ b/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKey.php @@ -8,6 +8,7 @@ use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder; use Doctrine\ORM\Mapping\ClassMetadata; use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType; +use Shlinkio\Shlink\Rest\Entity\ApiKeyRole; use function Shlinkio\Shlink\Core\determineTableName; @@ -34,4 +35,8 @@ return static function (ClassMetadata $metadata, array $emConfig): void { $builder->createField('enabled', Types::BOOLEAN) ->build(); + + $builder->createOneToMany('roles', ApiKeyRole::class) + ->mappedBy('apiKey') + ->build(); }; diff --git a/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKeyRole.php b/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKeyRole.php new file mode 100644 index 00000000..9c6355e3 --- /dev/null +++ b/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKeyRole.php @@ -0,0 +1,42 @@ +setTable(determineTableName('api_key_roles', $emConfig)); + + $builder->createField('id', Types::BIGINT) + ->makePrimaryKey() + ->generatedValue('IDENTITY') + ->option('unsigned', true) + ->build(); + + $builder->createField('roleName', Types::STRING) + ->columnName('role_name') + ->length(256) + ->nullable(false) + ->build(); + + $builder->createField('meta', Types::JSON) + ->columnName('meta') + ->nullable(false) + ->build(); + + $builder->createManyToOne('apiKey', ApiKey::class) + ->addJoinColumn('api_key_id', 'id', false, false, 'CASCADE') + ->cascadePersist() + ->build(); + + $builder->addUniqueConstraint(['role_name', 'api_key_id'], 'UQ_role_plus_api_key'); +}; diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index 210c5b3a..bf1baccf 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Entity; use Cake\Chronos\Chronos; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Happyr\DoctrineSpecification\Spec; use Happyr\DoctrineSpecification\Specification\Specification; use Ramsey\Uuid\Uuid; @@ -15,12 +17,15 @@ class ApiKey extends AbstractEntity private string $key; private ?Chronos $expirationDate = null; private bool $enabled; + /** @var Collection|ApiKeyRole[] */ + private Collection $roles; public function __construct(?Chronos $expirationDate = null) { $this->key = Uuid::uuid4()->toString(); $this->expirationDate = $expirationDate; $this->enabled = true; + $this->roles = new ArrayCollection(); } public function getExpirationDate(): ?Chronos @@ -62,8 +67,6 @@ class ApiKey extends AbstractEntity return $this->key; } - /** - */ public function spec(): Specification { return Spec::andX(); diff --git a/module/Rest/src/Entity/ApiKeyRole.php b/module/Rest/src/Entity/ApiKeyRole.php new file mode 100644 index 00000000..6af3d328 --- /dev/null +++ b/module/Rest/src/Entity/ApiKeyRole.php @@ -0,0 +1,31 @@ +roleName = $roleName; + $this->meta = $meta; + $this->apiKey = $apiKey; + } + + public function name(): string + { + return $this->roleName; + } + + public function meta(): array + { + return $this->meta; + } +} From df53e6c6f2ff1e95293172ba35d5e27aeca6292e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 2 Jan 2021 20:08:49 +0100 Subject: [PATCH 04/46] Created specs for API key roles --- .../src/ShortUrl/Spec/BelongsToApiKey.php | 28 +++++++++++++++++ .../src/ShortUrl/Spec/BelongsToDomain.php | 27 +++++++++++++++++ module/Rest/src/ApiKey/Role.php | 30 +++++++++++++++++++ module/Rest/src/Entity/ApiKey.php | 4 ++- module/Rest/src/Entity/ApiKeyRole.php | 5 ++++ 5 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 module/Core/src/ShortUrl/Spec/BelongsToApiKey.php create mode 100644 module/Core/src/ShortUrl/Spec/BelongsToDomain.php create mode 100644 module/Rest/src/ApiKey/Role.php diff --git a/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php b/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php new file mode 100644 index 00000000..a1059168 --- /dev/null +++ b/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php @@ -0,0 +1,28 @@ +dqlAlias = $dqlAlias ?? 's'; + $this->apiKey = $apiKey; + parent::__construct($this->dqlAlias); + } + + protected function getSpec(): Filter + { + return Spec::eq('authorApiKey', $this->apiKey, $this->dqlAlias); + } +} diff --git a/module/Core/src/ShortUrl/Spec/BelongsToDomain.php b/module/Core/src/ShortUrl/Spec/BelongsToDomain.php new file mode 100644 index 00000000..27f93665 --- /dev/null +++ b/module/Core/src/ShortUrl/Spec/BelongsToDomain.php @@ -0,0 +1,27 @@ +domainId = $domainId; + $this->dqlAlias = $dqlAlias ?? 's'; + parent::__construct($this->dqlAlias); + } + + protected function getSpec(): Filter + { + return Spec::eq('domain', $this->domainId, $this->dqlAlias); + } +} diff --git a/module/Rest/src/ApiKey/Role.php b/module/Rest/src/ApiKey/Role.php new file mode 100644 index 00000000..1140f1ff --- /dev/null +++ b/module/Rest/src/ApiKey/Role.php @@ -0,0 +1,30 @@ +name() === self::AUTHORED_SHORT_URLS) { + return new BelongsToApiKey($role->apiKey()); + } + + if ($role->name() === self::DOMAIN_SPECIFIC) { + return new BelongsToDomain($role->meta()['domain_id'] ?? -1); + } + + return Spec::andX(); + } +} diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index bf1baccf..6c122494 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -11,6 +11,7 @@ use Happyr\DoctrineSpecification\Spec; use Happyr\DoctrineSpecification\Specification\Specification; use Ramsey\Uuid\Uuid; use Shlinkio\Shlink\Common\Entity\AbstractEntity; +use Shlinkio\Shlink\Rest\ApiKey\Role; class ApiKey extends AbstractEntity { @@ -69,6 +70,7 @@ class ApiKey extends AbstractEntity public function spec(): Specification { - return Spec::andX(); + $specs = $this->roles->map(fn (ApiKeyRole $role) => Role::toSpec($role)); + return Spec::andX(...$specs); } } diff --git a/module/Rest/src/Entity/ApiKeyRole.php b/module/Rest/src/Entity/ApiKeyRole.php index 6af3d328..aefda970 100644 --- a/module/Rest/src/Entity/ApiKeyRole.php +++ b/module/Rest/src/Entity/ApiKeyRole.php @@ -28,4 +28,9 @@ class ApiKeyRole extends AbstractEntity { return $this->meta; } + + public function apiKey(): ApiKey + { + return $this->apiKey; + } } From 6e1d6ab795e17cd9e7e140c83a4baecb5295fe9a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 3 Jan 2021 12:00:25 +0100 Subject: [PATCH 05/46] Changed point in which specs are applied for tags list --- module/Core/src/Repository/TagRepository.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php index 3b3c5beb..71d291ef 100644 --- a/module/Core/src/Repository/TagRepository.php +++ b/module/Core/src/Repository/TagRepository.php @@ -31,13 +31,17 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito */ public function findTagsWithInfo(?Specification $spec = null): array { - $qb = $this->getQueryBuilder($spec, 't'); + $qb = $this->createQueryBuilder('t'); $qb->select('t AS tag', 'COUNT(DISTINCT s.id) AS shortUrlsCount', 'COUNT(DISTINCT v.id) AS visitsCount') ->leftJoin('t.shortUrls', 's') ->leftJoin('s.visits', 'v') ->groupBy('t') ->orderBy('t.name', 'ASC'); + if ($spec !== null) { + $this->applySpecification($qb, $spec, 't'); + } + $query = $qb->getQuery(); return map( From 940383646bbd759a9934b9fe64a4b846c2b6cae3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 3 Jan 2021 13:05:21 +0100 Subject: [PATCH 06/46] Applied API role specs to short URLs list --- .../Adapter/ShortUrlRepositoryAdapter.php | 7 ++++- .../src/Repository/ShortUrlRepository.php | 27 +++++++++++++------ .../ShortUrlRepositoryInterface.php | 14 +++++++--- module/Core/src/Service/ShortUrlService.php | 5 ++-- .../src/Service/ShortUrlServiceInterface.php | 3 ++- .../Adapter/ShortUrlRepositoryAdapterTest.php | 10 ++++--- .../Action/ShortUrl/ListShortUrlsAction.php | 6 ++++- .../ShortUrl/ListShortUrlsActionTest.php | 7 +++-- 8 files changed, 57 insertions(+), 22 deletions(-) diff --git a/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php b/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php index 59d48a82..8f339dc0 100644 --- a/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php +++ b/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php @@ -7,16 +7,19 @@ namespace Shlinkio\Shlink\Core\Paginator\Adapter; use Laminas\Paginator\Adapter\AdapterInterface; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class ShortUrlRepositoryAdapter implements AdapterInterface { private ShortUrlRepositoryInterface $repository; private ShortUrlsParams $params; + private ?ApiKey $apiKey; - public function __construct(ShortUrlRepositoryInterface $repository, ShortUrlsParams $params) + public function __construct(ShortUrlRepositoryInterface $repository, ShortUrlsParams $params, ?ApiKey $apiKey) { $this->repository = $repository; $this->params = $params; + $this->apiKey = $apiKey; } public function getItems($offset, $itemCountPerPage): array // phpcs:ignore @@ -28,6 +31,7 @@ class ShortUrlRepositoryAdapter implements AdapterInterface $this->params->tags(), $this->params->orderBy(), $this->params->dateRange(), + $this->apiKey !== null ? $this->apiKey->spec() : null, ); } @@ -37,6 +41,7 @@ class ShortUrlRepositoryAdapter implements AdapterInterface $this->params->searchTerm(), $this->params->tags(), $this->params->dateRange(), + $this->apiKey !== null ? $this->apiKey->spec() : null, ); } } diff --git a/module/Core/src/Repository/ShortUrlRepository.php b/module/Core/src/Repository/ShortUrlRepository.php index fc6ace41..363d3290 100644 --- a/module/Core/src/Repository/ShortUrlRepository.php +++ b/module/Core/src/Repository/ShortUrlRepository.php @@ -4,9 +4,10 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Repository; -use Doctrine\ORM\EntityRepository; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; +use Happyr\DoctrineSpecification\EntitySpecificationRepository; +use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\ShortUrl; @@ -19,7 +20,7 @@ use function array_key_exists; use function count; use function Functional\contains; -class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryInterface +class ShortUrlRepository extends EntitySpecificationRepository implements ShortUrlRepositoryInterface { /** * @param string[] $tags @@ -31,9 +32,10 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI ?string $searchTerm = null, array $tags = [], ?ShortUrlsOrdering $orderBy = null, - ?DateRange $dateRange = null + ?DateRange $dateRange = null, + ?Specification $spec = null ): array { - $qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange); + $qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange, $spec); $qb->select('DISTINCT s') ->setMaxResults($limit) ->setFirstResult($offset); @@ -75,9 +77,13 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI return $qb->getQuery()->getResult(); } - public function countList(?string $searchTerm = null, array $tags = [], ?DateRange $dateRange = null): int - { - $qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange); + public function countList( + ?string $searchTerm = null, + array $tags = [], + ?DateRange $dateRange = null, + ?Specification $spec = null + ): int { + $qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange, $spec); $qb->select('COUNT(DISTINCT s)'); return (int) $qb->getQuery()->getSingleScalarResult(); @@ -86,7 +92,8 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI private function createListQueryBuilder( ?string $searchTerm = null, array $tags = [], - ?DateRange $dateRange = null + ?DateRange $dateRange = null, + ?Specification $spec = null ): QueryBuilder { $qb = $this->getEntityManager()->createQueryBuilder(); $qb->from(ShortUrl::class, 's') @@ -125,6 +132,10 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI ->andWhere($qb->expr()->in('t.name', $tags)); } + if ($spec) { + $this->applySpecification($qb, $spec, 's'); + } + return $qb; } diff --git a/module/Core/src/Repository/ShortUrlRepositoryInterface.php b/module/Core/src/Repository/ShortUrlRepositoryInterface.php index 1d6f38a8..98bfe778 100644 --- a/module/Core/src/Repository/ShortUrlRepositoryInterface.php +++ b/module/Core/src/Repository/ShortUrlRepositoryInterface.php @@ -5,13 +5,15 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Repository; use Doctrine\Persistence\ObjectRepository; +use Happyr\DoctrineSpecification\EntitySpecificationRepositoryInterface; +use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; -interface ShortUrlRepositoryInterface extends ObjectRepository +interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface { public function findList( ?int $limit = null, @@ -19,10 +21,16 @@ interface ShortUrlRepositoryInterface extends ObjectRepository ?string $searchTerm = null, array $tags = [], ?ShortUrlsOrdering $orderBy = null, - ?DateRange $dateRange = null + ?DateRange $dateRange = null, + ?Specification $spec = null ): array; - public function countList(?string $searchTerm = null, array $tags = [], ?DateRange $dateRange = null): int; + public function countList( + ?string $searchTerm = null, + array $tags = [], + ?DateRange $dateRange = null, + ?Specification $spec = null + ): int; public function findOneWithDomainFallback(string $shortCode, ?string $domain = null): ?ShortUrl; diff --git a/module/Core/src/Service/ShortUrlService.php b/module/Core/src/Service/ShortUrlService.php index 9159ef63..410e0f16 100644 --- a/module/Core/src/Service/ShortUrlService.php +++ b/module/Core/src/Service/ShortUrlService.php @@ -17,6 +17,7 @@ use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\Util\TagManagerTrait; use Shlinkio\Shlink\Core\Util\UrlValidatorInterface; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class ShortUrlService implements ShortUrlServiceInterface { @@ -39,11 +40,11 @@ class ShortUrlService implements ShortUrlServiceInterface /** * @return ShortUrl[]|Paginator */ - public function listShortUrls(ShortUrlsParams $params): Paginator + public function listShortUrls(ShortUrlsParams $params, ?ApiKey $apiKey = null): Paginator { /** @var ShortUrlRepository $repo */ $repo = $this->em->getRepository(ShortUrl::class); - $paginator = new Paginator(new ShortUrlRepositoryAdapter($repo, $params)); + $paginator = new Paginator(new ShortUrlRepositoryAdapter($repo, $params, $apiKey)); $paginator->setItemCountPerPage($params->itemsPerPage()) ->setCurrentPageNumber($params->page()); diff --git a/module/Core/src/Service/ShortUrlServiceInterface.php b/module/Core/src/Service/ShortUrlServiceInterface.php index 3c09e7e9..b3582ac2 100644 --- a/module/Core/src/Service/ShortUrlServiceInterface.php +++ b/module/Core/src/Service/ShortUrlServiceInterface.php @@ -11,13 +11,14 @@ use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlEdit; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; +use Shlinkio\Shlink\Rest\Entity\ApiKey; interface ShortUrlServiceInterface { /** * @return ShortUrl[]|Paginator */ - public function listShortUrls(ShortUrlsParams $params): Paginator; + public function listShortUrls(ShortUrlsParams $params, ?ApiKey $apiKey = null): Paginator; /** * @param string[] $tags diff --git a/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php b/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php index 9f541ebe..c3848aa5 100644 --- a/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php +++ b/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php @@ -11,6 +11,7 @@ use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Paginator\Adapter\ShortUrlRepositoryAdapter; use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class ShortUrlRepositoryAdapterTest extends TestCase { @@ -41,11 +42,11 @@ class ShortUrlRepositoryAdapterTest extends TestCase 'endDate' => $endDate, 'orderBy' => $orderBy, ]); - $adapter = new ShortUrlRepositoryAdapter($this->repo->reveal(), $params); + $adapter = new ShortUrlRepositoryAdapter($this->repo->reveal(), $params, null); $orderBy = $params->orderBy(); $dateRange = $params->dateRange(); - $this->repo->findList(10, 5, $searchTerm, $tags, $orderBy, $dateRange)->shouldBeCalledOnce(); + $this->repo->findList(10, 5, $searchTerm, $tags, $orderBy, $dateRange, null)->shouldBeCalledOnce(); $adapter->getItems(5, 10); } @@ -65,10 +66,11 @@ class ShortUrlRepositoryAdapterTest extends TestCase 'startDate' => $startDate, 'endDate' => $endDate, ]); - $adapter = new ShortUrlRepositoryAdapter($this->repo->reveal(), $params); + $apiKey = new ApiKey(); + $adapter = new ShortUrlRepositoryAdapter($this->repo->reveal(), $params, $apiKey); $dateRange = $params->dateRange(); - $this->repo->countList($searchTerm, $tags, $dateRange)->shouldBeCalledOnce(); + $this->repo->countList($searchTerm, $tags, $dateRange, $apiKey->spec())->shouldBeCalledOnce(); $adapter->count(); } diff --git a/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php b/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php index 10a0effc..35273dcc 100644 --- a/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php +++ b/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php @@ -12,6 +12,7 @@ use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; +use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class ListShortUrlsAction extends AbstractRestAction { @@ -31,7 +32,10 @@ class ListShortUrlsAction extends AbstractRestAction public function handle(Request $request): Response { - $shortUrls = $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData($request->getQueryParams())); + $shortUrls = $this->shortUrlService->listShortUrls( + ShortUrlsParams::fromRawData($request->getQueryParams()), + AuthenticationMiddleware::apiKeyFromRequest($request), + ); return new JsonResponse(['shortUrls' => $this->serializePaginator($shortUrls, new ShortUrlDataTransformer( $this->domainConfig, ))]); diff --git a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php index 741eceb5..7c4d47f7 100644 --- a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php +++ b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php @@ -15,6 +15,7 @@ use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Service\ShortUrlService; use Shlinkio\Shlink\Rest\Action\ShortUrl\ListShortUrlsAction; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class ListShortUrlsActionTest extends TestCase { @@ -46,6 +47,8 @@ class ListShortUrlsActionTest extends TestCase ?string $startDate = null, ?string $endDate = null ): void { + $apiKey = new ApiKey(); + $request = (new ServerRequest())->withQueryParams($query)->withAttribute(ApiKey::class, $apiKey); $listShortUrls = $this->service->listShortUrls(ShortUrlsParams::fromRawData([ 'page' => $expectedPage, 'searchTerm' => $expectedSearchTerm, @@ -53,10 +56,10 @@ class ListShortUrlsActionTest extends TestCase 'orderBy' => $expectedOrderBy, 'startDate' => $startDate, 'endDate' => $endDate, - ]))->willReturn(new Paginator(new ArrayAdapter())); + ]), $apiKey)->willReturn(new Paginator(new ArrayAdapter())); /** @var JsonResponse $response */ - $response = $this->action->handle((new ServerRequest())->withQueryParams($query)); + $response = $this->action->handle($request); $payload = $response->getPayload(); self::assertArrayHasKey('shortUrls', $payload); From dc08286a72406ede01aebe28167eff53c7d6b283 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 3 Jan 2021 13:33:07 +0100 Subject: [PATCH 07/46] Applied API role specs to single short URL resolution --- .../src/Repository/ShortUrlRepository.php | 20 +++++++++++-------- .../ShortUrlRepositoryInterface.php | 2 +- .../src/Service/ShortUrl/ShortUrlResolver.php | 9 +++++++-- .../ShortUrl/ShortUrlResolverInterface.php | 3 ++- .../Service/ShortUrl/ShortUrlResolverTest.php | 4 ++-- .../Action/ShortUrl/ResolveShortUrlAction.php | 6 +++++- .../ShortUrl/ResolveShortUrlActionTest.php | 7 +++++-- 7 files changed, 34 insertions(+), 17 deletions(-) diff --git a/module/Core/src/Repository/ShortUrlRepository.php b/module/Core/src/Repository/ShortUrlRepository.php index 363d3290..b4a2ec6e 100644 --- a/module/Core/src/Repository/ShortUrlRepository.php +++ b/module/Core/src/Repository/ShortUrlRepository.php @@ -90,10 +90,10 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU } private function createListQueryBuilder( - ?string $searchTerm = null, - array $tags = [], - ?DateRange $dateRange = null, - ?Specification $spec = null + ?string $searchTerm, + array $tags, + ?DateRange $dateRange, + ?Specification $spec ): QueryBuilder { $qb = $this->getEntityManager()->createQueryBuilder(); $qb->from(ShortUrl::class, 's') @@ -171,9 +171,9 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU return $query->getOneOrNullResult(); } - public function findOne(string $shortCode, ?string $domain = null): ?ShortUrl + public function findOne(string $shortCode, ?string $domain = null, ?Specification $spec = null): ?ShortUrl { - $qb = $this->createFindOneQueryBuilder($shortCode, $domain); + $qb = $this->createFindOneQueryBuilder($shortCode, $domain, $spec); $qb->select('s'); return $qb->getQuery()->getOneOrNullResult(); @@ -181,13 +181,13 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU public function shortCodeIsInUse(string $slug, ?string $domain = null): bool { - $qb = $this->createFindOneQueryBuilder($slug, $domain); + $qb = $this->createFindOneQueryBuilder($slug, $domain, null); $qb->select('COUNT(DISTINCT s.id)'); return ((int) $qb->getQuery()->getSingleScalarResult()) > 0; } - private function createFindOneQueryBuilder(string $slug, ?string $domain = null): QueryBuilder + private function createFindOneQueryBuilder(string $slug, ?string $domain, ?Specification $spec): QueryBuilder { $qb = $this->getEntityManager()->createQueryBuilder(); $qb->from(ShortUrl::class, 's') @@ -198,6 +198,10 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU $this->whereDomainIs($qb, $domain); + if ($spec !== null) { + $this->applySpecification($qb, $spec, 's'); + } + return $qb; } diff --git a/module/Core/src/Repository/ShortUrlRepositoryInterface.php b/module/Core/src/Repository/ShortUrlRepositoryInterface.php index 98bfe778..fee546fe 100644 --- a/module/Core/src/Repository/ShortUrlRepositoryInterface.php +++ b/module/Core/src/Repository/ShortUrlRepositoryInterface.php @@ -34,7 +34,7 @@ interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificat public function findOneWithDomainFallback(string $shortCode, ?string $domain = null): ?ShortUrl; - public function findOne(string $shortCode, ?string $domain = null): ?ShortUrl; + public function findOne(string $shortCode, ?string $domain = null, ?Specification $spec = null): ?ShortUrl; public function shortCodeIsInUse(string $slug, ?string $domain): bool; diff --git a/module/Core/src/Service/ShortUrl/ShortUrlResolver.php b/module/Core/src/Service/ShortUrl/ShortUrlResolver.php index 414a3446..6e03114c 100644 --- a/module/Core/src/Service/ShortUrl/ShortUrlResolver.php +++ b/module/Core/src/Service/ShortUrl/ShortUrlResolver.php @@ -9,6 +9,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class ShortUrlResolver implements ShortUrlResolverInterface { @@ -22,11 +23,15 @@ class ShortUrlResolver implements ShortUrlResolverInterface /** * @throws ShortUrlNotFoundException */ - public function resolveShortUrl(ShortUrlIdentifier $identifier): ShortUrl + public function resolveShortUrl(ShortUrlIdentifier $identifier, ?ApiKey $apiKey = null): ShortUrl { /** @var ShortUrlRepository $shortUrlRepo */ $shortUrlRepo = $this->em->getRepository(ShortUrl::class); - $shortUrl = $shortUrlRepo->findOne($identifier->shortCode(), $identifier->domain()); + $shortUrl = $shortUrlRepo->findOne( + $identifier->shortCode(), + $identifier->domain(), + $apiKey !== null ? $apiKey->spec() : null, + ); if ($shortUrl === null) { throw ShortUrlNotFoundException::fromNotFound($identifier); } diff --git a/module/Core/src/Service/ShortUrl/ShortUrlResolverInterface.php b/module/Core/src/Service/ShortUrl/ShortUrlResolverInterface.php index a3a7c115..daa66e43 100644 --- a/module/Core/src/Service/ShortUrl/ShortUrlResolverInterface.php +++ b/module/Core/src/Service/ShortUrl/ShortUrlResolverInterface.php @@ -7,13 +7,14 @@ namespace Shlinkio\Shlink\Core\Service\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; +use Shlinkio\Shlink\Rest\Entity\ApiKey; interface ShortUrlResolverInterface { /** * @throws ShortUrlNotFoundException */ - public function resolveShortUrl(ShortUrlIdentifier $identifier): ShortUrl; + public function resolveShortUrl(ShortUrlIdentifier $identifier, ?ApiKey $apiKey = null): ShortUrl; /** * @throws ShortUrlNotFoundException diff --git a/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php b/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php index 3566b285..e9ff7a51 100644 --- a/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php +++ b/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php @@ -42,7 +42,7 @@ class ShortUrlResolverTest extends TestCase $shortCode = $shortUrl->getShortCode(); $repo = $this->prophesize(ShortUrlRepositoryInterface::class); - $findOne = $repo->findOne($shortCode, null)->willReturn($shortUrl); + $findOne = $repo->findOne($shortCode, null, null)->willReturn($shortUrl); $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); $result = $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode)); @@ -58,7 +58,7 @@ class ShortUrlResolverTest extends TestCase $shortCode = 'abc123'; $repo = $this->prophesize(ShortUrlRepositoryInterface::class); - $findOne = $repo->findOne($shortCode, null)->willReturn(null); + $findOne = $repo->findOne($shortCode, null, null)->willReturn(null); $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); $this->expectException(ShortUrlNotFoundException::class); diff --git a/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php b/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php index 9c2cb3e4..99e58fee 100644 --- a/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/ResolveShortUrlAction.php @@ -11,6 +11,7 @@ use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; +use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class ResolveShortUrlAction extends AbstractRestAction { @@ -29,7 +30,10 @@ class ResolveShortUrlAction extends AbstractRestAction public function handle(Request $request): Response { $transformer = new ShortUrlDataTransformer($this->domainConfig); - $url = $this->urlResolver->resolveShortUrl(ShortUrlIdentifier::fromApiRequest($request)); + $url = $this->urlResolver->resolveShortUrl( + ShortUrlIdentifier::fromApiRequest($request), + AuthenticationMiddleware::apiKeyFromRequest($request), + ); return new JsonResponse($transformer->transform($url)); } diff --git a/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php index d61f0f64..f4c49a60 100644 --- a/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php @@ -12,6 +12,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Rest\Action\ShortUrl\ResolveShortUrlAction; +use Shlinkio\Shlink\Rest\Entity\ApiKey; use function strpos; @@ -32,12 +33,14 @@ class ResolveShortUrlActionTest extends TestCase public function correctShortCodeReturnsSuccess(): void { $shortCode = 'abc123'; - $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode))->willReturn( + $apiKey = new ApiKey(); + $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode), $apiKey)->willReturn( new ShortUrl('http://domain.com/foo/bar'), )->shouldBeCalledOnce(); - $request = (new ServerRequest())->withAttribute('shortCode', $shortCode); + $request = (new ServerRequest())->withAttribute('shortCode', $shortCode)->withAttribute(ApiKey::class, $apiKey); $response = $this->action->handle($request); + self::assertEquals(200, $response->getStatusCode()); self::assertTrue(strpos($response->getBody()->getContents(), 'http://domain.com/foo/bar') > 0); } From 3e565d38301bb880538e15b1b0357ea1ebf7678e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 3 Jan 2021 13:52:08 +0100 Subject: [PATCH 08/46] Removed unnecesary if statements --- module/Core/src/Repository/ShortUrlRepository.php | 8 ++------ module/Core/src/Repository/TagRepository.php | 4 +--- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/module/Core/src/Repository/ShortUrlRepository.php b/module/Core/src/Repository/ShortUrlRepository.php index b4a2ec6e..4fa6fbd1 100644 --- a/module/Core/src/Repository/ShortUrlRepository.php +++ b/module/Core/src/Repository/ShortUrlRepository.php @@ -132,9 +132,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU ->andWhere($qb->expr()->in('t.name', $tags)); } - if ($spec) { - $this->applySpecification($qb, $spec, 's'); - } + $this->applySpecification($qb, $spec, 's'); return $qb; } @@ -198,9 +196,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU $this->whereDomainIs($qb, $domain); - if ($spec !== null) { - $this->applySpecification($qb, $spec, 's'); - } + $this->applySpecification($qb, $spec, 's'); return $qb; } diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php index 71d291ef..a2cb5bfd 100644 --- a/module/Core/src/Repository/TagRepository.php +++ b/module/Core/src/Repository/TagRepository.php @@ -38,9 +38,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito ->groupBy('t') ->orderBy('t.name', 'ASC'); - if ($spec !== null) { - $this->applySpecification($qb, $spec, 't'); - } + $this->applySpecification($qb, $spec, 't'); $query = $qb->getQuery(); From 65797b61a06769892b9bda7cd660acebc86aab6f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 3 Jan 2021 14:03:10 +0100 Subject: [PATCH 09/46] Applied API role specs to single short URL deletion --- .../src/Service/ShortUrl/DeleteShortUrlService.php | 10 +++++++--- .../ShortUrl/DeleteShortUrlServiceInterface.php | 7 ++++++- .../src/Action/ShortUrl/DeleteShortUrlAction.php | 6 +++++- .../Action/ShortUrl/DeleteShortUrlActionTest.php | 12 ++++++++---- 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/module/Core/src/Service/ShortUrl/DeleteShortUrlService.php b/module/Core/src/Service/ShortUrl/DeleteShortUrlService.php index 35a540da..07af448d 100644 --- a/module/Core/src/Service/ShortUrl/DeleteShortUrlService.php +++ b/module/Core/src/Service/ShortUrl/DeleteShortUrlService.php @@ -9,6 +9,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Options\DeleteShortUrlsOptions; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class DeleteShortUrlService implements DeleteShortUrlServiceInterface { @@ -30,9 +31,12 @@ class DeleteShortUrlService implements DeleteShortUrlServiceInterface * @throws Exception\ShortUrlNotFoundException * @throws Exception\DeleteShortUrlException */ - public function deleteByShortCode(ShortUrlIdentifier $identifier, bool $ignoreThreshold = false): void - { - $shortUrl = $this->urlResolver->resolveShortUrl($identifier); + public function deleteByShortCode( + ShortUrlIdentifier $identifier, + bool $ignoreThreshold = false, + ?ApiKey $apiKey = null + ): void { + $shortUrl = $this->urlResolver->resolveShortUrl($identifier, $apiKey); if (! $ignoreThreshold && $this->isThresholdReached($shortUrl)) { throw Exception\DeleteShortUrlException::fromVisitsThreshold( $this->deleteShortUrlsOptions->getVisitsThreshold(), diff --git a/module/Core/src/Service/ShortUrl/DeleteShortUrlServiceInterface.php b/module/Core/src/Service/ShortUrl/DeleteShortUrlServiceInterface.php index 4759bf24..b1f01839 100644 --- a/module/Core/src/Service/ShortUrl/DeleteShortUrlServiceInterface.php +++ b/module/Core/src/Service/ShortUrl/DeleteShortUrlServiceInterface.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Service\ShortUrl; use Shlinkio\Shlink\Core\Exception; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; +use Shlinkio\Shlink\Rest\Entity\ApiKey; interface DeleteShortUrlServiceInterface { @@ -13,5 +14,9 @@ interface DeleteShortUrlServiceInterface * @throws Exception\ShortUrlNotFoundException * @throws Exception\DeleteShortUrlException */ - public function deleteByShortCode(ShortUrlIdentifier $identifier, bool $ignoreThreshold = false): void; + public function deleteByShortCode( + ShortUrlIdentifier $identifier, + bool $ignoreThreshold = false, + ?ApiKey $apiKey = null + ): void; } diff --git a/module/Rest/src/Action/ShortUrl/DeleteShortUrlAction.php b/module/Rest/src/Action/ShortUrl/DeleteShortUrlAction.php index bd5b487e..73eaa6ee 100644 --- a/module/Rest/src/Action/ShortUrl/DeleteShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/DeleteShortUrlAction.php @@ -10,6 +10,7 @@ use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; +use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class DeleteShortUrlAction extends AbstractRestAction { @@ -26,7 +27,10 @@ class DeleteShortUrlAction extends AbstractRestAction public function handle(ServerRequestInterface $request): ResponseInterface { $identifier = ShortUrlIdentifier::fromApiRequest($request); - $this->deleteShortUrlService->deleteByShortCode($identifier); + $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); + + $this->deleteShortUrlService->deleteByShortCode($identifier, false, $apiKey); + return new EmptyResponse(); } } diff --git a/module/Rest/test/Action/ShortUrl/DeleteShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/DeleteShortUrlActionTest.php index 6f724c4e..9be06756 100644 --- a/module/Rest/test/Action/ShortUrl/DeleteShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/DeleteShortUrlActionTest.php @@ -4,13 +4,14 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Rest\Action\ShortUrl; -use Laminas\Diactoros\ServerRequest; +use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface; use Shlinkio\Shlink\Rest\Action\ShortUrl\DeleteShortUrlAction; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class DeleteShortUrlActionTest extends TestCase { @@ -28,10 +29,13 @@ class DeleteShortUrlActionTest extends TestCase /** @test */ public function emptyResponseIsReturnedIfProperlyDeleted(): void { - $deleteByShortCode = $this->service->deleteByShortCode(Argument::any())->will(function (): void { - }); + $apiKey = new ApiKey(); + $deleteByShortCode = $this->service->deleteByShortCode(Argument::any(), false, $apiKey)->will( + function (): void { + }, + ); - $resp = $this->action->handle(new ServerRequest()); + $resp = $this->action->handle(ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, $apiKey)); self::assertEquals(204, $resp->getStatusCode()); $deleteByShortCode->shouldHaveBeenCalledOnce(); From fff10ebee414e1cdbf2ffd27f6612d4592ff70f1 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 3 Jan 2021 16:41:44 +0100 Subject: [PATCH 10/46] Applied API role specs to single short URL edition --- composer.json | 15 ++++-------- infection-db.json | 23 +++++++++++++++++++ infection.json | 8 +++---- module/Core/src/Service/ShortUrlService.php | 9 +++++--- .../src/Service/ShortUrlServiceInterface.php | 6 ++++- .../Core/test/Service/ShortUrlServiceTest.php | 17 +++++++++----- .../Action/ShortUrl/EditShortUrlAction.php | 4 +++- .../ShortUrl/EditShortUrlActionTest.php | 2 ++ 8 files changed, 59 insertions(+), 25 deletions(-) create mode 100644 infection-db.json diff --git a/composer.json b/composer.json index 09b26c42..97cdf152 100644 --- a/composer.json +++ b/composer.json @@ -134,17 +134,12 @@ "test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite", "test:api": "bin/test/run-api-tests.sh", "test:unit:pretty": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage-unit-html", - "infect": "infection --threads=4 --min-msi=80 --log-verbosity=default --only-covered", - "infect:ci:base": "@infect --skip-initial-tests", - "infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit", - "infect:ci:db": "@infect:ci:base --coverage=build/coverage-db --min-msi=95 --test-framework-options=--configuration=phpunit-db.xml", - "infect:ci": [ - "@infect:ci:unit", - "@infect:ci:db" - ], + "infect:ci:base": "infection --threads=4 --log-verbosity=default --only-covered --skip-initial-tests", + "infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=80", + "infect:ci:db": "@infect:ci:base --coverage=build/coverage-db --min-msi=95 --configuration=infection-db.json", + "infect:ci": "@parallel infect:ci:unit infect:ci:db", "infect:test": [ - "@test:unit:ci", - "@test:db:sqlite:ci", + "@parallel test:unit:ci test:db:sqlite:ci", "@infect:ci" ], "clean:dev": "rm -f data/database.sqlite && rm -f config/params/generated_config.php" diff --git a/infection-db.json b/infection-db.json new file mode 100644 index 00000000..a429c995 --- /dev/null +++ b/infection-db.json @@ -0,0 +1,23 @@ +{ + "source": { + "directories": [ + "module/*/src" + ] + }, + "timeout": 5, + "logs": { + "text": "build/infection-db/infection-log.txt", + "summary": "build/infection-db/summary-log.txt", + "debug": "build/infection-db/debug-log.txt" + }, + "tmpDir": "build/infection-db/temp", + "phpUnit": { + "configDir": "." + }, + "testFrameworkOptions": "--configuration=phpunit-db.xml", + "mutators": { + "@default": true, + "IdenticalEqual": false, + "NotIdenticalNotEqual": false + } +} diff --git a/infection.json b/infection.json index 44fdf228..b182bddf 100644 --- a/infection.json +++ b/infection.json @@ -6,11 +6,11 @@ }, "timeout": 5, "logs": { - "text": "build/infection/infection-log.txt", - "summary": "build/infection/summary-log.txt", - "debug": "build/infection/debug-log.txt" + "text": "build/infection-unit/infection-log.txt", + "summary": "build/infection-unit/summary-log.txt", + "debug": "build/infection-unit/debug-log.txt" }, - "tmpDir": "build/infection/temp", + "tmpDir": "build/infection-unit/temp", "phpUnit": { "configDir": "." }, diff --git a/module/Core/src/Service/ShortUrlService.php b/module/Core/src/Service/ShortUrlService.php index 410e0f16..b2691de2 100644 --- a/module/Core/src/Service/ShortUrlService.php +++ b/module/Core/src/Service/ShortUrlService.php @@ -69,13 +69,16 @@ class ShortUrlService implements ShortUrlServiceInterface * @throws ShortUrlNotFoundException * @throws InvalidUrlException */ - public function updateMetadataByShortCode(ShortUrlIdentifier $identifier, ShortUrlEdit $shortUrlEdit): ShortUrl - { + public function updateMetadataByShortCode( + ShortUrlIdentifier $identifier, + ShortUrlEdit $shortUrlEdit, + ?ApiKey $apiKey = null + ): ShortUrl { if ($shortUrlEdit->hasLongUrl()) { $this->urlValidator->validateUrl($shortUrlEdit->longUrl(), $shortUrlEdit->doValidateUrl()); } - $shortUrl = $this->urlResolver->resolveShortUrl($identifier); + $shortUrl = $this->urlResolver->resolveShortUrl($identifier, $apiKey); $shortUrl->update($shortUrlEdit); $this->em->flush(); diff --git a/module/Core/src/Service/ShortUrlServiceInterface.php b/module/Core/src/Service/ShortUrlServiceInterface.php index b3582ac2..63867045 100644 --- a/module/Core/src/Service/ShortUrlServiceInterface.php +++ b/module/Core/src/Service/ShortUrlServiceInterface.php @@ -30,5 +30,9 @@ interface ShortUrlServiceInterface * @throws ShortUrlNotFoundException * @throws InvalidUrlException */ - public function updateMetadataByShortCode(ShortUrlIdentifier $identifier, ShortUrlEdit $shortUrlEdit): ShortUrl; + public function updateMetadataByShortCode( + ShortUrlIdentifier $identifier, + ShortUrlEdit $shortUrlEdit, + ?ApiKey $apiKey = null + ): ShortUrl; } diff --git a/module/Core/test/Service/ShortUrlServiceTest.php b/module/Core/test/Service/ShortUrlServiceTest.php index fc2de22b..b290ceb5 100644 --- a/module/Core/test/Service/ShortUrlServiceTest.php +++ b/module/Core/test/Service/ShortUrlServiceTest.php @@ -20,6 +20,7 @@ use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\Service\ShortUrlService; use Shlinkio\Shlink\Core\Util\UrlValidatorInterface; +use Shlinkio\Shlink\Rest\Entity\ApiKey; use function count; @@ -90,15 +91,19 @@ class ShortUrlServiceTest extends TestCase */ public function updateMetadataByShortCodeUpdatesProvidedData( int $expectedValidateCalls, - ShortUrlEdit $shortUrlEdit + ShortUrlEdit $shortUrlEdit, + ?ApiKey $apiKey ): void { $originalLongUrl = 'originalLongUrl'; $shortUrl = new ShortUrl($originalLongUrl); - $findShortUrl = $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier('abc123'))->willReturn($shortUrl); + $findShortUrl = $this->urlResolver->resolveShortUrl( + new ShortUrlIdentifier('abc123'), + $apiKey, + )->willReturn($shortUrl); $flush = $this->em->flush()->willReturn(null); - $result = $this->service->updateMetadataByShortCode(new ShortUrlIdentifier('abc123'), $shortUrlEdit); + $result = $this->service->updateMetadataByShortCode(new ShortUrlIdentifier('abc123'), $shortUrlEdit, $apiKey); self::assertSame($shortUrl, $result); self::assertEquals($shortUrlEdit->validSince(), $shortUrl->getValidSince()); @@ -121,19 +126,19 @@ class ShortUrlServiceTest extends TestCase 'validUntil' => Chronos::parse('2017-01-05 00:00:00')->toAtomString(), 'maxVisits' => 5, ], - )]; + ), null]; yield 'long URL' => [1, ShortUrlEdit::fromRawData( [ 'validSince' => Chronos::parse('2017-01-01 00:00:00')->toAtomString(), 'maxVisits' => 10, 'longUrl' => 'modifiedLongUrl', ], - )]; + ), new ApiKey()]; yield 'long URL with validation' => [1, ShortUrlEdit::fromRawData( [ 'longUrl' => 'modifiedLongUrl', 'validateUrl' => true, ], - )]; + ), null]; } } diff --git a/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php b/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php index 30d95ae1..32d95b2d 100644 --- a/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php @@ -11,6 +11,7 @@ use Shlinkio\Shlink\Core\Model\ShortUrlEdit; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; +use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class EditShortUrlAction extends AbstractRestAction { @@ -28,8 +29,9 @@ class EditShortUrlAction extends AbstractRestAction { $shortUrlEdit = ShortUrlEdit::fromRawData((array) $request->getParsedBody()); $identifier = ShortUrlIdentifier::fromApiRequest($request); + $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); - $this->shortUrlService->updateMetadataByShortCode($identifier, $shortUrlEdit); + $this->shortUrlService->updateMetadataByShortCode($identifier, $shortUrlEdit, $apiKey); return new EmptyResponse(); } } diff --git a/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php index 087b4298..5e9eadf7 100644 --- a/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php @@ -13,6 +13,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Shlinkio\Shlink\Rest\Action\ShortUrl\EditShortUrlAction; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class EditShortUrlActionTest extends TestCase { @@ -43,6 +44,7 @@ class EditShortUrlActionTest extends TestCase public function correctShortCodeReturnsSuccess(): void { $request = (new ServerRequest())->withAttribute('shortCode', 'abc123') + ->withAttribute(ApiKey::class, new ApiKey()) ->withParsedBody([ 'maxVisits' => 5, ]); From 25ee9b5dafe8425257bcfd224120aae99bef4994 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 3 Jan 2021 16:50:47 +0100 Subject: [PATCH 11/46] Applied API role specs to single short URL tags edition --- .dockerignore | 2 +- module/Core/src/Service/ShortUrlService.php | 4 ++-- .../src/Service/ShortUrlServiceInterface.php | 2 +- .../Core/test/Service/ShortUrlServiceTest.php | 4 ++-- .../ShortUrl/EditShortUrlTagsAction.php | 4 +++- .../ShortUrl/EditShortUrlTagsActionTest.php | 24 ++++++++++++++----- 6 files changed, 27 insertions(+), 13 deletions(-) diff --git a/.dockerignore b/.dockerignore index 9a48c84c..2080adcf 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,7 +17,7 @@ indocker docker-* phpstan.neon php*xml* -infection.json +infection* **/test* build* **/.* diff --git a/module/Core/src/Service/ShortUrlService.php b/module/Core/src/Service/ShortUrlService.php index b2691de2..06b39f08 100644 --- a/module/Core/src/Service/ShortUrlService.php +++ b/module/Core/src/Service/ShortUrlService.php @@ -55,9 +55,9 @@ class ShortUrlService implements ShortUrlServiceInterface * @param string[] $tags * @throws ShortUrlNotFoundException */ - public function setTagsByShortCode(ShortUrlIdentifier $identifier, array $tags = []): ShortUrl + public function setTagsByShortCode(ShortUrlIdentifier $identifier, array $tags, ?ApiKey $apiKey = null): ShortUrl { - $shortUrl = $this->urlResolver->resolveShortUrl($identifier); + $shortUrl = $this->urlResolver->resolveShortUrl($identifier, $apiKey); $shortUrl->setTags($this->tagNamesToEntities($this->em, $tags)); $this->em->flush(); diff --git a/module/Core/src/Service/ShortUrlServiceInterface.php b/module/Core/src/Service/ShortUrlServiceInterface.php index 63867045..5f6b9b30 100644 --- a/module/Core/src/Service/ShortUrlServiceInterface.php +++ b/module/Core/src/Service/ShortUrlServiceInterface.php @@ -24,7 +24,7 @@ interface ShortUrlServiceInterface * @param string[] $tags * @throws ShortUrlNotFoundException */ - public function setTagsByShortCode(ShortUrlIdentifier $identifier, array $tags = []): ShortUrl; + public function setTagsByShortCode(ShortUrlIdentifier $identifier, array $tags, ?ApiKey $apiKey = null): ShortUrl; /** * @throws ShortUrlNotFoundException diff --git a/module/Core/test/Service/ShortUrlServiceTest.php b/module/Core/test/Service/ShortUrlServiceTest.php index b290ceb5..19c92b6f 100644 --- a/module/Core/test/Service/ShortUrlServiceTest.php +++ b/module/Core/test/Service/ShortUrlServiceTest.php @@ -74,8 +74,8 @@ class ShortUrlServiceTest extends TestCase $shortUrl = $this->prophesize(ShortUrl::class); $shortUrl->setTags(Argument::any())->shouldBeCalledOnce(); $shortCode = 'abc123'; - $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode))->willReturn($shortUrl->reveal()) - ->shouldBeCalledOnce(); + $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode), null)->willReturn($shortUrl->reveal()) + ->shouldBeCalledOnce(); $tagRepo = $this->prophesize(EntityRepository::class); $tagRepo->findOneBy(['name' => 'foo'])->willReturn(new Tag('foo'))->shouldBeCalledOnce(); diff --git a/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php b/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php index def36d6c..7d115765 100644 --- a/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php +++ b/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php @@ -11,6 +11,7 @@ use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; +use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class EditShortUrlTagsAction extends AbstractRestAction { @@ -35,8 +36,9 @@ class EditShortUrlTagsAction extends AbstractRestAction } ['tags' => $tags] = $bodyParams; $identifier = ShortUrlIdentifier::fromApiRequest($request); + $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); - $shortUrl = $this->shortUrlService->setTagsByShortCode($identifier, $tags); + $shortUrl = $this->shortUrlService->setTagsByShortCode($identifier, $tags, $apiKey); return new JsonResponse(['tags' => $shortUrl->getTags()->toArray()]); } } diff --git a/module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php b/module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php index 2fa6f456..9c72dd91 100644 --- a/module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php +++ b/module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php @@ -4,15 +4,18 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Rest\Action\ShortUrl; -use Laminas\Diactoros\ServerRequest; +use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\TestCase; +use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; +use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Service\ShortUrlService; use Shlinkio\Shlink\Rest\Action\ShortUrl\EditShortUrlTagsAction; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class EditShortUrlTagsActionTest extends TestCase { @@ -31,20 +34,29 @@ class EditShortUrlTagsActionTest extends TestCase public function notProvidingTagsReturnsError(): void { $this->expectException(ValidationException::class); - $this->action->handle((new ServerRequest())->withAttribute('shortCode', 'abc123')); + $this->action->handle($this->createRequestWithAPiKey()->withAttribute('shortCode', 'abc123')); } /** @test */ public function tagsListIsReturnedIfCorrectShortCodeIsProvided(): void { $shortCode = 'abc123'; - $this->shortUrlService->setTagsByShortCode(new ShortUrlIdentifier($shortCode), [])->willReturn(new ShortUrl('')) - ->shouldBeCalledOnce(); + $this->shortUrlService->setTagsByShortCode( + new ShortUrlIdentifier($shortCode), + [], + Argument::type(ApiKey::class), + )->willReturn(new ShortUrl('')) + ->shouldBeCalledOnce(); $response = $this->action->handle( - (new ServerRequest())->withAttribute('shortCode', 'abc123') - ->withParsedBody(['tags' => []]), + $this->createRequestWithAPiKey()->withAttribute('shortCode', 'abc123') + ->withParsedBody(['tags' => []]), ); self::assertEquals(200, $response->getStatusCode()); } + + private function createRequestWithAPiKey(): ServerRequestInterface + { + return ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, new ApiKey()); + } } From 4a1e7b761aab2830a0e88c73e0ad2a8682bf5f34 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 3 Jan 2021 17:48:32 +0100 Subject: [PATCH 12/46] Applied API role specs to short URL visits --- .../Adapter/ShortUrlRepositoryAdapter.php | 10 ++++-- .../Adapter/VisitsPaginatorAdapter.php | 8 ++++- .../src/Repository/ShortUrlRepository.php | 4 +-- .../ShortUrlRepositoryInterface.php | 2 +- .../Core/src/Repository/VisitRepository.php | 25 +++++++++----- .../Repository/VisitRepositoryInterface.php | 10 ++++-- module/Core/src/Service/VisitsTracker.php | 9 +++-- .../src/Service/VisitsTrackerInterface.php | 3 +- .../src/ShortUrl/Spec/BelongsToApiKey.php | 2 +- .../Adapter/VisitsPaginatorAdapterTest.php | 7 ++-- .../Core/test/Service/VisitsTrackerTest.php | 10 +++--- .../src/Action/Visit/ShortUrlVisitsAction.php | 5 ++- .../Action/Visit/ShortUrlVisitsActionTest.php | 34 ++++++++++++------- 13 files changed, 87 insertions(+), 42 deletions(-) diff --git a/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php b/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php index 8f339dc0..93fd88c7 100644 --- a/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php +++ b/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Paginator\Adapter; +use Happyr\DoctrineSpecification\Specification\Specification; use Laminas\Paginator\Adapter\AdapterInterface; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; @@ -31,7 +32,7 @@ class ShortUrlRepositoryAdapter implements AdapterInterface $this->params->tags(), $this->params->orderBy(), $this->params->dateRange(), - $this->apiKey !== null ? $this->apiKey->spec() : null, + $this->resolveSpec(), ); } @@ -41,7 +42,12 @@ class ShortUrlRepositoryAdapter implements AdapterInterface $this->params->searchTerm(), $this->params->tags(), $this->params->dateRange(), - $this->apiKey !== null ? $this->apiKey->spec() : null, + $this->resolveSpec(), ); } + + private function resolveSpec(): ?Specification + { + return $this->apiKey !== null ? $this->apiKey->spec() : null; + } } diff --git a/module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php b/module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php index 404ae309..29498a6d 100644 --- a/module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php +++ b/module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Paginator\Adapter; +use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface; @@ -13,15 +14,18 @@ class VisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter private VisitRepositoryInterface $visitRepository; private ShortUrlIdentifier $identifier; private VisitsParams $params; + private ?Specification $spec; public function __construct( VisitRepositoryInterface $visitRepository, ShortUrlIdentifier $identifier, - VisitsParams $params + VisitsParams $params, + ?Specification $spec ) { $this->visitRepository = $visitRepository; $this->params = $params; $this->identifier = $identifier; + $this->spec = $spec; } public function getItems($offset, $itemCountPerPage): array // phpcs:ignore @@ -32,6 +36,7 @@ class VisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter $this->params->getDateRange(), $itemCountPerPage, $offset, + $this->spec, ); } @@ -41,6 +46,7 @@ class VisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter $this->identifier->shortCode(), $this->identifier->domain(), $this->params->getDateRange(), + $this->spec, ); } } diff --git a/module/Core/src/Repository/ShortUrlRepository.php b/module/Core/src/Repository/ShortUrlRepository.php index 4fa6fbd1..d4bb1d16 100644 --- a/module/Core/src/Repository/ShortUrlRepository.php +++ b/module/Core/src/Repository/ShortUrlRepository.php @@ -177,9 +177,9 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU return $qb->getQuery()->getOneOrNullResult(); } - public function shortCodeIsInUse(string $slug, ?string $domain = null): bool + public function shortCodeIsInUse(string $slug, ?string $domain = null, ?Specification $spec = null): bool { - $qb = $this->createFindOneQueryBuilder($slug, $domain, null); + $qb = $this->createFindOneQueryBuilder($slug, $domain, $spec); $qb->select('COUNT(DISTINCT s.id)'); return ((int) $qb->getQuery()->getSingleScalarResult()) > 0; diff --git a/module/Core/src/Repository/ShortUrlRepositoryInterface.php b/module/Core/src/Repository/ShortUrlRepositoryInterface.php index fee546fe..a0131f6f 100644 --- a/module/Core/src/Repository/ShortUrlRepositoryInterface.php +++ b/module/Core/src/Repository/ShortUrlRepositoryInterface.php @@ -36,7 +36,7 @@ interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificat public function findOne(string $shortCode, ?string $domain = null, ?Specification $spec = null): ?ShortUrl; - public function shortCodeIsInUse(string $slug, ?string $domain): bool; + public function shortCodeIsInUse(string $slug, ?string $domain, ?Specification $spec = null): bool; public function findOneMatching(string $url, array $tags, ShortUrlMeta $meta): ?ShortUrl; diff --git a/module/Core/src/Repository/VisitRepository.php b/module/Core/src/Repository/VisitRepository.php index 458b8ef2..13447372 100644 --- a/module/Core/src/Repository/VisitRepository.php +++ b/module/Core/src/Repository/VisitRepository.php @@ -4,9 +4,10 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Repository; -use Doctrine\ORM\EntityRepository; use Doctrine\ORM\Query\ResultSetMappingBuilder; use Doctrine\ORM\QueryBuilder; +use Happyr\DoctrineSpecification\EntitySpecificationRepository; +use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; @@ -14,7 +15,7 @@ use Shlinkio\Shlink\Core\Entity\VisitLocation; use const PHP_INT_MAX; -class VisitRepository extends EntityRepository implements VisitRepositoryInterface +class VisitRepository extends EntitySpecificationRepository implements VisitRepositoryInterface { /** * @return iterable|Visit[] @@ -84,15 +85,20 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa ?string $domain = null, ?DateRange $dateRange = null, ?int $limit = null, - ?int $offset = null + ?int $offset = null, + ?Specification $spec = null ): array { - $qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange); + $qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange, $spec); return $this->resolveVisitsWithNativeQuery($qb, $limit, $offset); } - public function countVisitsByShortCode(string $shortCode, ?string $domain = null, ?DateRange $dateRange = null): int - { - $qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange); + public function countVisitsByShortCode( + string $shortCode, + ?string $domain = null, + ?DateRange $dateRange = null, + ?Specification $spec = null + ): int { + $qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange, $spec); $qb->select('COUNT(v.id)'); return (int) $qb->getQuery()->getSingleScalarResult(); @@ -101,11 +107,12 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa private function createVisitsByShortCodeQueryBuilder( string $shortCode, ?string $domain, - ?DateRange $dateRange + ?DateRange $dateRange, + ?Specification $spec = null ): QueryBuilder { /** @var ShortUrlRepositoryInterface $shortUrlRepo */ $shortUrlRepo = $this->getEntityManager()->getRepository(ShortUrl::class); - $shortUrl = $shortUrlRepo->findOne($shortCode, $domain); + $shortUrl = $shortUrlRepo->findOne($shortCode, $domain, $spec); $shortUrlId = $shortUrl !== null ? $shortUrl->getId() : -1; // Parameters in this query need to be part of the query itself, as we need to use it a sub-query later diff --git a/module/Core/src/Repository/VisitRepositoryInterface.php b/module/Core/src/Repository/VisitRepositoryInterface.php index 5a540171..804023c8 100644 --- a/module/Core/src/Repository/VisitRepositoryInterface.php +++ b/module/Core/src/Repository/VisitRepositoryInterface.php @@ -5,10 +5,12 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Repository; use Doctrine\Persistence\ObjectRepository; +use Happyr\DoctrineSpecification\EntitySpecificationRepositoryInterface; +use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\Visit; -interface VisitRepositoryInterface extends ObjectRepository +interface VisitRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface { public const DEFAULT_BLOCK_SIZE = 10000; @@ -35,13 +37,15 @@ interface VisitRepositoryInterface extends ObjectRepository ?string $domain = null, ?DateRange $dateRange = null, ?int $limit = null, - ?int $offset = null + ?int $offset = null, + ?Specification $spec = null ): array; public function countVisitsByShortCode( string $shortCode, ?string $domain = null, - ?DateRange $dateRange = null + ?DateRange $dateRange = null, + ?Specification $spec = null ): int; /** diff --git a/module/Core/src/Service/VisitsTracker.php b/module/Core/src/Service/VisitsTracker.php index e777af76..e12ddbec 100644 --- a/module/Core/src/Service/VisitsTracker.php +++ b/module/Core/src/Service/VisitsTracker.php @@ -21,6 +21,7 @@ use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsPaginatorAdapter; use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; use Shlinkio\Shlink\Core\Repository\TagRepository; use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class VisitsTracker implements VisitsTrackerInterface { @@ -52,17 +53,19 @@ class VisitsTracker implements VisitsTrackerInterface * @return Visit[]|Paginator * @throws ShortUrlNotFoundException */ - public function info(ShortUrlIdentifier $identifier, VisitsParams $params): Paginator + public function info(ShortUrlIdentifier $identifier, VisitsParams $params, ?ApiKey $apiKey = null): Paginator { + $spec = $apiKey !== null ? $apiKey->spec() : null; + /** @var ShortUrlRepositoryInterface $repo */ $repo = $this->em->getRepository(ShortUrl::class); - if (! $repo->shortCodeIsInUse($identifier->shortCode(), $identifier->domain())) { + if (! $repo->shortCodeIsInUse($identifier->shortCode(), $identifier->domain(), $spec)) { throw ShortUrlNotFoundException::fromNotFound($identifier); } /** @var VisitRepositoryInterface $repo */ $repo = $this->em->getRepository(Visit::class); - $paginator = new Paginator(new VisitsPaginatorAdapter($repo, $identifier, $params)); + $paginator = new Paginator(new VisitsPaginatorAdapter($repo, $identifier, $params, $spec)); $paginator->setItemCountPerPage($params->getItemsPerPage()) ->setCurrentPageNumber($params->getPage()); diff --git a/module/Core/src/Service/VisitsTrackerInterface.php b/module/Core/src/Service/VisitsTrackerInterface.php index 2c2759c2..68e6c854 100644 --- a/module/Core/src/Service/VisitsTrackerInterface.php +++ b/module/Core/src/Service/VisitsTrackerInterface.php @@ -12,6 +12,7 @@ use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Model\VisitsParams; +use Shlinkio\Shlink\Rest\Entity\ApiKey; interface VisitsTrackerInterface { @@ -21,7 +22,7 @@ interface VisitsTrackerInterface * @return Visit[]|Paginator * @throws ShortUrlNotFoundException */ - public function info(ShortUrlIdentifier $identifier, VisitsParams $params): Paginator; + public function info(ShortUrlIdentifier $identifier, VisitsParams $params, ?ApiKey $apiKey = null): Paginator; /** * @return Visit[]|Paginator diff --git a/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php b/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php index a1059168..9e094b90 100644 --- a/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php +++ b/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php @@ -16,8 +16,8 @@ class BelongsToApiKey extends BaseSpecification public function __construct(ApiKey $apiKey, ?string $dqlAlias = null) { - $this->dqlAlias = $dqlAlias ?? 's'; $this->apiKey = $apiKey; + $this->dqlAlias = $dqlAlias ?? 's'; parent::__construct($this->dqlAlias); } diff --git a/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php b/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php index 508a0984..ca0c5806 100644 --- a/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php +++ b/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php @@ -27,6 +27,7 @@ class VisitsPaginatorAdapterTest extends TestCase $this->repo->reveal(), new ShortUrlIdentifier(''), VisitsParams::fromRawData([]), + null, ); } @@ -36,7 +37,9 @@ class VisitsPaginatorAdapterTest extends TestCase $count = 3; $limit = 1; $offset = 5; - $findVisits = $this->repo->findVisitsByShortCode('', null, new DateRange(), $limit, $offset)->willReturn([]); + $findVisits = $this->repo->findVisitsByShortCode('', null, new DateRange(), $limit, $offset, null)->willReturn( + [], + ); for ($i = 0; $i < $count; $i++) { $this->adapter->getItems($offset, $limit); @@ -49,7 +52,7 @@ class VisitsPaginatorAdapterTest extends TestCase public function repoIsCalledOnlyOnceForCount(): void { $count = 3; - $countVisits = $this->repo->countVisitsByShortCode('', null, new DateRange())->willReturn(3); + $countVisits = $this->repo->countVisitsByShortCode('', null, new DateRange(), null)->willReturn(3); for ($i = 0; $i < $count; $i++) { $this->adapter->count(); diff --git a/module/Core/test/Service/VisitsTrackerTest.php b/module/Core/test/Service/VisitsTrackerTest.php index 1d9096e3..b5509ae3 100644 --- a/module/Core/test/Service/VisitsTrackerTest.php +++ b/module/Core/test/Service/VisitsTrackerTest.php @@ -63,13 +63,15 @@ class VisitsTrackerTest extends TestCase { $shortCode = '123ABC'; $repo = $this->prophesize(ShortUrlRepositoryInterface::class); - $count = $repo->shortCodeIsInUse($shortCode, null)->willReturn(true); + $count = $repo->shortCodeIsInUse($shortCode, null, null)->willReturn(true); $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce(); $list = map(range(0, 1), fn () => new Visit(new ShortUrl(''), Visitor::emptyInstance())); $repo2 = $this->prophesize(VisitRepository::class); - $repo2->findVisitsByShortCode($shortCode, null, Argument::type(DateRange::class), 1, 0)->willReturn($list); - $repo2->countVisitsByShortCode($shortCode, null, Argument::type(DateRange::class))->willReturn(1); + $repo2->findVisitsByShortCode($shortCode, null, Argument::type(DateRange::class), 1, 0, null)->willReturn( + $list, + ); + $repo2->countVisitsByShortCode($shortCode, null, Argument::type(DateRange::class), null)->willReturn(1); $this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce(); $paginator = $this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams()); @@ -83,7 +85,7 @@ class VisitsTrackerTest extends TestCase { $shortCode = '123ABC'; $repo = $this->prophesize(ShortUrlRepositoryInterface::class); - $count = $repo->shortCodeIsInUse($shortCode, null)->willReturn(false); + $count = $repo->shortCodeIsInUse($shortCode, null, null)->willReturn(false); $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce(); $this->expectException(ShortUrlNotFoundException::class); diff --git a/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php b/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php index 92a7e873..4a9a95e9 100644 --- a/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php +++ b/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php @@ -12,6 +12,7 @@ use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; +use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class ShortUrlVisitsAction extends AbstractRestAction { @@ -30,7 +31,9 @@ class ShortUrlVisitsAction extends AbstractRestAction public function handle(Request $request): Response { $identifier = ShortUrlIdentifier::fromApiRequest($request); - $visits = $this->visitsTracker->info($identifier, VisitsParams::fromRawData($request->getQueryParams())); + $params = VisitsParams::fromRawData($request->getQueryParams()); + $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); + $visits = $this->visitsTracker->info($identifier, $params, $apiKey); return new JsonResponse([ 'visits' => $this->serializePaginator($visits), diff --git a/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php b/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php index 25e71006..0bedbd37 100644 --- a/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php @@ -5,18 +5,20 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Rest\Action\Visit; use Cake\Chronos\Chronos; -use Laminas\Diactoros\ServerRequest; +use Laminas\Diactoros\ServerRequestFactory; use Laminas\Paginator\Adapter\ArrayAdapter; use Laminas\Paginator\Paginator; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; +use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Service\VisitsTracker; use Shlinkio\Shlink\Rest\Action\Visit\ShortUrlVisitsAction; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class ShortUrlVisitsActionTest extends TestCase { @@ -35,11 +37,14 @@ class ShortUrlVisitsActionTest extends TestCase public function providingCorrectShortCodeReturnsVisits(): void { $shortCode = 'abc123'; - $this->visitsTracker->info(new ShortUrlIdentifier($shortCode), Argument::type(VisitsParams::class))->willReturn( - new Paginator(new ArrayAdapter([])), - )->shouldBeCalledOnce(); + $this->visitsTracker->info( + new ShortUrlIdentifier($shortCode), + Argument::type(VisitsParams::class), + Argument::type(ApiKey::class), + )->willReturn(new Paginator(new ArrayAdapter([]))) + ->shouldBeCalledOnce(); - $response = $this->action->handle((new ServerRequest())->withAttribute('shortCode', $shortCode)); + $response = $this->action->handle($this->requestWithApiKey()->withAttribute('shortCode', $shortCode)); self::assertEquals(200, $response->getStatusCode()); } @@ -51,18 +56,23 @@ class ShortUrlVisitsActionTest extends TestCase new DateRange(null, Chronos::parse('2016-01-01 00:00:00')), 3, 10, - )) + ), Argument::type(ApiKey::class)) ->willReturn(new Paginator(new ArrayAdapter([]))) ->shouldBeCalledOnce(); $response = $this->action->handle( - (new ServerRequest())->withAttribute('shortCode', $shortCode) - ->withQueryParams([ - 'endDate' => '2016-01-01 00:00:00', - 'page' => '3', - 'itemsPerPage' => '10', - ]), + $this->requestWithApiKey()->withAttribute('shortCode', $shortCode) + ->withQueryParams([ + 'endDate' => '2016-01-01 00:00:00', + 'page' => '3', + 'itemsPerPage' => '10', + ]), ); self::assertEquals(200, $response->getStatusCode()); } + + private function requestWithApiKey(): ServerRequestInterface + { + return ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, new ApiKey()); + } } From 8aa6bdb93471dbc9e94a57f8a07a72635f6c7ec3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 4 Jan 2021 11:14:28 +0100 Subject: [PATCH 13/46] Applied API role specs to tag visits --- .../Adapter/VisitsForTagPaginatorAdapter.php | 24 +++++++++++++-- module/Core/src/Repository/TagRepository.php | 14 +++++++++ .../src/Repository/TagRepositoryInterface.php | 3 ++ .../Core/src/Repository/VisitRepository.php | 22 ++++++++------ .../Repository/VisitRepositoryInterface.php | 5 ++-- module/Core/src/Service/VisitsTracker.php | 7 ++--- .../src/Service/VisitsTrackerInterface.php | 2 +- .../ShortUrl/Spec/BelongsToApiKeyInlined.php | 29 ++++++++++++++++++ .../ShortUrl/Spec/BelongsToDomainInlined.php | 28 +++++++++++++++++ .../Core/src/Tag/Spec/CountTagsWithName.php | 30 +++++++++++++++++++ .../VisitsForTagPaginatorAdapterTest.php | 11 +++++-- .../Core/test/Service/VisitsTrackerTest.php | 16 +++++----- .../Rest/src/Action/Visit/TagVisitsAction.php | 5 +++- module/Rest/src/ApiKey/Role.php | 9 ++++-- .../Spec/WithApiKeySpecsEnsuringJoin.php | 29 ++++++++++++++++++ module/Rest/src/Entity/ApiKey.php | 9 ++++-- .../test/Action/Visit/TagVisitsActionTest.php | 8 +++-- 17 files changed, 214 insertions(+), 37 deletions(-) create mode 100644 module/Core/src/ShortUrl/Spec/BelongsToApiKeyInlined.php create mode 100644 module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php create mode 100644 module/Core/src/Tag/Spec/CountTagsWithName.php create mode 100644 module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php diff --git a/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php b/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php index e80fbcdd..3b73509a 100644 --- a/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php +++ b/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php @@ -4,20 +4,28 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Paginator\Adapter; +use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter { private VisitRepositoryInterface $visitRepository; private string $tag; private VisitsParams $params; + private ?ApiKey $apiKey; - public function __construct(VisitRepositoryInterface $visitRepository, string $tag, VisitsParams $params) - { + public function __construct( + VisitRepositoryInterface $visitRepository, + string $tag, + VisitsParams $params, + ?ApiKey $apiKey + ) { $this->visitRepository = $visitRepository; $this->params = $params; $this->tag = $tag; + $this->apiKey = $apiKey; } public function getItems($offset, $itemCountPerPage): array // phpcs:ignore @@ -27,11 +35,21 @@ class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte $this->params->getDateRange(), $itemCountPerPage, $offset, + $this->resolveSpec(), ); } protected function doCount(): int { - return $this->visitRepository->countVisitsByTag($this->tag, $this->params->getDateRange()); + return $this->visitRepository->countVisitsByTag( + $this->tag, + $this->params->getDateRange(), + $this->resolveSpec(), + ); + } + + private function resolveSpec(): ?Specification + { + return $this->apiKey !== null ? $this->apiKey->spec(true) : null; } } diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php index a2cb5bfd..dd15c292 100644 --- a/module/Core/src/Repository/TagRepository.php +++ b/module/Core/src/Repository/TagRepository.php @@ -5,9 +5,13 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Repository; use Happyr\DoctrineSpecification\EntitySpecificationRepository; +use Happyr\DoctrineSpecification\Spec; use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; +use Shlinkio\Shlink\Core\Tag\Spec\CountTagsWithName; +use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin; +use Shlinkio\Shlink\Rest\Entity\ApiKey; use function Functional\map; @@ -47,4 +51,14 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito fn (array $row) => new TagInfo($row['tag'], (int) $row['shortUrlsCount'], (int) $row['visitsCount']), ); } + + public function tagExists(string $tag, ?ApiKey $apiKey = null): bool + { + $result = (int) $this->matchSingleScalarResult(Spec::andX( + new CountTagsWithName($tag), + new WithApiKeySpecsEnsuringJoin($apiKey), + )); + + return $result > 0; + } } diff --git a/module/Core/src/Repository/TagRepositoryInterface.php b/module/Core/src/Repository/TagRepositoryInterface.php index a486ef55..86898ed1 100644 --- a/module/Core/src/Repository/TagRepositoryInterface.php +++ b/module/Core/src/Repository/TagRepositoryInterface.php @@ -8,6 +8,7 @@ use Doctrine\Persistence\ObjectRepository; use Happyr\DoctrineSpecification\EntitySpecificationRepositoryInterface; use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; +use Shlinkio\Shlink\Rest\Entity\ApiKey; interface TagRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface { @@ -17,4 +18,6 @@ interface TagRepositoryInterface extends ObjectRepository, EntitySpecificationRe * @return TagInfo[] */ public function findTagsWithInfo(?Specification $spec = null): array; + + public function tagExists(string $tag, ?ApiKey $apiKey = null): bool; } diff --git a/module/Core/src/Repository/VisitRepository.php b/module/Core/src/Repository/VisitRepository.php index 13447372..8e750bc3 100644 --- a/module/Core/src/Repository/VisitRepository.php +++ b/module/Core/src/Repository/VisitRepository.php @@ -131,32 +131,36 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo string $tag, ?DateRange $dateRange = null, ?int $limit = null, - ?int $offset = null + ?int $offset = null, + ?Specification $spec = null ): array { - $qb = $this->createVisitsByTagQueryBuilder($tag, $dateRange); + $qb = $this->createVisitsByTagQueryBuilder($tag, $dateRange, $spec); return $this->resolveVisitsWithNativeQuery($qb, $limit, $offset); } - public function countVisitsByTag(string $tag, ?DateRange $dateRange = null): int + public function countVisitsByTag(string $tag, ?DateRange $dateRange = null, ?Specification $spec = null): int { - $qb = $this->createVisitsByTagQueryBuilder($tag, $dateRange); + $qb = $this->createVisitsByTagQueryBuilder($tag, $dateRange, $spec); $qb->select('COUNT(v.id)'); return (int) $qb->getQuery()->getSingleScalarResult(); } - private function createVisitsByTagQueryBuilder(string $tag, ?DateRange $dateRange = null): QueryBuilder - { - // Parameters in this query need to be part of the query itself, as we need to use it a sub-query later + private function createVisitsByTagQueryBuilder( + string $tag, + ?DateRange $dateRange, + ?Specification $spec + ): QueryBuilder { + // Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later // Since they are not strictly provided by the caller, it's reasonably safe $qb = $this->getEntityManager()->createQueryBuilder(); $qb->from(Visit::class, 'v') ->join('v.shortUrl', 's') ->join('s.tags', 't') - ->where($qb->expr()->eq('t.name', '\'' . $tag . '\'')); + ->where($qb->expr()->eq('t.name', '\'' . $tag . '\'')); // This needs to be concatenated, not bound - // Apply date range filtering $this->applyDatesInline($qb, $dateRange); + $this->applySpecification($qb, $spec, 'v'); return $qb; } diff --git a/module/Core/src/Repository/VisitRepositoryInterface.php b/module/Core/src/Repository/VisitRepositoryInterface.php index 804023c8..71a6c4ae 100644 --- a/module/Core/src/Repository/VisitRepositoryInterface.php +++ b/module/Core/src/Repository/VisitRepositoryInterface.php @@ -55,8 +55,9 @@ interface VisitRepositoryInterface extends ObjectRepository, EntitySpecification string $tag, ?DateRange $dateRange = null, ?int $limit = null, - ?int $offset = null + ?int $offset = null, + ?Specification $spec = null ): array; - public function countVisitsByTag(string $tag, ?DateRange $dateRange = null): int; + public function countVisitsByTag(string $tag, ?DateRange $dateRange = null, ?Specification $spec = null): int; } diff --git a/module/Core/src/Service/VisitsTracker.php b/module/Core/src/Service/VisitsTracker.php index e12ddbec..fc35499f 100644 --- a/module/Core/src/Service/VisitsTracker.php +++ b/module/Core/src/Service/VisitsTracker.php @@ -76,18 +76,17 @@ class VisitsTracker implements VisitsTrackerInterface * @return Visit[]|Paginator * @throws TagNotFoundException */ - public function visitsForTag(string $tag, VisitsParams $params): Paginator + public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey = null): Paginator { /** @var TagRepository $tagRepo */ $tagRepo = $this->em->getRepository(Tag::class); - $count = $tagRepo->count(['name' => $tag]); - if ($count === 0) { + if (! $tagRepo->tagExists($tag, $apiKey)) { throw TagNotFoundException::fromTag($tag); } /** @var VisitRepositoryInterface $repo */ $repo = $this->em->getRepository(Visit::class); - $paginator = new Paginator(new VisitsForTagPaginatorAdapter($repo, $tag, $params)); + $paginator = new Paginator(new VisitsForTagPaginatorAdapter($repo, $tag, $params, $apiKey)); $paginator->setItemCountPerPage($params->getItemsPerPage()) ->setCurrentPageNumber($params->getPage()); diff --git a/module/Core/src/Service/VisitsTrackerInterface.php b/module/Core/src/Service/VisitsTrackerInterface.php index 68e6c854..ecffae23 100644 --- a/module/Core/src/Service/VisitsTrackerInterface.php +++ b/module/Core/src/Service/VisitsTrackerInterface.php @@ -28,5 +28,5 @@ interface VisitsTrackerInterface * @return Visit[]|Paginator * @throws TagNotFoundException */ - public function visitsForTag(string $tag, VisitsParams $params): Paginator; + public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey = null): Paginator; } diff --git a/module/Core/src/ShortUrl/Spec/BelongsToApiKeyInlined.php b/module/Core/src/ShortUrl/Spec/BelongsToApiKeyInlined.php new file mode 100644 index 00000000..197031f3 --- /dev/null +++ b/module/Core/src/ShortUrl/Spec/BelongsToApiKeyInlined.php @@ -0,0 +1,29 @@ +apiKey = $apiKey; + } + + public function getFilter(QueryBuilder $qb, string $dqlAlias): string + { + // Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later + return (string) $qb->expr()->eq('s.authorApiKey', '\'' . $this->apiKey->getId() . '\''); + } + + public function modify(QueryBuilder $qb, string $dqlAlias): void + { + } +} diff --git a/module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php b/module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php new file mode 100644 index 00000000..edadf760 --- /dev/null +++ b/module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php @@ -0,0 +1,28 @@ +domainId = $domainId; + } + + public function getFilter(QueryBuilder $qb, string $dqlAlias): string + { + // Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later + return (string) $qb->expr()->eq('s.domain', '\'' . $this->domainId . '\''); + } + + public function modify(QueryBuilder $qb, string $dqlAlias): void + { + } +} diff --git a/module/Core/src/Tag/Spec/CountTagsWithName.php b/module/Core/src/Tag/Spec/CountTagsWithName.php new file mode 100644 index 00000000..a3f90a78 --- /dev/null +++ b/module/Core/src/Tag/Spec/CountTagsWithName.php @@ -0,0 +1,30 @@ +tagName = $tagName; + } + + protected function getSpec(): Specification + { + return Spec::countOf( + Spec::andX( + Spec::select('id'), + Spec::eq('name', $this->tagName), + ), + ); + } +} diff --git a/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php b/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php index b3a47749..8d577b91 100644 --- a/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php +++ b/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php @@ -22,7 +22,12 @@ class VisitsForTagPaginatorAdapterTest extends TestCase protected function setUp(): void { $this->repo = $this->prophesize(VisitRepositoryInterface::class); - $this->adapter = new VisitsForTagPaginatorAdapter($this->repo->reveal(), 'foo', VisitsParams::fromRawData([])); + $this->adapter = new VisitsForTagPaginatorAdapter( + $this->repo->reveal(), + 'foo', + VisitsParams::fromRawData([]), + null, + ); } /** @test */ @@ -31,7 +36,7 @@ class VisitsForTagPaginatorAdapterTest extends TestCase $count = 3; $limit = 1; $offset = 5; - $findVisits = $this->repo->findVisitsByTag('foo', new DateRange(), $limit, $offset)->willReturn([]); + $findVisits = $this->repo->findVisitsByTag('foo', new DateRange(), $limit, $offset, null)->willReturn([]); for ($i = 0; $i < $count; $i++) { $this->adapter->getItems($offset, $limit); @@ -44,7 +49,7 @@ class VisitsForTagPaginatorAdapterTest extends TestCase public function repoIsCalledOnlyOnceForCount(): void { $count = 3; - $countVisits = $this->repo->countVisitsByTag('foo', new DateRange())->willReturn(3); + $countVisits = $this->repo->countVisitsByTag('foo', new DateRange(), null)->willReturn(3); for ($i = 0; $i < $count; $i++) { $this->adapter->count(); diff --git a/module/Core/test/Service/VisitsTrackerTest.php b/module/Core/test/Service/VisitsTrackerTest.php index b5509ae3..56478966 100644 --- a/module/Core/test/Service/VisitsTrackerTest.php +++ b/module/Core/test/Service/VisitsTrackerTest.php @@ -25,6 +25,7 @@ use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; use Shlinkio\Shlink\Core\Repository\TagRepository; use Shlinkio\Shlink\Core\Repository\VisitRepository; use Shlinkio\Shlink\Core\Service\VisitsTracker; +use Shlinkio\Shlink\Rest\Entity\ApiKey; use function Functional\map; use function range; @@ -98,15 +99,16 @@ class VisitsTrackerTest extends TestCase public function throwsExceptionWhenRequestingVisitsForInvalidTag(): void { $tag = 'foo'; + $apiKey = new ApiKey(); $repo = $this->prophesize(TagRepository::class); - $count = $repo->count(['name' => $tag])->willReturn(0); + $tagExists = $repo->tagExists($tag, $apiKey)->willReturn(false); $getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal()); $this->expectException(TagNotFoundException::class); - $count->shouldBeCalledOnce(); + $tagExists->shouldBeCalledOnce(); $getRepo->shouldBeCalledOnce(); - $this->visitsTracker->visitsForTag($tag, new VisitsParams()); + $this->visitsTracker->visitsForTag($tag, new VisitsParams(), $apiKey); } /** @test */ @@ -114,19 +116,19 @@ class VisitsTrackerTest extends TestCase { $tag = 'foo'; $repo = $this->prophesize(TagRepository::class); - $count = $repo->count(['name' => $tag])->willReturn(1); + $tagExists = $repo->tagExists($tag, null)->willReturn(true); $getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal()); $list = map(range(0, 1), fn () => new Visit(new ShortUrl(''), Visitor::emptyInstance())); $repo2 = $this->prophesize(VisitRepository::class); - $repo2->findVisitsByTag($tag, Argument::type(DateRange::class), 1, 0)->willReturn($list); - $repo2->countVisitsByTag($tag, Argument::type(DateRange::class))->willReturn(1); + $repo2->findVisitsByTag($tag, Argument::type(DateRange::class), 1, 0, null)->willReturn($list); + $repo2->countVisitsByTag($tag, Argument::type(DateRange::class), null)->willReturn(1); $this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce(); $paginator = $this->visitsTracker->visitsForTag($tag, new VisitsParams()); self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentItems())); - $count->shouldHaveBeenCalledOnce(); + $tagExists->shouldHaveBeenCalledOnce(); $getRepo->shouldHaveBeenCalledOnce(); } } diff --git a/module/Rest/src/Action/Visit/TagVisitsAction.php b/module/Rest/src/Action/Visit/TagVisitsAction.php index 1107ca5c..c83ee95c 100644 --- a/module/Rest/src/Action/Visit/TagVisitsAction.php +++ b/module/Rest/src/Action/Visit/TagVisitsAction.php @@ -11,6 +11,7 @@ use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; +use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class TagVisitsAction extends AbstractRestAction { @@ -29,7 +30,9 @@ class TagVisitsAction extends AbstractRestAction public function handle(Request $request): Response { $tag = $request->getAttribute('tag', ''); - $visits = $this->visitsTracker->visitsForTag($tag, VisitsParams::fromRawData($request->getQueryParams())); + $params = VisitsParams::fromRawData($request->getQueryParams()); + $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); + $visits = $this->visitsTracker->visitsForTag($tag, $params, $apiKey); return new JsonResponse([ 'visits' => $this->serializePaginator($visits), diff --git a/module/Rest/src/ApiKey/Role.php b/module/Rest/src/ApiKey/Role.php index 1140f1ff..83a78087 100644 --- a/module/Rest/src/ApiKey/Role.php +++ b/module/Rest/src/ApiKey/Role.php @@ -7,7 +7,9 @@ namespace Shlinkio\Shlink\Rest\ApiKey; use Happyr\DoctrineSpecification\Spec; use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Core\ShortUrl\Spec\BelongsToApiKey; +use Shlinkio\Shlink\Core\ShortUrl\Spec\BelongsToApiKeyInlined; use Shlinkio\Shlink\Core\ShortUrl\Spec\BelongsToDomain; +use Shlinkio\Shlink\Core\ShortUrl\Spec\BelongsToDomainInlined; use Shlinkio\Shlink\Rest\Entity\ApiKeyRole; class Role @@ -15,14 +17,15 @@ class Role public const AUTHORED_SHORT_URLS = 'AUTHORED_SHORT_URLS'; public const DOMAIN_SPECIFIC = 'DOMAIN_SPECIFIC'; - public static function toSpec(ApiKeyRole $role): Specification + public static function toSpec(ApiKeyRole $role, bool $inlined): Specification { if ($role->name() === self::AUTHORED_SHORT_URLS) { - return new BelongsToApiKey($role->apiKey()); + return $inlined ? new BelongsToApiKeyInlined($role->apiKey()) : new BelongsToApiKey($role->apiKey()); } if ($role->name() === self::DOMAIN_SPECIFIC) { - return new BelongsToDomain($role->meta()['domain_id'] ?? -1); + $domainId = $role->meta()['domain_id'] ?? -1; + return $inlined ? new BelongsToDomainInlined($domainId) : new BelongsToDomain($domainId); } return Spec::andX(); diff --git a/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php b/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php new file mode 100644 index 00000000..6bf8034d --- /dev/null +++ b/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php @@ -0,0 +1,29 @@ +apiKey = $apiKey; + } + + protected function getSpec(): Specification + { + return $this->apiKey === null || $this->apiKey->isAdmin() ? Spec::andX() : Spec::andX( + Spec::join('shortUrls', 's'), + $this->apiKey->spec(), + ); + } +} diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index 6c122494..81c22b25 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -68,9 +68,14 @@ class ApiKey extends AbstractEntity return $this->key; } - public function spec(): Specification + public function spec(bool $inlined = false): Specification { - $specs = $this->roles->map(fn (ApiKeyRole $role) => Role::toSpec($role)); + $specs = $this->roles->map(fn (ApiKeyRole $role) => Role::toSpec($role, $inlined)); return Spec::andX(...$specs); } + + public function isAdmin(): bool + { + return $this->roles->count() === 0; + } } diff --git a/module/Rest/test/Action/Visit/TagVisitsActionTest.php b/module/Rest/test/Action/Visit/TagVisitsActionTest.php index 53dbf8f2..a7598971 100644 --- a/module/Rest/test/Action/Visit/TagVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/TagVisitsActionTest.php @@ -14,6 +14,7 @@ use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Service\VisitsTracker; use Shlinkio\Shlink\Rest\Action\Visit\TagVisitsAction; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class TagVisitsActionTest extends TestCase { @@ -32,11 +33,14 @@ class TagVisitsActionTest extends TestCase public function providingCorrectShortCodeReturnsVisits(): void { $tag = 'foo'; - $getVisits = $this->visitsTracker->visitsForTag($tag, Argument::type(VisitsParams::class))->willReturn( + $apiKey = new ApiKey(); + $getVisits = $this->visitsTracker->visitsForTag($tag, Argument::type(VisitsParams::class), $apiKey)->willReturn( new Paginator(new ArrayAdapter([])), ); - $response = $this->action->handle((new ServerRequest())->withAttribute('tag', $tag)); + $response = $this->action->handle( + (new ServerRequest())->withAttribute('tag', $tag)->withAttribute(ApiKey::class, $apiKey), + ); self::assertEquals(200, $response->getStatusCode()); $getVisits->shouldHaveBeenCalledOnce(); From 68c601a5a899dffe8388f7a46033a13bfb8f93b8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 4 Jan 2021 11:27:55 +0100 Subject: [PATCH 14/46] Applied API role specs to global visits --- module/Core/src/Repository/TagRepository.php | 2 +- module/Core/src/Repository/VisitRepository.php | 10 ++++++++++ .../Core/src/Repository/VisitRepositoryInterface.php | 3 +++ module/Core/src/Visit/VisitsStatsHelper.php | 9 +++++---- module/Core/src/Visit/VisitsStatsHelperInterface.php | 3 ++- module/Core/test/Visit/VisitsStatsHelperTest.php | 2 +- module/Rest/src/Action/Visit/GlobalVisitsAction.php | 5 ++++- .../src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php | 6 ++++-- .../Rest/test/Action/Visit/GlobalVisitsActionTest.php | 6 ++++-- 9 files changed, 34 insertions(+), 12 deletions(-) diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php index dd15c292..7cb66a6a 100644 --- a/module/Core/src/Repository/TagRepository.php +++ b/module/Core/src/Repository/TagRepository.php @@ -56,7 +56,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito { $result = (int) $this->matchSingleScalarResult(Spec::andX( new CountTagsWithName($tag), - new WithApiKeySpecsEnsuringJoin($apiKey), + new WithApiKeySpecsEnsuringJoin($apiKey, 'shortUrls'), )); return $result > 0; diff --git a/module/Core/src/Repository/VisitRepository.php b/module/Core/src/Repository/VisitRepository.php index 8e750bc3..a1df73a5 100644 --- a/module/Core/src/Repository/VisitRepository.php +++ b/module/Core/src/Repository/VisitRepository.php @@ -7,11 +7,14 @@ namespace Shlinkio\Shlink\Core\Repository; use Doctrine\ORM\Query\ResultSetMappingBuilder; use Doctrine\ORM\QueryBuilder; use Happyr\DoctrineSpecification\EntitySpecificationRepository; +use Happyr\DoctrineSpecification\Spec; use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\VisitLocation; +use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin; +use Shlinkio\Shlink\Rest\Entity\ApiKey; use const PHP_INT_MAX; @@ -205,4 +208,11 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo return $query->getResult(); } + + public function countVisits(?ApiKey $apiKey = null): int + { + return (int) $this->matchSingleScalarResult( + Spec::countOf(new WithApiKeySpecsEnsuringJoin($apiKey, 'shortUrl')), + ); + } } diff --git a/module/Core/src/Repository/VisitRepositoryInterface.php b/module/Core/src/Repository/VisitRepositoryInterface.php index 71a6c4ae..526645df 100644 --- a/module/Core/src/Repository/VisitRepositoryInterface.php +++ b/module/Core/src/Repository/VisitRepositoryInterface.php @@ -9,6 +9,7 @@ use Happyr\DoctrineSpecification\EntitySpecificationRepositoryInterface; use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\Visit; +use Shlinkio\Shlink\Rest\Entity\ApiKey; interface VisitRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface { @@ -60,4 +61,6 @@ interface VisitRepositoryInterface extends ObjectRepository, EntitySpecification ): array; public function countVisitsByTag(string $tag, ?DateRange $dateRange = null, ?Specification $spec = null): int; + + public function countVisits(?ApiKey $apiKey = null): int; } diff --git a/module/Core/src/Visit/VisitsStatsHelper.php b/module/Core/src/Visit/VisitsStatsHelper.php index de3219ff..ab06079a 100644 --- a/module/Core/src/Visit/VisitsStatsHelper.php +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -8,6 +8,7 @@ use Doctrine\ORM\EntityManagerInterface; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Repository\VisitRepository; use Shlinkio\Shlink\Core\Visit\Model\VisitsStats; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class VisitsStatsHelper implements VisitsStatsHelperInterface { @@ -18,15 +19,15 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface $this->em = $em; } - public function getVisitsStats(): VisitsStats + public function getVisitsStats(?ApiKey $apiKey = null): VisitsStats { - return new VisitsStats($this->getVisitsCount()); + return new VisitsStats($this->getVisitsCount($apiKey)); } - private function getVisitsCount(): int + private function getVisitsCount(?ApiKey $apiKey): int { /** @var VisitRepository $visitsRepo */ $visitsRepo = $this->em->getRepository(Visit::class); - return $visitsRepo->count([]); + return $visitsRepo->countVisits($apiKey); } } diff --git a/module/Core/src/Visit/VisitsStatsHelperInterface.php b/module/Core/src/Visit/VisitsStatsHelperInterface.php index 81423cb0..ca044d4b 100644 --- a/module/Core/src/Visit/VisitsStatsHelperInterface.php +++ b/module/Core/src/Visit/VisitsStatsHelperInterface.php @@ -5,8 +5,9 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Visit; use Shlinkio\Shlink\Core\Visit\Model\VisitsStats; +use Shlinkio\Shlink\Rest\Entity\ApiKey; interface VisitsStatsHelperInterface { - public function getVisitsStats(): VisitsStats; + public function getVisitsStats(?ApiKey $apiKey = null): VisitsStats; } diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php index 2381a73a..cdc76bd4 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -36,7 +36,7 @@ class VisitsStatsHelperTest extends TestCase public function returnsExpectedVisitsStats(int $expectedCount): void { $repo = $this->prophesize(VisitRepository::class); - $count = $repo->count([])->willReturn($expectedCount); + $count = $repo->countVisits(null)->willReturn($expectedCount); $getRepo = $this->em->getRepository(Visit::class)->willReturn($repo->reveal()); $stats = $this->helper->getVisitsStats(); diff --git a/module/Rest/src/Action/Visit/GlobalVisitsAction.php b/module/Rest/src/Action/Visit/GlobalVisitsAction.php index a27412b2..4810b100 100644 --- a/module/Rest/src/Action/Visit/GlobalVisitsAction.php +++ b/module/Rest/src/Action/Visit/GlobalVisitsAction.php @@ -9,6 +9,7 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; +use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class GlobalVisitsAction extends AbstractRestAction { @@ -24,8 +25,10 @@ class GlobalVisitsAction extends AbstractRestAction public function handle(ServerRequestInterface $request): ResponseInterface { + $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); + return new JsonResponse([ - 'visits' => $this->statsHelper->getVisitsStats(), + 'visits' => $this->statsHelper->getVisitsStats($apiKey), ]); } } diff --git a/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php b/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php index 6bf8034d..04ed9565 100644 --- a/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php +++ b/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php @@ -12,17 +12,19 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class WithApiKeySpecsEnsuringJoin extends BaseSpecification { private ?ApiKey $apiKey; + private string $fieldToJoin; - public function __construct(?ApiKey $apiKey) + public function __construct(?ApiKey $apiKey, string $fieldToJoin) { parent::__construct(); $this->apiKey = $apiKey; + $this->fieldToJoin = $fieldToJoin; } protected function getSpec(): Specification { return $this->apiKey === null || $this->apiKey->isAdmin() ? Spec::andX() : Spec::andX( - Spec::join('shortUrls', 's'), + Spec::join($this->fieldToJoin, 's'), $this->apiKey->spec(), ); } diff --git a/module/Rest/test/Action/Visit/GlobalVisitsActionTest.php b/module/Rest/test/Action/Visit/GlobalVisitsActionTest.php index 6b91ba56..6e3ab1e4 100644 --- a/module/Rest/test/Action/Visit/GlobalVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/GlobalVisitsActionTest.php @@ -12,6 +12,7 @@ use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Visit\Model\VisitsStats; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\Rest\Action\Visit\GlobalVisitsAction; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class GlobalVisitsActionTest extends TestCase { @@ -29,11 +30,12 @@ class GlobalVisitsActionTest extends TestCase /** @test */ public function statsAreReturnedFromHelper(): void { + $apiKey = new ApiKey(); $stats = new VisitsStats(5); - $getStats = $this->helper->getVisitsStats()->willReturn($stats); + $getStats = $this->helper->getVisitsStats($apiKey)->willReturn($stats); /** @var JsonResponse $resp */ - $resp = $this->action->handle(ServerRequestFactory::fromGlobals()); + $resp = $this->action->handle(ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, $apiKey)); $payload = $resp->getPayload(); self::assertEquals($payload, ['visits' => $stats]); From 24f7fb9c4f676f05aae880ff3e8ad0f319742428 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 4 Jan 2021 12:44:29 +0100 Subject: [PATCH 15/46] Applied API role specs to tags list without stats --- docs/swagger/paths/v1_short-urls.json | 2 +- module/Core/src/Repository/TagRepository.php | 2 +- module/Core/src/Tag/TagService.php | 11 +++++++++-- module/Core/src/Tag/TagServiceInterface.php | 2 +- module/Core/test/Service/Tag/TagServiceTest.php | 4 ++-- module/Rest/src/Action/Tag/ListTagsAction.php | 4 ++-- .../ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php | 2 +- .../Rest/test/Action/Tag/ListTagsActionTest.php | 17 +++++++++++------ 8 files changed, 28 insertions(+), 16 deletions(-) diff --git a/docs/swagger/paths/v1_short-urls.json b/docs/swagger/paths/v1_short-urls.json index a89dd187..a81853d8 100644 --- a/docs/swagger/paths/v1_short-urls.json +++ b/docs/swagger/paths/v1_short-urls.json @@ -191,7 +191,7 @@ "Short URLs" ], "summary": "Create short URL", - "description": "Creates a new short URL.

**Param findIfExists:**: Starting with v1.16, this new param allows to force shlink to return existing short URLs when found based on provided params, instead of creating a new one. However, it might add complexity and have unexpected outputs.\n\nThese are the use cases:\n* Only the long URL is provided: It will return the newest match or create a new short URL if none is found.\n* Long url and custom slug are provided: It will return the short URL when both params match, return an error when the slug is in use for another long URL, or create a new short URL otherwise.\n* Any of the above but including other params (tags, validSince, validUntil, maxVisits): It will behave the same as the previous two cases, but it will try to exactly match existing results using all the params. If any of them does not match, it will try to create a new short URL.", + "description": "Creates a new short URL.

**Param findIfExists**: This new param allows to force shlink to return existing short URLs when found based on provided params, instead of creating a new one. However, it might add complexity and have unexpected outputs.\n\nThese are the use cases:\n* Only the long URL is provided: It will return the newest match or create a new short URL if none is found.\n* Long url and custom slug are provided: It will return the short URL when both params match, return an error when the slug is in use for another long URL, or create a new short URL otherwise.\n* Any of the above but including other params (tags, validSince, validUntil, maxVisits): It will behave the same as the previous two cases, but it will try to exactly match existing results using all the params. If any of them does not match, it will try to create a new short URL.", "security": [ { "ApiKey": [] diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php index 7cb66a6a..dd15c292 100644 --- a/module/Core/src/Repository/TagRepository.php +++ b/module/Core/src/Repository/TagRepository.php @@ -56,7 +56,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito { $result = (int) $this->matchSingleScalarResult(Spec::andX( new CountTagsWithName($tag), - new WithApiKeySpecsEnsuringJoin($apiKey, 'shortUrls'), + new WithApiKeySpecsEnsuringJoin($apiKey), )); return $result > 0; diff --git a/module/Core/src/Tag/TagService.php b/module/Core/src/Tag/TagService.php index 786e102d..342c5d31 100644 --- a/module/Core/src/Tag/TagService.php +++ b/module/Core/src/Tag/TagService.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Tag; use Doctrine\Common\Collections\Collection; use Doctrine\ORM; +use Happyr\DoctrineSpecification\Spec; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; @@ -13,6 +14,7 @@ use Shlinkio\Shlink\Core\Repository\TagRepository; use Shlinkio\Shlink\Core\Repository\TagRepositoryInterface; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Util\TagManagerTrait; +use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin; use Shlinkio\Shlink\Rest\Entity\ApiKey; class TagService implements TagServiceInterface @@ -29,10 +31,15 @@ class TagService implements TagServiceInterface /** * @return Tag[] */ - public function listTags(): array + public function listTags(?ApiKey $apiKey = null): array { + /** @var TagRepository $repo */ + $repo = $this->em->getRepository(Tag::class); /** @var Tag[] $tags */ - $tags = $this->em->getRepository(Tag::class)->findBy([], ['name' => 'ASC']); + $tags = $repo->match(Spec::andX( + Spec::orderBy('name'), + new WithApiKeySpecsEnsuringJoin($apiKey), + )); return $tags; } diff --git a/module/Core/src/Tag/TagServiceInterface.php b/module/Core/src/Tag/TagServiceInterface.php index bd96a225..698736a6 100644 --- a/module/Core/src/Tag/TagServiceInterface.php +++ b/module/Core/src/Tag/TagServiceInterface.php @@ -16,7 +16,7 @@ interface TagServiceInterface /** * @return Tag[] */ - public function listTags(): array; + public function listTags(?ApiKey $apiKey = null): array; /** * @return TagInfo[] diff --git a/module/Core/test/Service/Tag/TagServiceTest.php b/module/Core/test/Service/Tag/TagServiceTest.php index c4203c85..a66fdc6f 100644 --- a/module/Core/test/Service/Tag/TagServiceTest.php +++ b/module/Core/test/Service/Tag/TagServiceTest.php @@ -38,12 +38,12 @@ class TagServiceTest extends TestCase { $expected = [new Tag('foo'), new Tag('bar')]; - $find = $this->repo->findBy(Argument::cetera())->willReturn($expected); + $match = $this->repo->match(Argument::cetera())->willReturn($expected); $result = $this->service->listTags(); self::assertEquals($expected, $result); - $find->shouldHaveBeenCalled(); + $match->shouldHaveBeenCalled(); } /** @test */ diff --git a/module/Rest/src/Action/Tag/ListTagsAction.php b/module/Rest/src/Action/Tag/ListTagsAction.php index 64ddad33..48cf923b 100644 --- a/module/Rest/src/Action/Tag/ListTagsAction.php +++ b/module/Rest/src/Action/Tag/ListTagsAction.php @@ -30,16 +30,16 @@ class ListTagsAction extends AbstractRestAction { $query = $request->getQueryParams(); $withStats = ($query['withStats'] ?? null) === 'true'; + $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); if (! $withStats) { return new JsonResponse([ 'tags' => [ - 'data' => $this->tagService->listTags(), + 'data' => $this->tagService->listTags($apiKey), ], ]); } - $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); $tagsInfo = $this->tagService->tagsInfo($apiKey); $data = map($tagsInfo, fn (TagInfo $info) => (string) $info->tag()); diff --git a/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php b/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php index 04ed9565..64359d15 100644 --- a/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php +++ b/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php @@ -14,7 +14,7 @@ class WithApiKeySpecsEnsuringJoin extends BaseSpecification private ?ApiKey $apiKey; private string $fieldToJoin; - public function __construct(?ApiKey $apiKey, string $fieldToJoin) + public function __construct(?ApiKey $apiKey, string $fieldToJoin = 'shortUrls') { parent::__construct(); $this->apiKey = $apiKey; diff --git a/module/Rest/test/Action/Tag/ListTagsActionTest.php b/module/Rest/test/Action/Tag/ListTagsActionTest.php index c8b65a48..9bdad15b 100644 --- a/module/Rest/test/Action/Tag/ListTagsActionTest.php +++ b/module/Rest/test/Action/Tag/ListTagsActionTest.php @@ -7,8 +7,10 @@ namespace ShlinkioTest\Shlink\Rest\Action\Tag; use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\TestCase; +use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; +use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; @@ -35,10 +37,10 @@ class ListTagsActionTest extends TestCase public function returnsBaseDataWhenStatsAreNotRequested(array $query): void { $tags = [new Tag('foo'), new Tag('bar')]; - $listTags = $this->tagService->listTags()->willReturn($tags); + $listTags = $this->tagService->listTags(Argument::type(ApiKey::class))->willReturn($tags); /** @var JsonResponse $resp */ - $resp = $this->action->handle(ServerRequestFactory::fromGlobals()->withQueryParams($query)); + $resp = $this->action->handle($this->requestWithApiKey()->withQueryParams($query)); $payload = $resp->getPayload(); self::assertEquals([ @@ -63,10 +65,8 @@ class ListTagsActionTest extends TestCase new TagInfo(new Tag('foo'), 1, 1), new TagInfo(new Tag('bar'), 3, 10), ]; - $apiKey = new ApiKey(); - $tagsInfo = $this->tagService->tagsInfo($apiKey)->willReturn($stats); - $req = ServerRequestFactory::fromGlobals()->withQueryParams(['withStats' => 'true']) - ->withAttribute(ApiKey::class, $apiKey); + $tagsInfo = $this->tagService->tagsInfo(Argument::type(ApiKey::class))->willReturn($stats); + $req = $this->requestWithApiKey()->withQueryParams(['withStats' => 'true']); /** @var JsonResponse $resp */ $resp = $this->action->handle($req); @@ -80,4 +80,9 @@ class ListTagsActionTest extends TestCase ], $payload); $tagsInfo->shouldHaveBeenCalled(); } + + private function requestWithApiKey(): ServerRequestInterface + { + return ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, new ApiKey()); + } } From 29cdfaed39b0305b6fdfe7b5c64585d81916d729 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 4 Jan 2021 13:32:44 +0100 Subject: [PATCH 16/46] Changed ShortUrlMeta so that it expects an ApiKey instance instead of the key as string --- module/Core/src/Entity/ShortUrl.php | 2 +- module/Core/src/Model/ShortUrlMeta.php | 5 +-- .../PersistenceShortUrlRelationResolver.php | 12 ------- .../ShortUrlRelationResolverInterface.php | 3 -- .../SimpleShortUrlRelationResolver.php | 6 ---- .../Validation/ShortUrlMetaInputFilter.php | 7 +++- ...ersistenceShortUrlRelationResolverTest.php | 35 ------------------- .../SimpleShortUrlRelationResolverTest.php | 15 -------- .../Action/ShortUrl/CreateShortUrlAction.php | 2 +- .../SingleStepCreateShortUrlAction.php | 2 +- .../ShortUrl/CreateShortUrlActionTest.php | 2 +- .../SingleStepCreateShortUrlActionTest.php | 6 ++-- 12 files changed, 16 insertions(+), 81 deletions(-) diff --git a/module/Core/src/Entity/ShortUrl.php b/module/Core/src/Entity/ShortUrl.php index 6f7493aa..67d41136 100644 --- a/module/Core/src/Entity/ShortUrl.php +++ b/module/Core/src/Entity/ShortUrl.php @@ -59,7 +59,7 @@ class ShortUrl extends AbstractEntity $this->shortCodeLength = $meta->getShortCodeLength(); $this->shortCode = $meta->getCustomSlug() ?? generateRandomShortCode($this->shortCodeLength); $this->domain = $relationResolver->resolveDomain($meta->getDomain()); - $this->authorApiKey = $relationResolver->resolveApiKey($meta->getApiKey()); + $this->authorApiKey = $meta->getApiKey(); } public static function fromImport( diff --git a/module/Core/src/Model/ShortUrlMeta.php b/module/Core/src/Model/ShortUrlMeta.php index fa82919e..0df792be 100644 --- a/module/Core/src/Model/ShortUrlMeta.php +++ b/module/Core/src/Model/ShortUrlMeta.php @@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\Model; use Cake\Chronos\Chronos; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter; +use Shlinkio\Shlink\Rest\Entity\ApiKey; use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter; use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter; @@ -24,7 +25,7 @@ final class ShortUrlMeta private ?string $domain = null; private int $shortCodeLength = 5; private ?bool $validateUrl = null; - private ?string $apiKey = null; + private ?ApiKey $apiKey = null; // Enforce named constructors private function __construct() @@ -135,7 +136,7 @@ final class ShortUrlMeta return $this->validateUrl; } - public function getApiKey(): ?string + public function getApiKey(): ?ApiKey { return $this->apiKey; } diff --git a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php index d898fb37..0e3afa23 100644 --- a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php +++ b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php @@ -6,7 +6,6 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Resolver; use Doctrine\ORM\EntityManagerInterface; use Shlinkio\Shlink\Core\Entity\Domain; -use Shlinkio\Shlink\Rest\Entity\ApiKey; class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInterface { @@ -27,15 +26,4 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt $existingDomain = $this->em->getRepository(Domain::class)->findOneBy(['authority' => $domain]); return $existingDomain ?? new Domain($domain); } - - public function resolveApiKey(?string $key): ?ApiKey - { - if ($key === null) { - return null; - } - - /** @var ApiKey|null $existingApiKey */ - $existingApiKey = $this->em->getRepository(ApiKey::class)->findOneBy(['key' => $key]); - return $existingApiKey; - } } diff --git a/module/Core/src/ShortUrl/Resolver/ShortUrlRelationResolverInterface.php b/module/Core/src/ShortUrl/Resolver/ShortUrlRelationResolverInterface.php index 0a708cf6..bc576dbd 100644 --- a/module/Core/src/ShortUrl/Resolver/ShortUrlRelationResolverInterface.php +++ b/module/Core/src/ShortUrl/Resolver/ShortUrlRelationResolverInterface.php @@ -5,11 +5,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Resolver; use Shlinkio\Shlink\Core\Entity\Domain; -use Shlinkio\Shlink\Rest\Entity\ApiKey; interface ShortUrlRelationResolverInterface { public function resolveDomain(?string $domain): ?Domain; - - public function resolveApiKey(?string $key): ?ApiKey; } diff --git a/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php b/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php index 9de156ee..4e4620f5 100644 --- a/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php +++ b/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Resolver; use Shlinkio\Shlink\Core\Entity\Domain; -use Shlinkio\Shlink\Rest\Entity\ApiKey; class SimpleShortUrlRelationResolver implements ShortUrlRelationResolverInterface { @@ -13,9 +12,4 @@ class SimpleShortUrlRelationResolver implements ShortUrlRelationResolverInterfac { return $domain !== null ? new Domain($domain) : null; } - - public function resolveApiKey(?string $key): ?ApiKey - { - return null; - } } diff --git a/module/Core/src/Validation/ShortUrlMetaInputFilter.php b/module/Core/src/Validation/ShortUrlMetaInputFilter.php index 9d3f8ec5..ca29ad14 100644 --- a/module/Core/src/Validation/ShortUrlMetaInputFilter.php +++ b/module/Core/src/Validation/ShortUrlMetaInputFilter.php @@ -11,6 +11,7 @@ use Laminas\InputFilter\InputFilter; use Laminas\Validator; use Shlinkio\Shlink\Common\Validation; use Shlinkio\Shlink\Core\Util\CocurSymfonySluggerBridge; +use Shlinkio\Shlink\Rest\Entity\ApiKey; use const Shlinkio\Shlink\Core\CUSTOM_SLUGS_REGEXP; use const Shlinkio\Shlink\Core\MIN_SHORT_CODES_LENGTH; @@ -73,7 +74,11 @@ class ShortUrlMetaInputFilter extends InputFilter $domain->getValidatorChain()->attach(new Validation\HostAndPortValidator()); $this->add($domain); - $this->add($this->createInput(self::API_KEY, false)); + $apiKeyInput = new Input(self::API_KEY); + $apiKeyInput + ->setRequired(false) + ->getValidatorChain()->attach(new Validator\IsInstanceOf(['className' => ApiKey::class])); + $this->add($apiKeyInput); } private function createPositiveNumberInput(string $name, int $min = 1): Input diff --git a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php index 5791d579..9cea7883 100644 --- a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php +++ b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php @@ -11,7 +11,6 @@ use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; -use Shlinkio\Shlink\Rest\Entity\ApiKey; class PersistenceShortUrlRelationResolverTest extends TestCase { @@ -63,38 +62,4 @@ class PersistenceShortUrlRelationResolverTest extends TestCase yield 'not found domain' => [null, $authority]; yield 'found domain' => [new Domain($authority), $authority]; } - - /** @test */ - public function returnsEmptyWhenNoApiKeyIsProvided(): void - { - $getRepository = $this->em->getRepository(ApiKey::class); - - self::assertNull($this->resolver->resolveApiKey(null)); - $getRepository->shouldNotHaveBeenCalled(); - } - - /** - * @test - * @dataProvider provideFoundApiKeys - */ - public function triesToFindApiKeyWhenValueIsProvided(?ApiKey $foundApiKey, string $key): void - { - $repo = $this->prophesize(ObjectRepository::class); - $find = $repo->findOneBy(['key' => $key])->willReturn($foundApiKey); - $getRepository = $this->em->getRepository(ApiKey::class)->willReturn($repo->reveal()); - - $result = $this->resolver->resolveApiKey($key); - - self::assertSame($result, $foundApiKey); - $find->shouldHaveBeenCalledOnce(); - $getRepository->shouldHaveBeenCalledOnce(); - } - - public function provideFoundApiKeys(): iterable - { - $key = 'abc123'; - - yield 'not found api key' => [null, $key]; - yield 'found api key' => [new ApiKey(), $key]; - } } diff --git a/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php b/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php index e2d0822c..84d838b9 100644 --- a/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php +++ b/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php @@ -38,19 +38,4 @@ class SimpleShortUrlRelationResolverTest extends TestCase yield 'empty domain' => [null]; yield 'non-empty domain' => ['domain.com']; } - - /** - * @test - * @dataProvider provideKeys - */ - public function alwaysReturnsNullForApiKeys(?string $key): void - { - self::assertNull($this->resolver->resolveApiKey($key)); - } - - public function provideKeys(): iterable - { - yield 'empty api key' => [null]; - yield 'non-empty api key' => ['abc123']; - } } diff --git a/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php index b3db6460..28941579 100644 --- a/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php @@ -28,7 +28,7 @@ class CreateShortUrlAction extends AbstractCreateShortUrlAction ]); } - $payload[ShortUrlMetaInputFilter::API_KEY] = AuthenticationMiddleware::apiKeyFromRequest($request)->toString(); + $payload[ShortUrlMetaInputFilter::API_KEY] = AuthenticationMiddleware::apiKeyFromRequest($request); $meta = ShortUrlMeta::fromRawData($payload); return new CreateShortUrlData($payload['longUrl'], (array) ($payload['tags'] ?? []), $meta); diff --git a/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php index 996e59a6..cbb06386 100644 --- a/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php @@ -50,7 +50,7 @@ class SingleStepCreateShortUrlAction extends AbstractCreateShortUrlAction } return new CreateShortUrlData($longUrl, [], ShortUrlMeta::fromRawData([ - ShortUrlMetaInputFilter::API_KEY => $apiKeyResult->apiKey()->toString(), + ShortUrlMetaInputFilter::API_KEY => $apiKeyResult->apiKey(), ])); } } diff --git a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php index 082b1783..80ccfc17 100644 --- a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php @@ -53,7 +53,7 @@ class CreateShortUrlActionTest extends TestCase { $apiKey = new ApiKey(); $shortUrl = new ShortUrl(''); - $expectedMeta['apiKey'] = $apiKey->toString(); + $expectedMeta['apiKey'] = $apiKey; $shorten = $this->urlShortener->shorten( Argument::type('string'), diff --git a/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php index eb1d6cd2..b42b95fb 100644 --- a/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php @@ -78,12 +78,12 @@ class SingleStepCreateShortUrlActionTest extends TestCase ]); $findApiKey = $this->apiKeyService->check($key)->willReturn(new ApiKeyCheckResult($apiKey)); $generateShortCode = $this->urlShortener->shorten( - Argument::that(function (string $argument): string { + Argument::that(function (string $argument): bool { Assert::assertEquals('http://foobar.com', $argument); - return $argument; + return true; }), [], - ShortUrlMeta::fromRawData(['apiKey' => $key]), + ShortUrlMeta::fromRawData(['apiKey' => $apiKey]), )->willReturn(new ShortUrl('')); $resp = $this->action->handle($request); From 364be2420bb086305cdd779ff77f0567b69aa7d2 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 4 Jan 2021 13:54:38 +0100 Subject: [PATCH 17/46] Applied API role specs to short URL creation when findIfExists is provided --- module/Core/src/Repository/ShortUrlRepository.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/module/Core/src/Repository/ShortUrlRepository.php b/module/Core/src/Repository/ShortUrlRepository.php index d4bb1d16..ddfaa189 100644 --- a/module/Core/src/Repository/ShortUrlRepository.php +++ b/module/Core/src/Repository/ShortUrlRepository.php @@ -234,6 +234,11 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU ->setParameter('domain', $meta->getDomain()); } + $apiKey = $meta->getApiKey(); + if ($apiKey !== null) { + $this->applySpecification($qb, $apiKey->spec(), 's'); + } + $tagsAmount = count($tags); if ($tagsAmount === 0) { return $qb->getQuery()->getOneOrNullResult(); From a01e0ba337d9c4f5622777b4e0e6377d4505c104 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 4 Jan 2021 15:02:37 +0100 Subject: [PATCH 18/46] Changed logic to list domains to centralize conditions in service --- module/CLI/config/dependencies.config.php | 2 +- .../src/Command/Domain/ListDomainsCommand.php | 16 ++++---- .../Command/Domain/ListDomainsCommandTest.php | 11 +++--- module/Core/config/dependencies.config.php | 2 +- module/Core/src/Domain/DomainService.php | 18 +++++++-- .../src/Domain/DomainServiceInterface.php | 6 +-- module/Core/src/Domain/Model/DomainItem.php | 37 +++++++++++++++++++ module/Core/test/Domain/DomainServiceTest.php | 21 +++++++---- module/Rest/config/dependencies.config.php | 2 +- .../src/Action/Domain/ListDomainsAction.php | 22 ++--------- .../Action/Domain/ListDomainsActionTest.php | 26 ++++--------- 11 files changed, 93 insertions(+), 70 deletions(-) create mode 100644 module/Core/src/Domain/Model/DomainItem.php diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index 199d29ef..313d0022 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -87,7 +87,7 @@ return [ Command\Tag\RenameTagCommand::class => [TagService::class], Command\Tag\DeleteTagsCommand::class => [TagService::class], - Command\Domain\ListDomainsCommand::class => [DomainService::class, 'config.url_shortener.domain.hostname'], + Command\Domain\ListDomainsCommand::class => [DomainService::class], Command\Db\CreateDatabaseCommand::class => [ LockFactory::class, diff --git a/module/CLI/src/Command/Domain/ListDomainsCommand.php b/module/CLI/src/Command/Domain/ListDomainsCommand.php index 0368f1dd..b05ad429 100644 --- a/module/CLI/src/Command/Domain/ListDomainsCommand.php +++ b/module/CLI/src/Command/Domain/ListDomainsCommand.php @@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\CLI\Command\Domain; use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; -use Shlinkio\Shlink\Core\Entity\Domain; +use Shlinkio\Shlink\Core\Domain\Model\DomainItem; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -19,13 +19,11 @@ class ListDomainsCommand extends Command public const NAME = 'domain:list'; private DomainServiceInterface $domainService; - private string $defaultDomain; - public function __construct(DomainServiceInterface $domainService, string $defaultDomain) + public function __construct(DomainServiceInterface $domainService) { parent::__construct(); $this->domainService = $domainService; - $this->defaultDomain = $defaultDomain; } protected function configure(): void @@ -37,12 +35,12 @@ class ListDomainsCommand extends Command protected function execute(InputInterface $input, OutputInterface $output): ?int { - $regularDomains = $this->domainService->listDomainsWithout($this->defaultDomain); + $regularDomains = $this->domainService->listDomainsWithout(); - ShlinkTable::fromOutput($output)->render(['Domain', 'Is default'], [ - [$this->defaultDomain, 'Yes'], - ...map($regularDomains, fn (Domain $domain) => [$domain->getAuthority(), 'No']), - ]); + ShlinkTable::fromOutput($output)->render( + ['Domain', 'Is default'], + map($regularDomains, fn (DomainItem $domain) => [$domain->toString(), $domain->isDefault() ? 'Yes' : 'No']), + ); return ExitCodes::EXIT_SUCCESS; } diff --git a/module/CLI/test/Command/Domain/ListDomainsCommandTest.php b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php index 500fed7f..05e35c0b 100644 --- a/module/CLI/test/Command/Domain/ListDomainsCommandTest.php +++ b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php @@ -10,7 +10,7 @@ use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Domain\ListDomainsCommand; use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; -use Shlinkio\Shlink\Core\Entity\Domain; +use Shlinkio\Shlink\Core\Domain\Model\DomainItem; use Symfony\Component\Console\Application; use Symfony\Component\Console\Tester\CommandTester; @@ -25,7 +25,7 @@ class ListDomainsCommandTest extends TestCase { $this->domainService = $this->prophesize(DomainServiceInterface::class); - $command = new ListDomainsCommand($this->domainService->reveal(), 'foo.com'); + $command = new ListDomainsCommand($this->domainService->reveal()); $app = new Application(); $app->add($command); @@ -45,9 +45,10 @@ class ListDomainsCommandTest extends TestCase +---------+------------+ OUTPUT; - $listDomains = $this->domainService->listDomainsWithout('foo.com')->willReturn([ - new Domain('bar.com'), - new Domain('baz.com'), + $listDomains = $this->domainService->listDomainsWithout()->willReturn([ + new DomainItem('foo.com', true), + new DomainItem('bar.com', false), + new DomainItem('baz.com', false), ]); $this->commandTester->execute([]); diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 94b5858a..a843a0a2 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -88,7 +88,7 @@ return [ ], Service\ShortUrl\ShortUrlResolver::class => ['em'], Service\ShortUrl\ShortCodeHelper::class => ['em'], - Domain\DomainService::class => ['em'], + Domain\DomainService::class => ['em', 'config.url_shortener.domain.hostname'], Util\UrlValidator::class => ['httpClient', Options\UrlShortenerOptions::class], Util\DoctrineBatchHelper::class => ['em'], diff --git a/module/Core/src/Domain/DomainService.php b/module/Core/src/Domain/DomainService.php index d7575361..12211208 100644 --- a/module/Core/src/Domain/DomainService.php +++ b/module/Core/src/Domain/DomainService.php @@ -5,25 +5,35 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Domain; use Doctrine\ORM\EntityManagerInterface; +use Shlinkio\Shlink\Core\Domain\Model\DomainItem; use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface; use Shlinkio\Shlink\Core\Entity\Domain; +use function Functional\map; + class DomainService implements DomainServiceInterface { private EntityManagerInterface $em; + private string $defaultDomain; - public function __construct(EntityManagerInterface $em) + public function __construct(EntityManagerInterface $em, string $defaultDomain) { $this->em = $em; + $this->defaultDomain = $defaultDomain; } /** - * @return Domain[] + * @return DomainItem[] */ - public function listDomainsWithout(?string $excludeDomain = null): array + public function listDomainsWithout(): array { /** @var DomainRepositoryInterface $repo */ $repo = $this->em->getRepository(Domain::class); - return $repo->findDomainsWithout($excludeDomain); + $domains = $repo->findDomainsWithout($this->defaultDomain); + + return [ + new DomainItem($this->defaultDomain, true), + ...map($domains, fn (Domain $domain) => new DomainItem($domain->getAuthority(), false)), + ]; } } diff --git a/module/Core/src/Domain/DomainServiceInterface.php b/module/Core/src/Domain/DomainServiceInterface.php index 3e56c69c..2cff914f 100644 --- a/module/Core/src/Domain/DomainServiceInterface.php +++ b/module/Core/src/Domain/DomainServiceInterface.php @@ -4,12 +4,12 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Domain; -use Shlinkio\Shlink\Core\Entity\Domain; +use Shlinkio\Shlink\Core\Domain\Model\DomainItem; interface DomainServiceInterface { /** - * @return Domain[] + * @return DomainItem[] */ - public function listDomainsWithout(?string $excludeDomain = null): array; + public function listDomainsWithout(): array; } diff --git a/module/Core/src/Domain/Model/DomainItem.php b/module/Core/src/Domain/Model/DomainItem.php new file mode 100644 index 00000000..4006b186 --- /dev/null +++ b/module/Core/src/Domain/Model/DomainItem.php @@ -0,0 +1,37 @@ +domain = $domain; + $this->isDefault = $isDefault; + } + + public function jsonSerialize(): array + { + return [ + 'domain' => $this->domain, + 'isDefault' => $this->isDefault, + ]; + } + + public function toString(): string + { + return $this->domain; + } + + public function isDefault(): bool + { + return $this->isDefault; + } +} diff --git a/module/Core/test/Domain/DomainServiceTest.php b/module/Core/test/Domain/DomainServiceTest.php index 906088ea..4192745f 100644 --- a/module/Core/test/Domain/DomainServiceTest.php +++ b/module/Core/test/Domain/DomainServiceTest.php @@ -9,6 +9,7 @@ use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Domain\DomainService; +use Shlinkio\Shlink\Core\Domain\Model\DomainItem; use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface; use Shlinkio\Shlink\Core\Entity\Domain; @@ -22,20 +23,20 @@ class DomainServiceTest extends TestCase public function setUp(): void { $this->em = $this->prophesize(EntityManagerInterface::class); - $this->domainService = new DomainService($this->em->reveal()); + $this->domainService = new DomainService($this->em->reveal(), 'default.com'); } /** * @test * @dataProvider provideExcludedDomains */ - public function listDomainsWithoutDelegatesIntoRepository(?string $excludedDomain, array $expectedResult): void + public function listDomainsWithoutDelegatesIntoRepository(array $domains, array $expectedResult): void { $repo = $this->prophesize(DomainRepositoryInterface::class); $getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal()); - $findDomains = $repo->findDomainsWithout($excludedDomain)->willReturn($expectedResult); + $findDomains = $repo->findDomainsWithout('default.com')->willReturn($domains); - $result = $this->domainService->listDomainsWithout($excludedDomain); + $result = $this->domainService->listDomainsWithout(); self::assertEquals($expectedResult, $result); $getRepo->shouldHaveBeenCalledOnce(); @@ -44,9 +45,13 @@ class DomainServiceTest extends TestCase public function provideExcludedDomains(): iterable { - yield 'no excluded domain' => [null, []]; - yield 'foo.com excluded domain' => ['foo.com', []]; - yield 'bar.com excluded domain' => ['bar.com', [new Domain('bar.com')]]; - yield 'baz.com excluded domain' => ['baz.com', [new Domain('foo.com'), new Domain('bar.com')]]; + $default = new DomainItem('default.com', true); + + yield 'empty list' => [[], [$default]]; + yield 'one item' => [[new Domain('bar.com')], [$default, new DomainItem('bar.com', false)]]; + yield 'multiple items' => [ + [new Domain('foo.com'), new Domain('bar.com')], + [$default, new DomainItem('foo.com', false), new DomainItem('bar.com', false)], + ]; } } diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index 8c1cdb8e..6f94556d 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -74,7 +74,7 @@ return [ Action\Tag\DeleteTagsAction::class => [TagService::class], Action\Tag\CreateTagsAction::class => [TagService::class], Action\Tag\UpdateTagAction::class => [TagService::class], - Action\Domain\ListDomainsAction::class => [DomainService::class, 'config.url_shortener.domain.hostname'], + Action\Domain\ListDomainsAction::class => [DomainService::class], Middleware\CrossDomainMiddleware::class => ['config.cors'], Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ['config.url_shortener.domain.hostname'], diff --git a/module/Rest/src/Action/Domain/ListDomainsAction.php b/module/Rest/src/Action/Domain/ListDomainsAction.php index 7362123a..b3bb10bd 100644 --- a/module/Rest/src/Action/Domain/ListDomainsAction.php +++ b/module/Rest/src/Action/Domain/ListDomainsAction.php @@ -8,44 +8,28 @@ use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; -use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; -use function Functional\map; - class ListDomainsAction extends AbstractRestAction { protected const ROUTE_PATH = '/domains'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; private DomainServiceInterface $domainService; - private string $defaultDomain; - public function __construct(DomainServiceInterface $domainService, string $defaultDomain) + public function __construct(DomainServiceInterface $domainService) { $this->domainService = $domainService; - $this->defaultDomain = $defaultDomain; } public function handle(ServerRequestInterface $request): ResponseInterface { - $regularDomains = $this->domainService->listDomainsWithout($this->defaultDomain); + $domainItems = $this->domainService->listDomainsWithout(); return new JsonResponse([ 'domains' => [ - 'data' => [ - $this->mapDomain($this->defaultDomain, true), - ...map($regularDomains, fn (Domain $domain) => $this->mapDomain($domain->getAuthority())), - ], + 'data' => $domainItems, ], ]); } - - private function mapDomain(string $domain, bool $isDefault = false): array - { - return [ - 'domain' => $domain, - 'isDefault' => $isDefault, - ]; - } } diff --git a/module/Rest/test/Action/Domain/ListDomainsActionTest.php b/module/Rest/test/Action/Domain/ListDomainsActionTest.php index 6750d105..940bb2fb 100644 --- a/module/Rest/test/Action/Domain/ListDomainsActionTest.php +++ b/module/Rest/test/Action/Domain/ListDomainsActionTest.php @@ -10,7 +10,7 @@ use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; -use Shlinkio\Shlink\Core\Entity\Domain; +use Shlinkio\Shlink\Core\Domain\Model\DomainItem; use Shlinkio\Shlink\Rest\Action\Domain\ListDomainsAction; class ListDomainsActionTest extends TestCase @@ -29,10 +29,11 @@ class ListDomainsActionTest extends TestCase /** @test */ public function domainsAreProperlyListed(): void { - $listDomains = $this->domainService->listDomainsWithout('foo.com')->willReturn([ - new Domain('bar.com'), - new Domain('baz.com'), - ]); + $domains = [ + new DomainItem('bar.com', true), + new DomainItem('baz.com', false), + ]; + $listDomains = $this->domainService->listDomainsWithout()->willReturn($domains); /** @var JsonResponse $resp */ $resp = $this->action->handle(ServerRequestFactory::fromGlobals()); @@ -40,20 +41,7 @@ class ListDomainsActionTest extends TestCase self::assertEquals([ 'domains' => [ - 'data' => [ - [ - 'domain' => 'foo.com', - 'isDefault' => true, - ], - [ - 'domain' => 'bar.com', - 'isDefault' => false, - ], - [ - 'domain' => 'baz.com', - 'isDefault' => false, - ], - ], + 'data' => $domains, ], ], $payload); $listDomains->shouldHaveBeenCalledOnce(); From 262a06f6243c5a86be4a7c24f0221d5b031eaba1 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 4 Jan 2021 15:16:51 +0100 Subject: [PATCH 19/46] Renamed method to be more consistent to what it actually does --- module/CLI/src/Command/Domain/ListDomainsCommand.php | 4 ++-- module/CLI/test/Command/Domain/ListDomainsCommandTest.php | 2 +- module/Core/src/Domain/DomainService.php | 2 +- module/Core/src/Domain/DomainServiceInterface.php | 2 +- module/Core/test/Domain/DomainServiceTest.php | 4 ++-- module/Rest/src/Action/Domain/ListDomainsAction.php | 2 +- module/Rest/test/Action/Domain/ListDomainsActionTest.php | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/module/CLI/src/Command/Domain/ListDomainsCommand.php b/module/CLI/src/Command/Domain/ListDomainsCommand.php index b05ad429..ddcfa1bd 100644 --- a/module/CLI/src/Command/Domain/ListDomainsCommand.php +++ b/module/CLI/src/Command/Domain/ListDomainsCommand.php @@ -35,11 +35,11 @@ class ListDomainsCommand extends Command protected function execute(InputInterface $input, OutputInterface $output): ?int { - $regularDomains = $this->domainService->listDomainsWithout(); + $domains = $this->domainService->listDomains(); ShlinkTable::fromOutput($output)->render( ['Domain', 'Is default'], - map($regularDomains, fn (DomainItem $domain) => [$domain->toString(), $domain->isDefault() ? 'Yes' : 'No']), + map($domains, fn (DomainItem $domain) => [$domain->toString(), $domain->isDefault() ? 'Yes' : 'No']), ); return ExitCodes::EXIT_SUCCESS; diff --git a/module/CLI/test/Command/Domain/ListDomainsCommandTest.php b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php index 05e35c0b..a0f79448 100644 --- a/module/CLI/test/Command/Domain/ListDomainsCommandTest.php +++ b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php @@ -45,7 +45,7 @@ class ListDomainsCommandTest extends TestCase +---------+------------+ OUTPUT; - $listDomains = $this->domainService->listDomainsWithout()->willReturn([ + $listDomains = $this->domainService->listDomains()->willReturn([ new DomainItem('foo.com', true), new DomainItem('bar.com', false), new DomainItem('baz.com', false), diff --git a/module/Core/src/Domain/DomainService.php b/module/Core/src/Domain/DomainService.php index 12211208..4e5e5da8 100644 --- a/module/Core/src/Domain/DomainService.php +++ b/module/Core/src/Domain/DomainService.php @@ -25,7 +25,7 @@ class DomainService implements DomainServiceInterface /** * @return DomainItem[] */ - public function listDomainsWithout(): array + public function listDomains(): array { /** @var DomainRepositoryInterface $repo */ $repo = $this->em->getRepository(Domain::class); diff --git a/module/Core/src/Domain/DomainServiceInterface.php b/module/Core/src/Domain/DomainServiceInterface.php index 2cff914f..ff89b7b1 100644 --- a/module/Core/src/Domain/DomainServiceInterface.php +++ b/module/Core/src/Domain/DomainServiceInterface.php @@ -11,5 +11,5 @@ interface DomainServiceInterface /** * @return DomainItem[] */ - public function listDomainsWithout(): array; + public function listDomains(): array; } diff --git a/module/Core/test/Domain/DomainServiceTest.php b/module/Core/test/Domain/DomainServiceTest.php index 4192745f..ccdbf04c 100644 --- a/module/Core/test/Domain/DomainServiceTest.php +++ b/module/Core/test/Domain/DomainServiceTest.php @@ -30,13 +30,13 @@ class DomainServiceTest extends TestCase * @test * @dataProvider provideExcludedDomains */ - public function listDomainsWithoutDelegatesIntoRepository(array $domains, array $expectedResult): void + public function listDomainsDelegatesIntoRepository(array $domains, array $expectedResult): void { $repo = $this->prophesize(DomainRepositoryInterface::class); $getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal()); $findDomains = $repo->findDomainsWithout('default.com')->willReturn($domains); - $result = $this->domainService->listDomainsWithout(); + $result = $this->domainService->listDomains(); self::assertEquals($expectedResult, $result); $getRepo->shouldHaveBeenCalledOnce(); diff --git a/module/Rest/src/Action/Domain/ListDomainsAction.php b/module/Rest/src/Action/Domain/ListDomainsAction.php index b3bb10bd..c35bb074 100644 --- a/module/Rest/src/Action/Domain/ListDomainsAction.php +++ b/module/Rest/src/Action/Domain/ListDomainsAction.php @@ -24,7 +24,7 @@ class ListDomainsAction extends AbstractRestAction public function handle(ServerRequestInterface $request): ResponseInterface { - $domainItems = $this->domainService->listDomainsWithout(); + $domainItems = $this->domainService->listDomains(); return new JsonResponse([ 'domains' => [ diff --git a/module/Rest/test/Action/Domain/ListDomainsActionTest.php b/module/Rest/test/Action/Domain/ListDomainsActionTest.php index 940bb2fb..61d9a99f 100644 --- a/module/Rest/test/Action/Domain/ListDomainsActionTest.php +++ b/module/Rest/test/Action/Domain/ListDomainsActionTest.php @@ -33,7 +33,7 @@ class ListDomainsActionTest extends TestCase new DomainItem('bar.com', true), new DomainItem('baz.com', false), ]; - $listDomains = $this->domainService->listDomainsWithout()->willReturn($domains); + $listDomains = $this->domainService->listDomains()->willReturn($domains); /** @var JsonResponse $resp */ $resp = $this->action->handle(ServerRequestFactory::fromGlobals()); From 19834f6715456bc063718f5b5ab381e3c98ef851 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 4 Jan 2021 15:55:59 +0100 Subject: [PATCH 20/46] Applied API role specs to domains list --- module/Core/src/Domain/DomainService.php | 13 ++++++++++--- module/Core/src/Domain/DomainServiceInterface.php | 3 ++- .../Core/src/Domain/Repository/DomainRepository.php | 11 ++++++++--- .../Domain/Repository/DomainRepositoryInterface.php | 6 ++++-- .../Domain/Repository/DomainRepositoryTest.php | 2 +- module/Core/test/Domain/DomainServiceTest.php | 2 +- module/Rest/src/Action/Domain/ListDomainsAction.php | 4 +++- module/Rest/src/Entity/ApiKey.php | 7 ++++++- .../test/Action/Domain/ListDomainsActionTest.php | 8 +++++--- 9 files changed, 40 insertions(+), 16 deletions(-) diff --git a/module/Core/src/Domain/DomainService.php b/module/Core/src/Domain/DomainService.php index 4e5e5da8..836ca1da 100644 --- a/module/Core/src/Domain/DomainService.php +++ b/module/Core/src/Domain/DomainService.php @@ -8,6 +8,8 @@ use Doctrine\ORM\EntityManagerInterface; use Shlinkio\Shlink\Core\Domain\Model\DomainItem; use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface; use Shlinkio\Shlink\Core\Entity\Domain; +use Shlinkio\Shlink\Rest\ApiKey\Role; +use Shlinkio\Shlink\Rest\Entity\ApiKey; use function Functional\map; @@ -25,15 +27,20 @@ class DomainService implements DomainServiceInterface /** * @return DomainItem[] */ - public function listDomains(): array + public function listDomains(?ApiKey $apiKey = null): array { /** @var DomainRepositoryInterface $repo */ $repo = $this->em->getRepository(Domain::class); - $domains = $repo->findDomainsWithout($this->defaultDomain); + $domains = $repo->findDomainsWithout($this->defaultDomain, $apiKey); + $mappedDomains = map($domains, fn (Domain $domain) => new DomainItem($domain->getAuthority(), false)); + + if ($apiKey !== null && $apiKey->hasRole(Role::DOMAIN_SPECIFIC)) { + return $mappedDomains; + } return [ new DomainItem($this->defaultDomain, true), - ...map($domains, fn (Domain $domain) => new DomainItem($domain->getAuthority(), false)), + ...$mappedDomains, ]; } } diff --git a/module/Core/src/Domain/DomainServiceInterface.php b/module/Core/src/Domain/DomainServiceInterface.php index ff89b7b1..681000c5 100644 --- a/module/Core/src/Domain/DomainServiceInterface.php +++ b/module/Core/src/Domain/DomainServiceInterface.php @@ -5,11 +5,12 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Domain; use Shlinkio\Shlink\Core\Domain\Model\DomainItem; +use Shlinkio\Shlink\Rest\Entity\ApiKey; interface DomainServiceInterface { /** * @return DomainItem[] */ - public function listDomains(): array; + public function listDomains(?ApiKey $apiKey = null): array; } diff --git a/module/Core/src/Domain/Repository/DomainRepository.php b/module/Core/src/Domain/Repository/DomainRepository.php index f02dd120..f2152fbe 100644 --- a/module/Core/src/Domain/Repository/DomainRepository.php +++ b/module/Core/src/Domain/Repository/DomainRepository.php @@ -4,17 +4,18 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Domain\Repository; -use Doctrine\ORM\EntityRepository; use Doctrine\ORM\Query\Expr\Join; +use Happyr\DoctrineSpecification\EntitySpecificationRepository; use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\ShortUrl; +use Shlinkio\Shlink\Rest\Entity\ApiKey; -class DomainRepository extends EntityRepository implements DomainRepositoryInterface +class DomainRepository extends EntitySpecificationRepository implements DomainRepositoryInterface { /** * @return Domain[] */ - public function findDomainsWithout(?string $excludedAuthority = null): array + public function findDomainsWithout(?string $excludedAuthority, ?ApiKey $apiKey = null): array { $qb = $this->createQueryBuilder('d'); $qb->join(ShortUrl::class, 's', Join::WITH, 's.domain = d') @@ -25,6 +26,10 @@ class DomainRepository extends EntityRepository implements DomainRepositoryInter ->setParameter('excludedAuthority', $excludedAuthority); } + if ($apiKey !== null) { + $this->applySpecification($qb, $apiKey->spec(), 's'); + } + return $qb->getQuery()->getResult(); } } diff --git a/module/Core/src/Domain/Repository/DomainRepositoryInterface.php b/module/Core/src/Domain/Repository/DomainRepositoryInterface.php index 56a765ac..13917dc6 100644 --- a/module/Core/src/Domain/Repository/DomainRepositoryInterface.php +++ b/module/Core/src/Domain/Repository/DomainRepositoryInterface.php @@ -5,12 +5,14 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Domain\Repository; use Doctrine\Persistence\ObjectRepository; +use Happyr\DoctrineSpecification\EntitySpecificationRepositoryInterface; use Shlinkio\Shlink\Core\Entity\Domain; +use Shlinkio\Shlink\Rest\Entity\ApiKey; -interface DomainRepositoryInterface extends ObjectRepository +interface DomainRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface { /** * @return Domain[] */ - public function findDomainsWithout(?string $excludedAuthority = null): array; + public function findDomainsWithout(?string $excludedAuthority, ?ApiKey $apiKey = null): array; } diff --git a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php index 79f9caaf..c553821e 100644 --- a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php +++ b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php @@ -46,7 +46,7 @@ class DomainRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); - self::assertEquals([$barDomain, $bazDomain, $fooDomain], $this->repo->findDomainsWithout()); + self::assertEquals([$barDomain, $bazDomain, $fooDomain], $this->repo->findDomainsWithout(null)); self::assertEquals([$barDomain, $bazDomain], $this->repo->findDomainsWithout('foo.com')); self::assertEquals([$bazDomain, $fooDomain], $this->repo->findDomainsWithout('bar.com')); self::assertEquals([$barDomain, $fooDomain], $this->repo->findDomainsWithout('baz.com')); diff --git a/module/Core/test/Domain/DomainServiceTest.php b/module/Core/test/Domain/DomainServiceTest.php index ccdbf04c..55094b53 100644 --- a/module/Core/test/Domain/DomainServiceTest.php +++ b/module/Core/test/Domain/DomainServiceTest.php @@ -34,7 +34,7 @@ class DomainServiceTest extends TestCase { $repo = $this->prophesize(DomainRepositoryInterface::class); $getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal()); - $findDomains = $repo->findDomainsWithout('default.com')->willReturn($domains); + $findDomains = $repo->findDomainsWithout('default.com', null)->willReturn($domains); $result = $this->domainService->listDomains(); diff --git a/module/Rest/src/Action/Domain/ListDomainsAction.php b/module/Rest/src/Action/Domain/ListDomainsAction.php index c35bb074..35ce04f3 100644 --- a/module/Rest/src/Action/Domain/ListDomainsAction.php +++ b/module/Rest/src/Action/Domain/ListDomainsAction.php @@ -9,6 +9,7 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; +use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class ListDomainsAction extends AbstractRestAction { @@ -24,7 +25,8 @@ class ListDomainsAction extends AbstractRestAction public function handle(ServerRequestInterface $request): ResponseInterface { - $domainItems = $this->domainService->listDomains(); + $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); + $domainItems = $this->domainService->listDomains($apiKey); return new JsonResponse([ 'domains' => [ diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index 81c22b25..28b236fc 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -76,6 +76,11 @@ class ApiKey extends AbstractEntity public function isAdmin(): bool { - return $this->roles->count() === 0; + return $this->roles->isEmpty(); + } + + public function hasRole(string $roleName): bool + { + return $this->roles->exists(fn ($key, ApiKeyRole $role) => $role->name() === $roleName); } } diff --git a/module/Rest/test/Action/Domain/ListDomainsActionTest.php b/module/Rest/test/Action/Domain/ListDomainsActionTest.php index 61d9a99f..d6dcc4a3 100644 --- a/module/Rest/test/Action/Domain/ListDomainsActionTest.php +++ b/module/Rest/test/Action/Domain/ListDomainsActionTest.php @@ -12,6 +12,7 @@ use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; use Shlinkio\Shlink\Core\Domain\Model\DomainItem; use Shlinkio\Shlink\Rest\Action\Domain\ListDomainsAction; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class ListDomainsActionTest extends TestCase { @@ -23,20 +24,21 @@ class ListDomainsActionTest extends TestCase public function setUp(): void { $this->domainService = $this->prophesize(DomainServiceInterface::class); - $this->action = new ListDomainsAction($this->domainService->reveal(), 'foo.com'); + $this->action = new ListDomainsAction($this->domainService->reveal()); } /** @test */ public function domainsAreProperlyListed(): void { + $apiKey = new ApiKey(); $domains = [ new DomainItem('bar.com', true), new DomainItem('baz.com', false), ]; - $listDomains = $this->domainService->listDomains()->willReturn($domains); + $listDomains = $this->domainService->listDomains($apiKey)->willReturn($domains); /** @var JsonResponse $resp */ - $resp = $this->action->handle(ServerRequestFactory::fromGlobals()); + $resp = $this->action->handle(ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, $apiKey)); $payload = $resp->getPayload(); self::assertEquals([ From 4b67d413622c649c5de39280faeb729509e89a12 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 4 Jan 2021 20:15:42 +0100 Subject: [PATCH 21/46] Applied API role specs to short URL creation --- module/Core/src/Domain/DomainService.php | 12 ++ .../src/Domain/DomainServiceInterface.php | 3 + .../src/Exception/DomainNotFoundException.php | 32 ++++ .../src/ShortUrl/Spec/BelongsToDomain.php | 4 +- .../ShortUrl/Spec/BelongsToDomainInlined.php | 4 +- module/Core/test/Domain/DomainServiceTest.php | 24 +++ .../Exception/DomainNotFoundExceptionTest.php | 28 ++++ .../Exception/TagNotFoundExceptionTest.php | 2 +- module/Rest/config/dependencies.config.php | 2 + module/Rest/config/routes.config.php | 7 +- .../SingleStepCreateShortUrlAction.php | 2 + module/Rest/src/ApiKey/Role.php | 7 +- module/Rest/src/Entity/ApiKey.php | 7 + .../ShortUrl/OverrideDomainMiddleware.php | 46 ++++++ .../ShortUrl/OverrideDomainMiddlewareTest.php | 141 ++++++++++++++++++ 15 files changed, 314 insertions(+), 7 deletions(-) create mode 100644 module/Core/src/Exception/DomainNotFoundException.php create mode 100644 module/Core/test/Exception/DomainNotFoundExceptionTest.php create mode 100644 module/Rest/src/Middleware/ShortUrl/OverrideDomainMiddleware.php create mode 100644 module/Rest/test/Middleware/ShortUrl/OverrideDomainMiddlewareTest.php diff --git a/module/Core/src/Domain/DomainService.php b/module/Core/src/Domain/DomainService.php index 836ca1da..e80f36b7 100644 --- a/module/Core/src/Domain/DomainService.php +++ b/module/Core/src/Domain/DomainService.php @@ -8,6 +8,7 @@ use Doctrine\ORM\EntityManagerInterface; use Shlinkio\Shlink\Core\Domain\Model\DomainItem; use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface; use Shlinkio\Shlink\Core\Entity\Domain; +use Shlinkio\Shlink\Core\Exception\DomainNotFoundException; use Shlinkio\Shlink\Rest\ApiKey\Role; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -43,4 +44,15 @@ class DomainService implements DomainServiceInterface ...$mappedDomains, ]; } + + public function getDomain(string $domainId): Domain + { + /** @var Domain|null $domain */ + $domain = $this->em->find(Domain::class, $domainId); + if ($domain === null) { + throw DomainNotFoundException::fromId($domainId); + } + + return $domain; + } } diff --git a/module/Core/src/Domain/DomainServiceInterface.php b/module/Core/src/Domain/DomainServiceInterface.php index 681000c5..0a2ef914 100644 --- a/module/Core/src/Domain/DomainServiceInterface.php +++ b/module/Core/src/Domain/DomainServiceInterface.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Domain; use Shlinkio\Shlink\Core\Domain\Model\DomainItem; +use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Rest\Entity\ApiKey; interface DomainServiceInterface @@ -13,4 +14,6 @@ interface DomainServiceInterface * @return DomainItem[] */ public function listDomains(?ApiKey $apiKey = null): array; + + public function getDomain(string $domainId): Domain; } diff --git a/module/Core/src/Exception/DomainNotFoundException.php b/module/Core/src/Exception/DomainNotFoundException.php new file mode 100644 index 00000000..b1b97c91 --- /dev/null +++ b/module/Core/src/Exception/DomainNotFoundException.php @@ -0,0 +1,32 @@ +detail = $e->getMessage(); + $e->title = self::TITLE; + $e->type = self::TYPE; + $e->status = StatusCodeInterface::STATUS_NOT_FOUND; + $e->additional = ['id' => $id]; + + return $e; + } +} diff --git a/module/Core/src/ShortUrl/Spec/BelongsToDomain.php b/module/Core/src/ShortUrl/Spec/BelongsToDomain.php index 27f93665..81b4388a 100644 --- a/module/Core/src/ShortUrl/Spec/BelongsToDomain.php +++ b/module/Core/src/ShortUrl/Spec/BelongsToDomain.php @@ -10,10 +10,10 @@ use Happyr\DoctrineSpecification\Spec; class BelongsToDomain extends BaseSpecification { - private int $domainId; + private string $domainId; private string $dqlAlias; - public function __construct(int $domainId, ?string $dqlAlias = null) + public function __construct(string $domainId, ?string $dqlAlias = null) { $this->domainId = $domainId; $this->dqlAlias = $dqlAlias ?? 's'; diff --git a/module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php b/module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php index edadf760..a8ef527e 100644 --- a/module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php +++ b/module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php @@ -9,9 +9,9 @@ use Happyr\DoctrineSpecification\Specification\Specification; class BelongsToDomainInlined implements Specification { - private int $domainId; + private string $domainId; - public function __construct(int $domainId) + public function __construct(string $domainId) { $this->domainId = $domainId; } diff --git a/module/Core/test/Domain/DomainServiceTest.php b/module/Core/test/Domain/DomainServiceTest.php index 55094b53..0201d7d9 100644 --- a/module/Core/test/Domain/DomainServiceTest.php +++ b/module/Core/test/Domain/DomainServiceTest.php @@ -12,6 +12,7 @@ use Shlinkio\Shlink\Core\Domain\DomainService; use Shlinkio\Shlink\Core\Domain\Model\DomainItem; use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface; use Shlinkio\Shlink\Core\Entity\Domain; +use Shlinkio\Shlink\Core\Exception\DomainNotFoundException; class DomainServiceTest extends TestCase { @@ -54,4 +55,27 @@ class DomainServiceTest extends TestCase [$default, new DomainItem('foo.com', false), new DomainItem('bar.com', false)], ]; } + + /** @test */ + public function getDomainThrowsExceptionWhenDomainIsNotFound(): void + { + $find = $this->em->find(Domain::class, '123')->willReturn(null); + + $this->expectException(DomainNotFoundException::class); + $find->shouldBeCalledOnce(); + + $this->domainService->getDomain('123'); + } + + /** @test */ + public function getDomainReturnsEntityWhenFound(): void + { + $domain = new Domain(''); + $find = $this->em->find(Domain::class, '123')->willReturn($domain); + + $result = $this->domainService->getDomain('123'); + + self::assertSame($domain, $result); + $find->shouldHaveBeenCalledOnce(); + } } diff --git a/module/Core/test/Exception/DomainNotFoundExceptionTest.php b/module/Core/test/Exception/DomainNotFoundExceptionTest.php new file mode 100644 index 00000000..6ac26efd --- /dev/null +++ b/module/Core/test/Exception/DomainNotFoundExceptionTest.php @@ -0,0 +1,28 @@ +getMessage()); + self::assertEquals($expectedMessage, $e->getDetail()); + self::assertEquals('Domain not found', $e->getTitle()); + self::assertEquals('DOMAIN_NOT_FOUND', $e->getType()); + self::assertEquals(['id' => $id], $e->getAdditionalData()); + self::assertEquals(404, $e->getStatus()); + } +} diff --git a/module/Core/test/Exception/TagNotFoundExceptionTest.php b/module/Core/test/Exception/TagNotFoundExceptionTest.php index c6e8bf1d..ccd63788 100644 --- a/module/Core/test/Exception/TagNotFoundExceptionTest.php +++ b/module/Core/test/Exception/TagNotFoundExceptionTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace ShlinkioTest\Shlink\Rest\Exception; +namespace ShlinkioTest\Shlink\Core\Exception; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index 6f94556d..c2181f70 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -45,6 +45,7 @@ return [ Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class => InvokableFactory::class, Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ConfigAbstractFactory::class, Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => ConfigAbstractFactory::class, + Middleware\ShortUrl\OverrideDomainMiddleware::class => ConfigAbstractFactory::class, ], ], @@ -81,6 +82,7 @@ return [ Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => [ 'config.url_shortener.default_short_codes_length', ], + Middleware\ShortUrl\OverrideDomainMiddleware::class => [DomainService::class], ], ]; diff --git a/module/Rest/config/routes.config.php b/module/Rest/config/routes.config.php index 64333254..a5382c38 100644 --- a/module/Rest/config/routes.config.php +++ b/module/Rest/config/routes.config.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Rest; $contentNegotiationMiddleware = Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class; $dropDomainMiddleware = Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class; +$overrideDomainMiddleware = Middleware\ShortUrl\OverrideDomainMiddleware::class; return [ @@ -16,9 +17,13 @@ return [ Action\ShortUrl\CreateShortUrlAction::getRouteDef([ $contentNegotiationMiddleware, $dropDomainMiddleware, + $overrideDomainMiddleware, Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class, ]), - Action\ShortUrl\SingleStepCreateShortUrlAction::getRouteDef([$contentNegotiationMiddleware]), + Action\ShortUrl\SingleStepCreateShortUrlAction::getRouteDef([ + $contentNegotiationMiddleware, + $overrideDomainMiddleware, + ]), Action\ShortUrl\EditShortUrlAction::getRouteDef([$dropDomainMiddleware]), Action\ShortUrl\DeleteShortUrlAction::getRouteDef([$dropDomainMiddleware]), Action\ShortUrl\ResolveShortUrlAction::getRouteDef([$dropDomainMiddleware]), diff --git a/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php index cbb06386..e9edee41 100644 --- a/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php @@ -51,6 +51,8 @@ class SingleStepCreateShortUrlAction extends AbstractCreateShortUrlAction return new CreateShortUrlData($longUrl, [], ShortUrlMeta::fromRawData([ ShortUrlMetaInputFilter::API_KEY => $apiKeyResult->apiKey(), + // This will usually be null, unless this API key enforces one specific domain + ShortUrlMetaInputFilter::DOMAIN => $request->getAttribute(ShortUrlMetaInputFilter::DOMAIN), ])); } } diff --git a/module/Rest/src/ApiKey/Role.php b/module/Rest/src/ApiKey/Role.php index 83a78087..87bad5fc 100644 --- a/module/Rest/src/ApiKey/Role.php +++ b/module/Rest/src/ApiKey/Role.php @@ -24,10 +24,15 @@ class Role } if ($role->name() === self::DOMAIN_SPECIFIC) { - $domainId = $role->meta()['domain_id'] ?? -1; + $domainId = self::domainIdFromMeta($role->meta()); return $inlined ? new BelongsToDomainInlined($domainId) : new BelongsToDomain($domainId); } return Spec::andX(); } + + public static function domainIdFromMeta(array $meta): string + { + return $meta['domain_id'] ?? '-1'; + } } diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index 28b236fc..3d600ce7 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -83,4 +83,11 @@ class ApiKey extends AbstractEntity { return $this->roles->exists(fn ($key, ApiKeyRole $role) => $role->name() === $roleName); } + + public function getRoleMeta(string $roleName): array + { + /** @var ApiKeyRole|false $role */ + $role = $this->roles->filter(fn (ApiKeyRole $role) => $role->name() === $roleName)->first(); + return ! $role ? [] : $role->meta(); + } } diff --git a/module/Rest/src/Middleware/ShortUrl/OverrideDomainMiddleware.php b/module/Rest/src/Middleware/ShortUrl/OverrideDomainMiddleware.php new file mode 100644 index 00000000..817570a8 --- /dev/null +++ b/module/Rest/src/Middleware/ShortUrl/OverrideDomainMiddleware.php @@ -0,0 +1,46 @@ +domainService = $domainService; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); + if (! $apiKey->hasRole(Role::DOMAIN_SPECIFIC)) { + return $handler->handle($request); + } + + $requestMethod = $request->getMethod(); + $domainId = Role::domainIdFromMeta($apiKey->getRoleMeta(Role::DOMAIN_SPECIFIC)); + $domain = $this->domainService->getDomain($domainId); + + if ($requestMethod === RequestMethodInterface::METHOD_POST) { + $payload = $request->getParsedBody(); + $payload[ShortUrlMetaInputFilter::DOMAIN] = $domain->getAuthority(); + + return $handler->handle($request->withParsedBody($payload)); + } + + return $handler->handle($request->withAttribute(ShortUrlMetaInputFilter::DOMAIN, $domain->getAuthority())); + } +} diff --git a/module/Rest/test/Middleware/ShortUrl/OverrideDomainMiddlewareTest.php b/module/Rest/test/Middleware/ShortUrl/OverrideDomainMiddlewareTest.php new file mode 100644 index 00000000..dcf4d7ce --- /dev/null +++ b/module/Rest/test/Middleware/ShortUrl/OverrideDomainMiddlewareTest.php @@ -0,0 +1,141 @@ +apiKey = $this->prophesize(ApiKey::class); + $this->handler = $this->prophesize(RequestHandlerInterface::class); + + $this->domainService = $this->prophesize(DomainServiceInterface::class); + $this->middleware = new OverrideDomainMiddleware($this->domainService->reveal()); + } + + /** @test */ + public function nextMiddlewareIsCalledWhenApiKeyDoesNotHaveProperRole(): void + { + $request = $this->requestWithApiKey(); + $response = new Response(); + $hasRole = $this->apiKey->hasRole(Role::DOMAIN_SPECIFIC)->willReturn(false); + $handle = $this->handler->handle($request)->willReturn($response); + $getDomain = $this->domainService->getDomain(Argument::cetera()); + + $result = $this->middleware->process($request, $this->handler->reveal()); + + self::assertSame($response, $result); + $hasRole->shouldHaveBeenCalledOnce(); + $handle->shouldHaveBeenCalledOnce(); + $getDomain->shouldNotHaveBeenCalled(); + } + + /** + * @test + * @dataProvider provideBodies + */ + public function overwritesRequestBodyWhenMethodIsPost(Domain $domain, array $body, array $expectedBody): void + { + $request = $this->requestWithApiKey()->withMethod('POST')->withParsedBody($body); + $hasRole = $this->apiKey->hasRole(Role::DOMAIN_SPECIFIC)->willReturn(true); + $getRoleMeta = $this->apiKey->getRoleMeta(Role::DOMAIN_SPECIFIC)->willReturn(['domain_id' => '123']); + $getDomain = $this->domainService->getDomain('123')->willReturn($domain); + $handle = $this->handler->handle(Argument::that( + function (ServerRequestInterface $req) use ($expectedBody): bool { + Assert::assertEquals($req->getParsedBody(), $expectedBody); + return true; + }, + ))->willReturn(new Response()); + + $this->middleware->process($request, $this->handler->reveal()); + + $hasRole->shouldHaveBeenCalledOnce(); + $getRoleMeta->shouldHaveBeenCalledOnce(); + $getDomain->shouldHaveBeenCalledOnce(); + $handle->shouldHaveBeenCalledOnce(); + } + + public function provideBodies(): iterable + { + yield 'no domain provided' => [new Domain('foo.com'), [], [ShortUrlMetaInputFilter::DOMAIN => 'foo.com']]; + yield 'other domain provided' => [ + new Domain('bar.com'), + [ShortUrlMetaInputFilter::DOMAIN => 'foo.com'], + [ShortUrlMetaInputFilter::DOMAIN => 'bar.com'], + ]; + yield 'same domain provided' => [ + new Domain('baz.com'), + [ShortUrlMetaInputFilter::DOMAIN => 'baz.com'], + [ShortUrlMetaInputFilter::DOMAIN => 'baz.com'], + ]; + yield 'more body params' => [ + new Domain('doma.in'), + [ShortUrlMetaInputFilter::DOMAIN => 'baz.com', 'something' => 'else', 'foo' => 123], + [ShortUrlMetaInputFilter::DOMAIN => 'doma.in', 'something' => 'else', 'foo' => 123], + ]; + } + + /** + * @test + * @dataProvider provideMethods + */ + public function setsRequestAttributeWhenMethodIsNotPost(string $method): void + { + $domain = new Domain('something.com'); + $request = $this->requestWithApiKey()->withMethod($method); + $hasRole = $this->apiKey->hasRole(Role::DOMAIN_SPECIFIC)->willReturn(true); + $getRoleMeta = $this->apiKey->getRoleMeta(Role::DOMAIN_SPECIFIC)->willReturn(['domain_id' => '123']); + $getDomain = $this->domainService->getDomain('123')->willReturn($domain); + $handle = $this->handler->handle(Argument::that( + function (ServerRequestInterface $req): bool { + Assert::assertEquals($req->getAttribute(ShortUrlMetaInputFilter::DOMAIN), 'something.com'); + return true; + }, + ))->willReturn(new Response()); + + $this->middleware->process($request, $this->handler->reveal()); + + $hasRole->shouldHaveBeenCalledOnce(); + $getRoleMeta->shouldHaveBeenCalledOnce(); + $getDomain->shouldHaveBeenCalledOnce(); + $handle->shouldHaveBeenCalledOnce(); + } + + public function provideMethods(): iterable + { + yield 'GET' => ['GET']; + yield 'PUT' => ['PUT']; + yield 'PATCH' => ['PATCH']; + yield 'DELETE' => ['DELETE']; + } + + private function requestWithApiKey(): ServerRequestInterface + { + return ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, $this->apiKey->reveal()); + } +} From f821dea06c1bfee2c69bb269dd4c9dc4ebc2806f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 5 Jan 2021 19:29:42 +0100 Subject: [PATCH 22/46] Fixed typo on fixture --- module/Rest/src/Action/Tag/DeleteTagsAction.php | 6 ------ module/Rest/test-api/Fixtures/VisitsFixture.php | 8 ++++---- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/module/Rest/src/Action/Tag/DeleteTagsAction.php b/module/Rest/src/Action/Tag/DeleteTagsAction.php index f38c443a..aea11a41 100644 --- a/module/Rest/src/Action/Tag/DeleteTagsAction.php +++ b/module/Rest/src/Action/Tag/DeleteTagsAction.php @@ -22,12 +22,6 @@ class DeleteTagsAction extends AbstractRestAction $this->tagService = $tagService; } - /** - * Process an incoming server request and return a response, optionally delegating - * to the next middleware component to create the response. - * - * - */ public function handle(ServerRequestInterface $request): ResponseInterface { $query = $request->getQueryParams(); diff --git a/module/Rest/test-api/Fixtures/VisitsFixture.php b/module/Rest/test-api/Fixtures/VisitsFixture.php index a07d95d1..73601748 100644 --- a/module/Rest/test-api/Fixtures/VisitsFixture.php +++ b/module/Rest/test-api/Fixtures/VisitsFixture.php @@ -31,10 +31,10 @@ class VisitsFixture extends AbstractFixture implements DependentFixtureInterface $manager->persist(new Visit($defShortUrl, new Visitor('shlink-tests-agent', '', '127.0.0.1'))); $manager->persist(new Visit($defShortUrl, new Visitor('shlink-tests-agent', 'https://app.shlink.io', ''))); - /** @var ShortUrl $defShortUrl */ - $defShortUrl = $this->getReference('ghi789_short_url'); - $manager->persist(new Visit($defShortUrl, new Visitor('shlink-tests-agent', '', '1.2.3.4'))); - $manager->persist(new Visit($defShortUrl, new Visitor('shlink-tests-agent', 'https://app.shlink.io', ''))); + /** @var ShortUrl $ghiShortUrl */ + $ghiShortUrl = $this->getReference('ghi789_short_url'); + $manager->persist(new Visit($ghiShortUrl, new Visitor('shlink-tests-agent', '', '1.2.3.4'))); + $manager->persist(new Visit($ghiShortUrl, new Visitor('shlink-tests-agent', 'https://app.shlink.io', ''))); $manager->flush(); } From 01b3c504f8761920a286588958e649cd7fa953ab Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 5 Jan 2021 19:32:18 +0100 Subject: [PATCH 23/46] Ensured fixed commit for happyr/doctrine-specification is installed, until a stable v2.0 is released --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 97cdf152..3619f89f 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,7 @@ "endroid/qr-code": "^3.6", "geoip2/geoip2": "^2.9", "guzzlehttp/guzzle": "^7.0", - "happyr/doctrine-specification": "2.0.x-dev as 2.0", + "happyr/doctrine-specification": "2.0.x-dev#cb116d3 as 2.0", "laminas/laminas-config": "^3.3", "laminas/laminas-config-aggregator": "^1.1", "laminas/laminas-diactoros": "^2.1.3", From 041f231ff20757225ef03edd8c1786a7b98ed826 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 6 Jan 2021 10:59:02 +0100 Subject: [PATCH 24/46] Implemented mechanism to add/remove roles from API keys --- .../Shlinkio.Shlink.Rest.Entity.ApiKey.php | 3 ++ .../Rest/src/ApiKey/Model/RoleDefinition.php | 39 ++++++++++++++++++ module/Rest/src/Entity/ApiKey.php | 40 ++++++++++++++++--- module/Rest/src/Entity/ApiKeyRole.php | 5 +++ 4 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 module/Rest/src/ApiKey/Model/RoleDefinition.php diff --git a/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKey.php b/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKey.php index 2cb2df2b..95f53b30 100644 --- a/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKey.php +++ b/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKey.php @@ -38,5 +38,8 @@ return static function (ClassMetadata $metadata, array $emConfig): void { $builder->createOneToMany('roles', ApiKeyRole::class) ->mappedBy('apiKey') + ->setIndexBy('roleName') + ->cascadePersist() + ->orphanRemoval() ->build(); }; diff --git a/module/Rest/src/ApiKey/Model/RoleDefinition.php b/module/Rest/src/ApiKey/Model/RoleDefinition.php new file mode 100644 index 00000000..bb9165e8 --- /dev/null +++ b/module/Rest/src/ApiKey/Model/RoleDefinition.php @@ -0,0 +1,39 @@ +roleName = $roleName; + $this->meta = $meta; + } + + public static function forAuthoredShortUrls(): self + { + return new self(Role::AUTHORED_SHORT_URLS, []); + } + + public static function forDomain(string $domainId): self + { + return new self(Role::DOMAIN_SPECIFIC, ['domain_id' => $domainId]); + } + + public function roleName(): string + { + return $this->roleName; + } + + public function meta(): array + { + return $this->meta; + } +} diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index 3d600ce7..59ff502b 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -7,10 +7,12 @@ namespace Shlinkio\Shlink\Rest\Entity; use Cake\Chronos\Chronos; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; +use Exception; use Happyr\DoctrineSpecification\Spec; use Happyr\DoctrineSpecification\Specification\Specification; use Ramsey\Uuid\Uuid; use Shlinkio\Shlink\Common\Entity\AbstractEntity; +use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\ApiKey\Role; class ApiKey extends AbstractEntity @@ -21,12 +23,20 @@ class ApiKey extends AbstractEntity /** @var Collection|ApiKeyRole[] */ private Collection $roles; - public function __construct(?Chronos $expirationDate = null) + /** + * @param RoleDefinition[] $roleDefinitions + * @throws Exception + */ + public function __construct(?Chronos $expirationDate = null, array $roleDefinitions = []) { $this->key = Uuid::uuid4()->toString(); $this->expirationDate = $expirationDate; $this->enabled = true; $this->roles = new ArrayCollection(); + + foreach ($roleDefinitions as $roleDefinition) { + $this->registerRole($roleDefinition); + } } public function getExpirationDate(): ?Chronos @@ -81,13 +91,33 @@ class ApiKey extends AbstractEntity public function hasRole(string $roleName): bool { - return $this->roles->exists(fn ($key, ApiKeyRole $role) => $role->name() === $roleName); + return $this->roles->containsKey($roleName); } public function getRoleMeta(string $roleName): array { - /** @var ApiKeyRole|false $role */ - $role = $this->roles->filter(fn (ApiKeyRole $role) => $role->name() === $roleName)->first(); - return ! $role ? [] : $role->meta(); + /** @var ApiKeyRole|null $role */ + $role = $this->roles->get($roleName); + return $role === null ? [] : $role->meta(); + } + + public function registerRole(RoleDefinition $roleDefinition): void + { + $roleName = $roleDefinition->roleName(); + $meta = $roleDefinition->meta(); + + if ($this->hasRole($roleName)) { + /** @var ApiKeyRole $role */ + $role = $this->roles->get($roleName); + $role->updateMeta($meta); + } else { + $role = new ApiKeyRole($roleDefinition->roleName(), $roleDefinition->meta(), $this); + $this->roles[$roleName] = $role; + } + } + + public function removeRole(string $roleName): void + { + $this->roles->remove($roleName); } } diff --git a/module/Rest/src/Entity/ApiKeyRole.php b/module/Rest/src/Entity/ApiKeyRole.php index aefda970..99dbb627 100644 --- a/module/Rest/src/Entity/ApiKeyRole.php +++ b/module/Rest/src/Entity/ApiKeyRole.php @@ -29,6 +29,11 @@ class ApiKeyRole extends AbstractEntity return $this->meta; } + public function updateMeta(array $newMeta): void + { + $this->meta = $newMeta; + } + public function apiKey(): ApiKey { return $this->apiKey; From b5710f87e27cc59f03e86079bfae9e8ab0856b7a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 6 Jan 2021 13:11:28 +0100 Subject: [PATCH 25/46] Created value object to wrap the renaming of a tag --- .../CLI/src/Command/Tag/RenameTagCommand.php | 3 +- .../test/Command/Tag/RenameTagCommandTest.php | 9 ++- .../src/Exception/TagConflictException.php | 10 ++- module/Core/src/Tag/Model/TagRenaming.php | 68 +++++++++++++++++++ module/Core/src/Tag/TagService.php | 13 ++-- module/Core/src/Tag/TagServiceInterface.php | 3 +- .../Exception/TagConflictExceptionTest.php | 7 +- .../Core/test/Service/Tag/TagServiceTest.php | 7 +- .../Rest/src/Action/Tag/UpdateTagAction.php | 11 +-- .../test/Action/Tag/UpdateTagActionTest.php | 3 +- 10 files changed, 102 insertions(+), 32 deletions(-) create mode 100644 module/Core/src/Tag/Model/TagRenaming.php diff --git a/module/CLI/src/Command/Tag/RenameTagCommand.php b/module/CLI/src/Command/Tag/RenameTagCommand.php index fe42a832..8bfb0242 100644 --- a/module/CLI/src/Command/Tag/RenameTagCommand.php +++ b/module/CLI/src/Command/Tag/RenameTagCommand.php @@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\CLI\Command\Tag; use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; +use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -42,7 +43,7 @@ class RenameTagCommand extends Command $newName = $input->getArgument('newName'); try { - $this->tagService->renameTag($oldName, $newName); + $this->tagService->renameTag(TagRenaming::fromNames($oldName, $newName)); $io->success('Tag properly renamed.'); return ExitCodes::EXIT_SUCCESS; } catch (TagNotFoundException | TagConflictException $e) { diff --git a/module/CLI/test/Command/Tag/RenameTagCommandTest.php b/module/CLI/test/Command/Tag/RenameTagCommandTest.php index 9764a111..d457c25d 100644 --- a/module/CLI/test/Command/Tag/RenameTagCommandTest.php +++ b/module/CLI/test/Command/Tag/RenameTagCommandTest.php @@ -10,6 +10,7 @@ use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Tag\RenameTagCommand; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; +use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Symfony\Component\Console\Application; use Symfony\Component\Console\Tester\CommandTester; @@ -37,7 +38,9 @@ class RenameTagCommandTest extends TestCase { $oldName = 'foo'; $newName = 'bar'; - $renameTag = $this->tagService->renameTag($oldName, $newName)->willThrow(TagNotFoundException::fromTag('foo')); + $renameTag = $this->tagService->renameTag(TagRenaming::fromNames($oldName, $newName))->willThrow( + TagNotFoundException::fromTag('foo'), + ); $this->commandTester->execute([ 'oldName' => $oldName, @@ -54,7 +57,9 @@ class RenameTagCommandTest extends TestCase { $oldName = 'foo'; $newName = 'bar'; - $renameTag = $this->tagService->renameTag($oldName, $newName)->willReturn(new Tag($newName)); + $renameTag = $this->tagService->renameTag(TagRenaming::fromNames($oldName, $newName))->willReturn( + new Tag($newName), + ); $this->commandTester->execute([ 'oldName' => $oldName, diff --git a/module/Core/src/Exception/TagConflictException.php b/module/Core/src/Exception/TagConflictException.php index 7362f76b..d551ec19 100644 --- a/module/Core/src/Exception/TagConflictException.php +++ b/module/Core/src/Exception/TagConflictException.php @@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\Exception; use Fig\Http\Message\StatusCodeInterface; use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait; use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface; +use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use function sprintf; @@ -17,18 +18,15 @@ class TagConflictException extends RuntimeException implements ProblemDetailsExc private const TITLE = 'Tag conflict'; private const TYPE = 'TAG_CONFLICT'; - public static function fromExistingTag(string $oldName, string $newName): self + public static function forExistingTag(TagRenaming $renaming): self { - $e = new self(sprintf('You cannot rename tag %s to %s, because it already exists', $oldName, $newName)); + $e = new self(sprintf('You cannot rename tag %s, because it already exists', $renaming->toString())); $e->detail = $e->getMessage(); $e->title = self::TITLE; $e->type = self::TYPE; $e->status = StatusCodeInterface::STATUS_CONFLICT; - $e->additional = [ - 'oldName' => $oldName, - 'newName' => $newName, - ]; + $e->additional = $renaming->toArray(); return $e; } diff --git a/module/Core/src/Tag/Model/TagRenaming.php b/module/Core/src/Tag/Model/TagRenaming.php new file mode 100644 index 00000000..1f677376 --- /dev/null +++ b/module/Core/src/Tag/Model/TagRenaming.php @@ -0,0 +1,68 @@ +oldName = $oldName; + $o->newName = $newName; + + return $o; + } + + public static function fromArray(array $payload): self + { + if (! isset($payload['oldName'], $payload['newName'])) { + throw ValidationException::fromArray([ + 'oldName' => 'oldName is required', + 'newName' => 'newName is required', + ]); + } + + return self::fromNames($payload['oldName'], $payload['newName']); + } + + public function oldName(): string + { + return $this->oldName; + } + + public function newName(): string + { + return $this->newName; + } + + public function nameChanged(): bool + { + return $this->oldName !== $this->newName; + } + + public function toString(): string + { + return sprintf('%s to %s', $this->oldName, $this->newName); + } + + public function toArray(): array + { + return [ + 'oldName' => $this->oldName, + 'newName' => $this->newName, + ]; + } +} diff --git a/module/Core/src/Tag/TagService.php b/module/Core/src/Tag/TagService.php index 342c5d31..5ce2be0f 100644 --- a/module/Core/src/Tag/TagService.php +++ b/module/Core/src/Tag/TagService.php @@ -13,6 +13,7 @@ use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Repository\TagRepository; use Shlinkio\Shlink\Core\Repository\TagRepositoryInterface; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; +use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use Shlinkio\Shlink\Core\Util\TagManagerTrait; use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -82,23 +83,23 @@ class TagService implements TagServiceInterface * @throws TagNotFoundException * @throws TagConflictException */ - public function renameTag(string $oldName, string $newName): Tag + public function renameTag(TagRenaming $renaming): Tag { /** @var TagRepository $repo */ $repo = $this->em->getRepository(Tag::class); /** @var Tag|null $tag */ - $tag = $repo->findOneBy(['name' => $oldName]); + $tag = $repo->findOneBy(['name' => $renaming->oldName()]); if ($tag === null) { - throw TagNotFoundException::fromTag($oldName); + throw TagNotFoundException::fromTag($renaming->oldName()); } - $newNameExists = $newName !== $oldName && $repo->count(['name' => $newName]) > 0; + $newNameExists = $renaming->nameChanged() && $repo->count(['name' => $renaming->newName()]) > 0; if ($newNameExists) { - throw TagConflictException::fromExistingTag($oldName, $newName); + throw TagConflictException::forExistingTag($renaming); } - $tag->rename($newName); + $tag->rename($renaming->newName()); $this->em->flush(); return $tag; diff --git a/module/Core/src/Tag/TagServiceInterface.php b/module/Core/src/Tag/TagServiceInterface.php index 698736a6..7bcf2ace 100644 --- a/module/Core/src/Tag/TagServiceInterface.php +++ b/module/Core/src/Tag/TagServiceInterface.php @@ -9,6 +9,7 @@ use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; +use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use Shlinkio\Shlink\Rest\Entity\ApiKey; interface TagServiceInterface @@ -39,5 +40,5 @@ interface TagServiceInterface * @throws TagNotFoundException * @throws TagConflictException */ - public function renameTag(string $oldName, string $newName): Tag; + public function renameTag(TagRenaming $renaming): Tag; } diff --git a/module/Core/test/Exception/TagConflictExceptionTest.php b/module/Core/test/Exception/TagConflictExceptionTest.php index 156fd500..4427eb40 100644 --- a/module/Core/test/Exception/TagConflictExceptionTest.php +++ b/module/Core/test/Exception/TagConflictExceptionTest.php @@ -2,22 +2,23 @@ declare(strict_types=1); -namespace ShlinkioTest\Shlink\Rest\Exception; +namespace ShlinkioTest\Shlink\Core\Exception; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Exception\TagConflictException; +use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use function sprintf; class TagConflictExceptionTest extends TestCase { /** @test */ - public function properlyCreatesExceptionFromNotFoundTag(): void + public function properlyCreatesExceptionForExistingTag(): void { $oldName = 'foo'; $newName = 'bar'; $expectedMessage = sprintf('You cannot rename tag %s to %s, because it already exists', $oldName, $newName); - $e = TagConflictException::fromExistingTag($oldName, $newName); + $e = TagConflictException::forExistingTag(TagRenaming::fromNames($oldName, $newName)); self::assertEquals($expectedMessage, $e->getMessage()); self::assertEquals($expectedMessage, $e->getDetail()); diff --git a/module/Core/test/Service/Tag/TagServiceTest.php b/module/Core/test/Service/Tag/TagServiceTest.php index a66fdc6f..842ce9a2 100644 --- a/module/Core/test/Service/Tag/TagServiceTest.php +++ b/module/Core/test/Service/Tag/TagServiceTest.php @@ -14,6 +14,7 @@ use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Repository\TagRepository; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; +use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use Shlinkio\Shlink\Core\Tag\TagService; class TagServiceTest extends TestCase @@ -92,7 +93,7 @@ class TagServiceTest extends TestCase $find->shouldBeCalled(); $this->expectException(TagNotFoundException::class); - $this->service->renameTag('foo', 'bar'); + $this->service->renameTag(TagRenaming::fromNames('foo', 'bar')); } /** @@ -107,7 +108,7 @@ class TagServiceTest extends TestCase $countTags = $this->repo->count(Argument::cetera())->willReturn($count); $flush = $this->em->flush()->willReturn(null); - $tag = $this->service->renameTag($oldName, $newName); + $tag = $this->service->renameTag(TagRenaming::fromNames($oldName, $newName)); self::assertSame($expected, $tag); self::assertEquals($newName, (string) $tag); @@ -134,6 +135,6 @@ class TagServiceTest extends TestCase $flush->shouldNotBeCalled(); $this->expectException(TagConflictException::class); - $this->service->renameTag('foo', 'bar'); + $this->service->renameTag(TagRenaming::fromNames('foo', 'bar')); } } diff --git a/module/Rest/src/Action/Tag/UpdateTagAction.php b/module/Rest/src/Action/Tag/UpdateTagAction.php index fbf93f50..77431798 100644 --- a/module/Rest/src/Action/Tag/UpdateTagAction.php +++ b/module/Rest/src/Action/Tag/UpdateTagAction.php @@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\Rest\Action\Tag; use Laminas\Diactoros\Response\EmptyResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Shlinkio\Shlink\Core\Exception\ValidationException; +use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; @@ -33,14 +33,7 @@ class UpdateTagAction extends AbstractRestAction public function handle(ServerRequestInterface $request): ResponseInterface { $body = $request->getParsedBody(); - if (! isset($body['oldName'], $body['newName'])) { - throw ValidationException::fromArray([ - 'oldName' => 'oldName is required', - 'newName' => 'newName is required', - ]); - } - - $this->tagService->renameTag($body['oldName'], $body['newName']); + $this->tagService->renameTag(TagRenaming::fromArray($body)); return new EmptyResponse(); } } diff --git a/module/Rest/test/Action/Tag/UpdateTagActionTest.php b/module/Rest/test/Action/Tag/UpdateTagActionTest.php index b82c8c2e..8546312f 100644 --- a/module/Rest/test/Action/Tag/UpdateTagActionTest.php +++ b/module/Rest/test/Action/Tag/UpdateTagActionTest.php @@ -10,6 +10,7 @@ use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Exception\ValidationException; +use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\Tag\UpdateTagAction; @@ -53,7 +54,7 @@ class UpdateTagActionTest extends TestCase 'oldName' => 'foo', 'newName' => 'bar', ]); - $rename = $this->tagService->renameTag('foo', 'bar')->willReturn(new Tag('bar')); + $rename = $this->tagService->renameTag(TagRenaming::fromNames('foo', 'bar'))->willReturn(new Tag('bar')); $resp = $this->action->handle($request); From a8b68f07b56e9f9cfc39f8b2ccfe7d21b8c8cf3d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 6 Jan 2021 17:31:49 +0100 Subject: [PATCH 26/46] Ensured delete/rename tags cannot be done with non-admin API keys --- .../ForbiddenTagOperationException.php | 39 +++++++++++ module/Core/src/Tag/TagService.php | 15 ++++- module/Core/src/Tag/TagServiceInterface.php | 7 +- .../ForbiddenTagOperationExceptionTest.php | 37 +++++++++++ .../Core/test/Service/Tag/TagServiceTest.php | 65 ++++++++++++++++--- .../Rest/src/Action/Tag/DeleteTagsAction.php | 4 +- .../Rest/src/Action/Tag/UpdateTagAction.php | 12 ++-- .../test/Action/Tag/DeleteTagsActionTest.php | 8 ++- .../test/Action/Tag/UpdateTagActionTest.php | 19 ++++-- 9 files changed, 177 insertions(+), 29 deletions(-) create mode 100644 module/Core/src/Exception/ForbiddenTagOperationException.php create mode 100644 module/Core/test/Exception/ForbiddenTagOperationExceptionTest.php diff --git a/module/Core/src/Exception/ForbiddenTagOperationException.php b/module/Core/src/Exception/ForbiddenTagOperationException.php new file mode 100644 index 00000000..d4200c92 --- /dev/null +++ b/module/Core/src/Exception/ForbiddenTagOperationException.php @@ -0,0 +1,39 @@ +detail = $message; + $e->title = self::TITLE; + $e->type = self::TYPE; + $e->status = StatusCodeInterface::STATUS_FORBIDDEN; + + return $e; + } +} diff --git a/module/Core/src/Tag/TagService.php b/module/Core/src/Tag/TagService.php index 5ce2be0f..ae46a312 100644 --- a/module/Core/src/Tag/TagService.php +++ b/module/Core/src/Tag/TagService.php @@ -8,6 +8,7 @@ use Doctrine\Common\Collections\Collection; use Doctrine\ORM; use Happyr\DoctrineSpecification\Spec; use Shlinkio\Shlink\Core\Entity\Tag; +use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException; use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Repository\TagRepository; @@ -56,9 +57,14 @@ class TagService implements TagServiceInterface /** * @param string[] $tagNames + * @throws ForbiddenTagOperationException */ - public function deleteTags(array $tagNames): void + public function deleteTags(array $tagNames, ?ApiKey $apiKey = null): void { + if ($apiKey !== null && ! $apiKey->isAdmin()) { + throw ForbiddenTagOperationException::forDeletion(); + } + /** @var TagRepository $repo */ $repo = $this->em->getRepository(Tag::class); $repo->deleteByName($tagNames); @@ -82,9 +88,14 @@ class TagService implements TagServiceInterface /** * @throws TagNotFoundException * @throws TagConflictException + * @throws ForbiddenTagOperationException */ - public function renameTag(TagRenaming $renaming): Tag + public function renameTag(TagRenaming $renaming, ?ApiKey $apiKey = null): Tag { + if ($apiKey !== null && ! $apiKey->isAdmin()) { + throw ForbiddenTagOperationException::forRenaming(); + } + /** @var TagRepository $repo */ $repo = $this->em->getRepository(Tag::class); diff --git a/module/Core/src/Tag/TagServiceInterface.php b/module/Core/src/Tag/TagServiceInterface.php index 7bcf2ace..34cf1871 100644 --- a/module/Core/src/Tag/TagServiceInterface.php +++ b/module/Core/src/Tag/TagServiceInterface.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Tag; use Doctrine\Common\Collections\Collection; use Shlinkio\Shlink\Core\Entity\Tag; +use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException; use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; @@ -26,8 +27,9 @@ interface TagServiceInterface /** * @param string[] $tagNames + * @throws ForbiddenTagOperationException */ - public function deleteTags(array $tagNames): void; + public function deleteTags(array $tagNames, ?ApiKey $apiKey = null): void; /** * @deprecated @@ -39,6 +41,7 @@ interface TagServiceInterface /** * @throws TagNotFoundException * @throws TagConflictException + * @throws ForbiddenTagOperationException */ - public function renameTag(TagRenaming $renaming): Tag; + public function renameTag(TagRenaming $renaming, ?ApiKey $apiKey = null): Tag; } diff --git a/module/Core/test/Exception/ForbiddenTagOperationExceptionTest.php b/module/Core/test/Exception/ForbiddenTagOperationExceptionTest.php new file mode 100644 index 00000000..c42f864a --- /dev/null +++ b/module/Core/test/Exception/ForbiddenTagOperationExceptionTest.php @@ -0,0 +1,37 @@ +assertExceptionShape($e, $expectedMessage); + } + + private function assertExceptionShape(ForbiddenTagOperationException $e, string $expectedMessage): void + { + self::assertEquals($expectedMessage, $e->getMessage()); + self::assertEquals($expectedMessage, $e->getDetail()); + self::assertEquals('Forbidden tag operation', $e->getTitle()); + self::assertEquals('FORBIDDEN_OPERATION', $e->getType()); + self::assertEquals(403, $e->getStatus()); + } + + public function provideExceptions(): iterable + { + yield 'deletion' => [ForbiddenTagOperationException::forDeletion(), 'You are not allowed to delete tags']; + yield 'renaming' => [ForbiddenTagOperationException::forRenaming(), 'You are not allowed to rename tags']; + } +} diff --git a/module/Core/test/Service/Tag/TagServiceTest.php b/module/Core/test/Service/Tag/TagServiceTest.php index 842ce9a2..670944f1 100644 --- a/module/Core/test/Service/Tag/TagServiceTest.php +++ b/module/Core/test/Service/Tag/TagServiceTest.php @@ -10,12 +10,15 @@ use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\Tag; +use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException; use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Repository\TagRepository; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use Shlinkio\Shlink\Core\Tag\TagService; +use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class TagServiceTest extends TestCase { @@ -29,7 +32,7 @@ class TagServiceTest extends TestCase { $this->em = $this->prophesize(EntityManagerInterface::class); $this->repo = $this->prophesize(TagRepository::class); - $this->em->getRepository(Tag::class)->willReturn($this->repo->reveal())->shouldBeCalled(); + $this->em->getRepository(Tag::class)->willReturn($this->repo->reveal()); $this->service = new TagService($this->em->reveal()); } @@ -60,16 +63,31 @@ class TagServiceTest extends TestCase $find->shouldHaveBeenCalled(); } - /** @test */ - public function deleteTagsDelegatesOnRepository(): void + /** + * @test + * @dataProvider provideAdminApiKeys + */ + public function deleteTagsDelegatesOnRepository(?ApiKey $apiKey): void { $delete = $this->repo->deleteByName(['foo', 'bar'])->willReturn(4); - $this->service->deleteTags(['foo', 'bar']); + $this->service->deleteTags(['foo', 'bar'], $apiKey); $delete->shouldHaveBeenCalled(); } + /** @test */ + public function deleteTagsThrowsExceptionWhenProvidedApiKeyIsNotAdmin(): void + { + $delete = $this->repo->deleteByName(['foo', 'bar']); + + $this->expectException(ForbiddenTagOperationException::class); + $this->expectExceptionMessage('You are not allowed to delete tags'); + $delete->shouldNotBeCalled(); + + $this->service->deleteTags(['foo', 'bar'], new ApiKey(null, [RoleDefinition::forAuthoredShortUrls()])); + } + /** @test */ public function createTagsPersistsEntities(): void { @@ -85,15 +103,18 @@ class TagServiceTest extends TestCase $flush->shouldHaveBeenCalled(); } - /** @test */ - public function renameInvalidTagThrowsException(): void + /** + * @test + * @dataProvider provideAdminApiKeys + */ + public function renameInvalidTagThrowsException(?ApiKey $apiKey): void { $find = $this->repo->findOneBy(Argument::cetera())->willReturn(null); $find->shouldBeCalled(); $this->expectException(TagNotFoundException::class); - $this->service->renameTag(TagRenaming::fromNames('foo', 'bar')); + $this->service->renameTag(TagRenaming::fromNames('foo', 'bar'), $apiKey); } /** @@ -123,8 +144,11 @@ class TagServiceTest extends TestCase yield 'different names names' => ['foo', 'bar', 0]; } - /** @test */ - public function renameTagToAnExistingNameThrowsException(): void + /** + * @test + * @dataProvider provideAdminApiKeys + */ + public function renameTagToAnExistingNameThrowsException(?ApiKey $apiKey): void { $find = $this->repo->findOneBy(Argument::cetera())->willReturn(new Tag('foo')); $countTags = $this->repo->count(Argument::cetera())->willReturn(1); @@ -135,6 +159,27 @@ class TagServiceTest extends TestCase $flush->shouldNotBeCalled(); $this->expectException(TagConflictException::class); - $this->service->renameTag(TagRenaming::fromNames('foo', 'bar')); + $this->service->renameTag(TagRenaming::fromNames('foo', 'bar'), $apiKey); + } + + public function provideAdminApiKeys(): iterable + { + yield 'no API key' => [null]; + yield 'admin API key' => [new ApiKey()]; + } + + /** @test */ + public function renamingTagThrowsExceptionWhenProvidedApiKeyIsNotAdmin(): void + { + $getRepo = $this->em->getRepository(Tag::class); + + $this->expectExceptionMessage(ForbiddenTagOperationException::class); + $this->expectExceptionMessage('You are not allowed to rename tags'); + $getRepo->shouldNotBeCalled(); + + $this->service->renameTag( + TagRenaming::fromNames('foo', 'bar'), + new ApiKey(null, [RoleDefinition::forAuthoredShortUrls()]), + ); } } diff --git a/module/Rest/src/Action/Tag/DeleteTagsAction.php b/module/Rest/src/Action/Tag/DeleteTagsAction.php index aea11a41..b1be8af5 100644 --- a/module/Rest/src/Action/Tag/DeleteTagsAction.php +++ b/module/Rest/src/Action/Tag/DeleteTagsAction.php @@ -9,6 +9,7 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; +use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class DeleteTagsAction extends AbstractRestAction { @@ -26,8 +27,9 @@ class DeleteTagsAction extends AbstractRestAction { $query = $request->getQueryParams(); $tags = $query['tags'] ?? []; + $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); - $this->tagService->deleteTags($tags); + $this->tagService->deleteTags($tags, $apiKey); return new EmptyResponse(); } } diff --git a/module/Rest/src/Action/Tag/UpdateTagAction.php b/module/Rest/src/Action/Tag/UpdateTagAction.php index 77431798..d83d8b9a 100644 --- a/module/Rest/src/Action/Tag/UpdateTagAction.php +++ b/module/Rest/src/Action/Tag/UpdateTagAction.php @@ -10,6 +10,7 @@ use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; +use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class UpdateTagAction extends AbstractRestAction { @@ -23,17 +24,12 @@ class UpdateTagAction extends AbstractRestAction $this->tagService = $tagService; } - /** - * Process an incoming server request and return a response, optionally delegating - * to the next middleware component to create the response. - * - * - * @throws \InvalidArgumentException - */ public function handle(ServerRequestInterface $request): ResponseInterface { $body = $request->getParsedBody(); - $this->tagService->renameTag(TagRenaming::fromArray($body)); + $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); + + $this->tagService->renameTag(TagRenaming::fromArray($body), $apiKey); return new EmptyResponse(); } } diff --git a/module/Rest/test/Action/Tag/DeleteTagsActionTest.php b/module/Rest/test/Action/Tag/DeleteTagsActionTest.php index b167ee2c..957c01a5 100644 --- a/module/Rest/test/Action/Tag/DeleteTagsActionTest.php +++ b/module/Rest/test/Action/Tag/DeleteTagsActionTest.php @@ -6,10 +6,12 @@ namespace ShlinkioTest\Shlink\Rest\Action\Tag; use Laminas\Diactoros\ServerRequest; use PHPUnit\Framework\TestCase; +use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\Tag\DeleteTagsAction; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class DeleteTagsActionTest extends TestCase { @@ -30,8 +32,10 @@ class DeleteTagsActionTest extends TestCase */ public function processDelegatesIntoService(?array $tags): void { - $request = (new ServerRequest())->withQueryParams(['tags' => $tags]); - $deleteTags = $this->tagService->deleteTags($tags ?: []); + $request = (new ServerRequest()) + ->withQueryParams(['tags' => $tags]) + ->withAttribute(ApiKey::class, new ApiKey()); + $deleteTags = $this->tagService->deleteTags($tags ?: [], Argument::type(ApiKey::class)); $response = $this->action->handle($request); diff --git a/module/Rest/test/Action/Tag/UpdateTagActionTest.php b/module/Rest/test/Action/Tag/UpdateTagActionTest.php index 8546312f..681e68f6 100644 --- a/module/Rest/test/Action/Tag/UpdateTagActionTest.php +++ b/module/Rest/test/Action/Tag/UpdateTagActionTest.php @@ -4,15 +4,18 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Rest\Action\Tag; -use Laminas\Diactoros\ServerRequest; +use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\TestCase; +use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; +use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\Tag\UpdateTagAction; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class UpdateTagActionTest extends TestCase { @@ -33,7 +36,7 @@ class UpdateTagActionTest extends TestCase */ public function whenInvalidParamsAreProvidedAnErrorIsReturned(array $bodyParams): void { - $request = (new ServerRequest())->withParsedBody($bodyParams); + $request = $this->requestWithApiKey()->withParsedBody($bodyParams); $this->expectException(ValidationException::class); @@ -50,15 +53,23 @@ class UpdateTagActionTest extends TestCase /** @test */ public function correctInvocationRenamesTag(): void { - $request = (new ServerRequest())->withParsedBody([ + $request = $this->requestWithApiKey()->withParsedBody([ 'oldName' => 'foo', 'newName' => 'bar', ]); - $rename = $this->tagService->renameTag(TagRenaming::fromNames('foo', 'bar'))->willReturn(new Tag('bar')); + $rename = $this->tagService->renameTag( + TagRenaming::fromNames('foo', 'bar'), + Argument::type(ApiKey::class), + )->willReturn(new Tag('bar')); $resp = $this->action->handle($request); self::assertEquals(204, $resp->getStatusCode()); $rename->shouldHaveBeenCalled(); } + + private function requestWithApiKey(): ServerRequestInterface + { + return ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, new ApiKey()); + } } From b0c4582f3fdaddb665904feda27ab8798bec8b5a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 9 Jan 2021 10:56:02 +0100 Subject: [PATCH 27/46] Used EntitySpecificationRepository as default entity repository --- composer.json | 2 +- config/autoload/entity-manager.global.php | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 3619f89f..17928a2f 100644 --- a/composer.json +++ b/composer.json @@ -47,7 +47,7 @@ "predis/predis": "^1.1", "pugx/shortid-php": "^0.7", "ramsey/uuid": "^3.9", - "shlinkio/shlink-common": "dev-main#2963395 as 3.4", + "shlinkio/shlink-common": "dev-main#1311861 as 3.4", "shlinkio/shlink-config": "^1.0", "shlinkio/shlink-event-dispatcher": "^1.6", "shlinkio/shlink-importer": "^2.1", diff --git a/config/autoload/entity-manager.global.php b/config/autoload/entity-manager.global.php index c08f66f2..639df7ec 100644 --- a/config/autoload/entity-manager.global.php +++ b/config/autoload/entity-manager.global.php @@ -4,12 +4,15 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Common; +use Happyr\DoctrineSpecification\EntitySpecificationRepository; + return [ 'entity_manager' => [ 'orm' => [ 'proxies_dir' => 'data/proxies', 'load_mappings_using_functional_style' => true, + 'default_repository_classname' => EntitySpecificationRepository::class, ], 'connection' => [ 'user' => '', From caa1ae0de8c08399a0c6b4c30e6ae912ef1b3cc2 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 9 Jan 2021 12:38:06 +0100 Subject: [PATCH 28/46] Added all missing unit tests covering API key permissions --- module/Core/test/Domain/DomainServiceTest.php | 45 ++++++++++-- .../VisitsForTagPaginatorAdapterTest.php | 27 ++++--- .../Adapter/VisitsPaginatorAdapterTest.php | 27 ++++--- .../Service/ShortUrl/ShortUrlResolverTest.php | 31 +++++--- .../Core/test/Service/ShortUrlServiceTest.php | 29 +++++--- .../Core/test/Service/Tag/TagServiceTest.php | 11 +-- .../Core/test/Service/VisitsTrackerTest.php | 40 +++++++---- module/Rest/test/ApiKey/RoleTest.php | 72 +++++++++++++++++++ 8 files changed, 222 insertions(+), 60 deletions(-) create mode 100644 module/Rest/test/ApiKey/RoleTest.php diff --git a/module/Core/test/Domain/DomainServiceTest.php b/module/Core/test/Domain/DomainServiceTest.php index 0201d7d9..5fc4aaaa 100644 --- a/module/Core/test/Domain/DomainServiceTest.php +++ b/module/Core/test/Domain/DomainServiceTest.php @@ -13,6 +13,8 @@ use Shlinkio\Shlink\Core\Domain\Model\DomainItem; use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface; use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Exception\DomainNotFoundException; +use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class DomainServiceTest extends TestCase { @@ -31,13 +33,13 @@ class DomainServiceTest extends TestCase * @test * @dataProvider provideExcludedDomains */ - public function listDomainsDelegatesIntoRepository(array $domains, array $expectedResult): void + public function listDomainsDelegatesIntoRepository(array $domains, array $expectedResult, ?ApiKey $apiKey): void { $repo = $this->prophesize(DomainRepositoryInterface::class); $getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal()); - $findDomains = $repo->findDomainsWithout('default.com', null)->willReturn($domains); + $findDomains = $repo->findDomainsWithout('default.com', $apiKey)->willReturn($domains); - $result = $this->domainService->listDomains(); + $result = $this->domainService->listDomains($apiKey); self::assertEquals($expectedResult, $result); $getRepo->shouldHaveBeenCalledOnce(); @@ -47,12 +49,43 @@ class DomainServiceTest extends TestCase public function provideExcludedDomains(): iterable { $default = new DomainItem('default.com', true); + $adminApiKey = new ApiKey(); + $domainSpecificApiKey = new ApiKey(null, [RoleDefinition::forDomain('123')]); - yield 'empty list' => [[], [$default]]; - yield 'one item' => [[new Domain('bar.com')], [$default, new DomainItem('bar.com', false)]]; - yield 'multiple items' => [ + yield 'empty list without API key' => [[], [$default], null]; + yield 'one item without API key' => [ + [new Domain('bar.com')], + [$default, new DomainItem('bar.com', false)], + null, + ]; + yield 'multiple items without API key' => [ [new Domain('foo.com'), new Domain('bar.com')], [$default, new DomainItem('foo.com', false), new DomainItem('bar.com', false)], + null, + ]; + + yield 'empty list with admin API key' => [[], [$default], $adminApiKey]; + yield 'one item with admin API key' => [ + [new Domain('bar.com')], + [$default, new DomainItem('bar.com', false)], + $adminApiKey, + ]; + yield 'multiple items with admin API key' => [ + [new Domain('foo.com'), new Domain('bar.com')], + [$default, new DomainItem('foo.com', false), new DomainItem('bar.com', false)], + $adminApiKey, + ]; + + yield 'empty list with domain-specific API key' => [[], [], $domainSpecificApiKey]; + yield 'one item with domain-specific API key' => [ + [new Domain('bar.com')], + [new DomainItem('bar.com', false)], + $domainSpecificApiKey, + ]; + yield 'multiple items with domain-specific API key' => [ + [new Domain('foo.com'), new Domain('bar.com')], + [new DomainItem('foo.com', false), new DomainItem('bar.com', false)], + $domainSpecificApiKey, ]; } diff --git a/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php b/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php index 8d577b91..a0bc6405 100644 --- a/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php +++ b/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php @@ -11,23 +11,17 @@ use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsForTagPaginatorAdapter; use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class VisitsForTagPaginatorAdapterTest extends TestCase { use ProphecyTrait; - private VisitsForTagPaginatorAdapter $adapter; private ObjectProphecy $repo; protected function setUp(): void { $this->repo = $this->prophesize(VisitRepositoryInterface::class); - $this->adapter = new VisitsForTagPaginatorAdapter( - $this->repo->reveal(), - 'foo', - VisitsParams::fromRawData([]), - null, - ); } /** @test */ @@ -36,10 +30,11 @@ class VisitsForTagPaginatorAdapterTest extends TestCase $count = 3; $limit = 1; $offset = 5; + $adapter = $this->createAdapter(null); $findVisits = $this->repo->findVisitsByTag('foo', new DateRange(), $limit, $offset, null)->willReturn([]); for ($i = 0; $i < $count; $i++) { - $this->adapter->getItems($offset, $limit); + $adapter->getItems($offset, $limit); } $findVisits->shouldHaveBeenCalledTimes($count); @@ -49,12 +44,24 @@ class VisitsForTagPaginatorAdapterTest extends TestCase public function repoIsCalledOnlyOnceForCount(): void { $count = 3; - $countVisits = $this->repo->countVisitsByTag('foo', new DateRange(), null)->willReturn(3); + $apiKey = new ApiKey(); + $adapter = $this->createAdapter($apiKey); + $countVisits = $this->repo->countVisitsByTag('foo', new DateRange(), $apiKey->spec())->willReturn(3); for ($i = 0; $i < $count; $i++) { - $this->adapter->count(); + $adapter->count(); } $countVisits->shouldHaveBeenCalledOnce(); } + + private function createAdapter(?ApiKey $apiKey): VisitsForTagPaginatorAdapter + { + return new VisitsForTagPaginatorAdapter( + $this->repo->reveal(), + 'foo', + VisitsParams::fromRawData([]), + $apiKey, + ); + } } diff --git a/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php b/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php index ca0c5806..76ccc220 100644 --- a/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php +++ b/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php @@ -12,23 +12,17 @@ use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsPaginatorAdapter; use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class VisitsPaginatorAdapterTest extends TestCase { use ProphecyTrait; - private VisitsPaginatorAdapter $adapter; private ObjectProphecy $repo; protected function setUp(): void { $this->repo = $this->prophesize(VisitRepositoryInterface::class); - $this->adapter = new VisitsPaginatorAdapter( - $this->repo->reveal(), - new ShortUrlIdentifier(''), - VisitsParams::fromRawData([]), - null, - ); } /** @test */ @@ -37,12 +31,13 @@ class VisitsPaginatorAdapterTest extends TestCase $count = 3; $limit = 1; $offset = 5; + $adapter = $this->createAdapter(null); $findVisits = $this->repo->findVisitsByShortCode('', null, new DateRange(), $limit, $offset, null)->willReturn( [], ); for ($i = 0; $i < $count; $i++) { - $this->adapter->getItems($offset, $limit); + $adapter->getItems($offset, $limit); } $findVisits->shouldHaveBeenCalledTimes($count); @@ -52,12 +47,24 @@ class VisitsPaginatorAdapterTest extends TestCase public function repoIsCalledOnlyOnceForCount(): void { $count = 3; - $countVisits = $this->repo->countVisitsByShortCode('', null, new DateRange(), null)->willReturn(3); + $apiKey = new ApiKey(); + $adapter = $this->createAdapter($apiKey); + $countVisits = $this->repo->countVisitsByShortCode('', null, new DateRange(), $apiKey->spec())->willReturn(3); for ($i = 0; $i < $count; $i++) { - $this->adapter->count(); + $adapter->count(); } $countVisits->shouldHaveBeenCalledOnce(); } + + private function createAdapter(?ApiKey $apiKey): VisitsPaginatorAdapter + { + return new VisitsPaginatorAdapter( + $this->repo->reveal(), + new ShortUrlIdentifier(''), + VisitsParams::fromRawData([]), + $apiKey !== null ? $apiKey->spec() : null, + ); + } } diff --git a/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php b/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php index e9ff7a51..54d2eeda 100644 --- a/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php +++ b/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php @@ -18,6 +18,7 @@ use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolver; +use Shlinkio\Shlink\Rest\Entity\ApiKey; use function Functional\map; use function range; @@ -35,37 +36,49 @@ class ShortUrlResolverTest extends TestCase $this->urlResolver = new ShortUrlResolver($this->em->reveal()); } - /** @test */ - public function shortCodeIsProperlyParsed(): void + /** + * @test + * @dataProvider provideApiKeys + */ + public function shortCodeIsProperlyParsed(?ApiKey $apiKey): void { $shortUrl = new ShortUrl('expected_url'); $shortCode = $shortUrl->getShortCode(); $repo = $this->prophesize(ShortUrlRepositoryInterface::class); - $findOne = $repo->findOne($shortCode, null, null)->willReturn($shortUrl); + $findOne = $repo->findOne($shortCode, null, $apiKey !== null ? $apiKey->spec() : null)->willReturn($shortUrl); $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); - $result = $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode)); + $result = $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode), $apiKey); self::assertSame($shortUrl, $result); $findOne->shouldHaveBeenCalledOnce(); $getRepo->shouldHaveBeenCalledOnce(); } - /** @test */ - public function exceptionIsThrownIfShortcodeIsNotFound(): void + /** + * @test + * @dataProvider provideApiKeys + */ + public function exceptionIsThrownIfShortcodeIsNotFound(?ApiKey $apiKey): void { $shortCode = 'abc123'; $repo = $this->prophesize(ShortUrlRepositoryInterface::class); - $findOne = $repo->findOne($shortCode, null, null)->willReturn(null); - $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); + $findOne = $repo->findOne($shortCode, null, $apiKey !== null ? $apiKey->spec() : null)->willReturn(null); + $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal(), $apiKey); $this->expectException(ShortUrlNotFoundException::class); $findOne->shouldBeCalledOnce(); $getRepo->shouldBeCalledOnce(); - $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode)); + $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode), $apiKey); + } + + public function provideApiKeys(): iterable + { + yield 'no API key' => [null]; + yield 'API key' => [new ApiKey()]; } /** @test */ diff --git a/module/Core/test/Service/ShortUrlServiceTest.php b/module/Core/test/Service/ShortUrlServiceTest.php index 19c92b6f..bb2f685a 100644 --- a/module/Core/test/Service/ShortUrlServiceTest.php +++ b/module/Core/test/Service/ShortUrlServiceTest.php @@ -49,8 +49,11 @@ class ShortUrlServiceTest extends TestCase ); } - /** @test */ - public function listedUrlsAreReturnedFromEntityManager(): void + /** + * @test + * @dataProvider provideApiKeys + */ + public function listedUrlsAreReturnedFromEntityManager(?ApiKey $apiKey): void { $list = [ new ShortUrl(''), @@ -64,25 +67,35 @@ class ShortUrlServiceTest extends TestCase $repo->countList(Argument::cetera())->willReturn(count($list))->shouldBeCalledOnce(); $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); - $list = $this->service->listShortUrls(ShortUrlsParams::emptyInstance()); + $list = $this->service->listShortUrls(ShortUrlsParams::emptyInstance(), $apiKey); self::assertEquals(4, $list->getCurrentItemCount()); } - /** @test */ - public function providedTagsAreGetFromRepoAndSetToTheShortUrl(): void + /** + * @test + * @dataProvider provideApiKeys + */ + public function providedTagsAreGetFromRepoAndSetToTheShortUrl(?ApiKey $apiKey): void { $shortUrl = $this->prophesize(ShortUrl::class); $shortUrl->setTags(Argument::any())->shouldBeCalledOnce(); $shortCode = 'abc123'; - $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode), null)->willReturn($shortUrl->reveal()) - ->shouldBeCalledOnce(); + $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode), $apiKey) + ->willReturn($shortUrl->reveal()) + ->shouldBeCalledOnce(); $tagRepo = $this->prophesize(EntityRepository::class); $tagRepo->findOneBy(['name' => 'foo'])->willReturn(new Tag('foo'))->shouldBeCalledOnce(); $tagRepo->findOneBy(['name' => 'bar'])->willReturn(null)->shouldBeCalledOnce(); $this->em->getRepository(Tag::class)->willReturn($tagRepo->reveal()); - $this->service->setTagsByShortCode(new ShortUrlIdentifier($shortCode), ['foo', 'bar']); + $this->service->setTagsByShortCode(new ShortUrlIdentifier($shortCode), ['foo', 'bar'], $apiKey); + } + + public function provideApiKeys(): iterable + { + yield 'no API key' => [null]; + yield 'API key' => [new ApiKey()]; } /** diff --git a/module/Core/test/Service/Tag/TagServiceTest.php b/module/Core/test/Service/Tag/TagServiceTest.php index 670944f1..c0ef8760 100644 --- a/module/Core/test/Service/Tag/TagServiceTest.php +++ b/module/Core/test/Service/Tag/TagServiceTest.php @@ -50,14 +50,17 @@ class TagServiceTest extends TestCase $match->shouldHaveBeenCalled(); } - /** @test */ - public function tagsInfoDelegatesOnRepository(): void + /** + * @test + * @dataProvider provideAdminApiKeys + */ + public function tagsInfoDelegatesOnRepository(?ApiKey $apiKey): void { $expected = [new TagInfo(new Tag('foo'), 1, 1), new TagInfo(new Tag('bar'), 3, 10)]; - $find = $this->repo->findTagsWithInfo(null)->willReturn($expected); + $find = $this->repo->findTagsWithInfo($apiKey === null ? null : $apiKey->spec())->willReturn($expected); - $result = $this->service->tagsInfo(); + $result = $this->service->tagsInfo($apiKey); self::assertEquals($expected, $result); $find->shouldHaveBeenCalled(); diff --git a/module/Core/test/Service/VisitsTrackerTest.php b/module/Core/test/Service/VisitsTrackerTest.php index 56478966..38facd24 100644 --- a/module/Core/test/Service/VisitsTrackerTest.php +++ b/module/Core/test/Service/VisitsTrackerTest.php @@ -43,7 +43,7 @@ class VisitsTrackerTest extends TestCase $this->em = $this->prophesize(EntityManager::class); $this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class); - $this->visitsTracker = new VisitsTracker($this->em->reveal(), $this->eventDispatcher->reveal(), true); + $this->visitsTracker = new VisitsTracker($this->em->reveal(), $this->eventDispatcher->reveal(), true); } /** @test */ @@ -59,23 +59,27 @@ class VisitsTrackerTest extends TestCase $this->eventDispatcher->dispatch(Argument::type(ShortUrlVisited::class))->shouldHaveBeenCalled(); } - /** @test */ - public function infoReturnsVisitsForCertainShortCode(): void + /** + * @test + * @dataProvider provideApiKeys + */ + public function infoReturnsVisitsForCertainShortCode(?ApiKey $apiKey): void { $shortCode = '123ABC'; + $spec = $apiKey === null ? null : $apiKey->spec(); $repo = $this->prophesize(ShortUrlRepositoryInterface::class); - $count = $repo->shortCodeIsInUse($shortCode, null, null)->willReturn(true); + $count = $repo->shortCodeIsInUse($shortCode, null, $spec)->willReturn(true); $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce(); $list = map(range(0, 1), fn () => new Visit(new ShortUrl(''), Visitor::emptyInstance())); $repo2 = $this->prophesize(VisitRepository::class); - $repo2->findVisitsByShortCode($shortCode, null, Argument::type(DateRange::class), 1, 0, null)->willReturn( + $repo2->findVisitsByShortCode($shortCode, null, Argument::type(DateRange::class), 1, 0, $spec)->willReturn( $list, ); - $repo2->countVisitsByShortCode($shortCode, null, Argument::type(DateRange::class), null)->willReturn(1); + $repo2->countVisitsByShortCode($shortCode, null, Argument::type(DateRange::class), $spec)->willReturn(1); $this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce(); - $paginator = $this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams()); + $paginator = $this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams(), $apiKey); self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentItems())); $count->shouldHaveBeenCalledOnce(); @@ -111,24 +115,34 @@ class VisitsTrackerTest extends TestCase $this->visitsTracker->visitsForTag($tag, new VisitsParams(), $apiKey); } - /** @test */ - public function visitsForTagAreReturnedAsExpected(): void + /** + * @test + * @dataProvider provideApiKeys + */ + public function visitsForTagAreReturnedAsExpected(?ApiKey $apiKey): void { $tag = 'foo'; $repo = $this->prophesize(TagRepository::class); - $tagExists = $repo->tagExists($tag, null)->willReturn(true); + $tagExists = $repo->tagExists($tag, $apiKey)->willReturn(true); $getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal()); + $spec = $apiKey === null ? null : $apiKey->spec(); $list = map(range(0, 1), fn () => new Visit(new ShortUrl(''), Visitor::emptyInstance())); $repo2 = $this->prophesize(VisitRepository::class); - $repo2->findVisitsByTag($tag, Argument::type(DateRange::class), 1, 0, null)->willReturn($list); - $repo2->countVisitsByTag($tag, Argument::type(DateRange::class), null)->willReturn(1); + $repo2->findVisitsByTag($tag, Argument::type(DateRange::class), 1, 0, $spec)->willReturn($list); + $repo2->countVisitsByTag($tag, Argument::type(DateRange::class), $spec)->willReturn(1); $this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce(); - $paginator = $this->visitsTracker->visitsForTag($tag, new VisitsParams()); + $paginator = $this->visitsTracker->visitsForTag($tag, new VisitsParams(), $apiKey); self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentItems())); $tagExists->shouldHaveBeenCalledOnce(); $getRepo->shouldHaveBeenCalledOnce(); } + + public function provideApiKeys(): iterable + { + yield 'no API key' => [null]; + yield 'API key' => [new ApiKey()]; + } } diff --git a/module/Rest/test/ApiKey/RoleTest.php b/module/Rest/test/ApiKey/RoleTest.php new file mode 100644 index 00000000..b2dead47 --- /dev/null +++ b/module/Rest/test/ApiKey/RoleTest.php @@ -0,0 +1,72 @@ + [new ApiKeyRole('invalid', [], $apiKey), true, Spec::andX()]; + yield 'not inline invalid role' => [new ApiKeyRole('invalid', [], $apiKey), false, Spec::andX()]; + yield 'inline author role' => [ + new ApiKeyRole(Role::AUTHORED_SHORT_URLS, [], $apiKey), + true, + new BelongsToApiKeyInlined($apiKey), + ]; + yield 'not inline author role' => [ + new ApiKeyRole(Role::AUTHORED_SHORT_URLS, [], $apiKey), + false, + new BelongsToApiKey($apiKey), + ]; + yield 'inline domain role' => [ + new ApiKeyRole(Role::DOMAIN_SPECIFIC, ['domain_id' => '123'], $apiKey), + true, + new BelongsToDomainInlined('123'), + ]; + yield 'not inline domain role' => [ + new ApiKeyRole(Role::DOMAIN_SPECIFIC, ['domain_id' => '456'], $apiKey), + false, + new BelongsToDomain('456'), + ]; + } + + /** + * @test + * @dataProvider provideMetas + */ + public function getsExpectedDomainIdFromMeta(array $meta, string $expectedDomainId): void + { + self::assertEquals($expectedDomainId, Role::domainIdFromMeta($meta)); + } + + public function provideMetas(): iterable + { + yield 'empty meta' => [[], '-1']; + yield 'meta without domain_id' => [['foo' => 'bar'], '-1']; + yield 'meta with domain_id' => [['domain_id' => '123'], '123']; + } +} From bef1b13a3330b968572cda2d40c59562f3ef3cd9 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 9 Jan 2021 13:16:33 +0100 Subject: [PATCH 29/46] Enhanced DomainRepositoryTest covering API key permissions --- .../Repository/DomainRepositoryTest.php | 59 ++++++++++++++----- module/Rest/src/Entity/ApiKey.php | 2 +- 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php index c553821e..d377e326 100644 --- a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php +++ b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php @@ -9,12 +9,13 @@ use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface; +use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase; class DomainRepositoryTest extends DatabaseTestCase { - protected const ENTITIES_TO_EMPTY = [ShortUrl::class, Domain::class]; + protected const ENTITIES_TO_EMPTY = [ShortUrl::class, Domain::class, ApiKey::class]; private DomainRepository $repo; @@ -28,18 +29,15 @@ class DomainRepositoryTest extends DatabaseTestCase { $fooDomain = new Domain('foo.com'); $this->getEntityManager()->persist($fooDomain); - $fooShortUrl = $this->createShortUrl($fooDomain); - $this->getEntityManager()->persist($fooShortUrl); + $this->getEntityManager()->persist($this->createShortUrl($fooDomain)); $barDomain = new Domain('bar.com'); $this->getEntityManager()->persist($barDomain); - $barShortUrl = $this->createShortUrl($barDomain); - $this->getEntityManager()->persist($barShortUrl); + $this->getEntityManager()->persist($this->createShortUrl($barDomain)); $bazDomain = new Domain('baz.com'); $this->getEntityManager()->persist($bazDomain); - $bazShortUrl = $this->createShortUrl($bazDomain); - $this->getEntityManager()->persist($bazShortUrl); + $this->getEntityManager()->persist($this->createShortUrl($bazDomain)); $detachedDomain = new Domain('detached.com'); $this->getEntityManager()->persist($detachedDomain); @@ -52,11 +50,49 @@ class DomainRepositoryTest extends DatabaseTestCase self::assertEquals([$barDomain, $fooDomain], $this->repo->findDomainsWithout('baz.com')); } - private function createShortUrl(Domain $domain): ShortUrl + /** @test */ + public function findDomainsReturnsJustThoseMatchingProvidedApiKey(): void + { + $authorApiKey = new ApiKey(null, [RoleDefinition::forAuthoredShortUrls()]); + $this->getEntityManager()->persist($authorApiKey); + $authorAndDomainApiKey = new ApiKey(null, [RoleDefinition::forAuthoredShortUrls()]); + $this->getEntityManager()->persist($authorAndDomainApiKey); + + $fooDomain = new Domain('foo.com'); + $this->getEntityManager()->persist($fooDomain); + $this->getEntityManager()->persist($this->createShortUrl($fooDomain, $authorApiKey)); + + $barDomain = new Domain('bar.com'); + $this->getEntityManager()->persist($barDomain); + $this->getEntityManager()->persist($this->createShortUrl($barDomain, $authorAndDomainApiKey)); + + $bazDomain = new Domain('baz.com'); + $this->getEntityManager()->persist($bazDomain); + $this->getEntityManager()->persist($this->createShortUrl($bazDomain, $authorApiKey)); + + $this->getEntityManager()->flush(); + + $authorAndDomainApiKey->registerRole(RoleDefinition::forDomain($fooDomain->getId())); + + $fooDomainApiKey = new ApiKey(null, [RoleDefinition::forDomain($fooDomain->getId())]); + $this->getEntityManager()->persist($fooDomainApiKey); + + $barDomainApiKey = new ApiKey(null, [RoleDefinition::forDomain($barDomain->getId())]); + $this->getEntityManager()->persist($fooDomainApiKey); + + $this->getEntityManager()->flush(); + + self::assertEquals([$fooDomain], $this->repo->findDomainsWithout(null, $fooDomainApiKey)); + self::assertEquals([$barDomain], $this->repo->findDomainsWithout(null, $barDomainApiKey)); + self::assertEquals([$bazDomain, $fooDomain], $this->repo->findDomainsWithout(null, $authorApiKey)); + self::assertEquals([], $this->repo->findDomainsWithout(null, $authorAndDomainApiKey)); + } + + private function createShortUrl(Domain $domain, ?ApiKey $apiKey = null): ShortUrl { return new ShortUrl( 'foo', - ShortUrlMeta::fromRawData(['domain' => $domain->getAuthority()]), + ShortUrlMeta::fromRawData(['domain' => $domain->getAuthority(), 'apiKey' => $apiKey]), new class ($domain) implements ShortUrlRelationResolverInterface { private Domain $domain; @@ -69,11 +105,6 @@ class DomainRepositoryTest extends DatabaseTestCase { return $this->domain; } - - public function resolveApiKey(?string $key): ?ApiKey - { - return null; - } }, ); } diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index 59ff502b..937e42f0 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -80,7 +80,7 @@ class ApiKey extends AbstractEntity public function spec(bool $inlined = false): Specification { - $specs = $this->roles->map(fn (ApiKeyRole $role) => Role::toSpec($role, $inlined)); + $specs = $this->roles->map(fn (ApiKeyRole $role) => Role::toSpec($role, $inlined))->getValues(); return Spec::andX(...$specs); } From ba32366b0b77aea911b75cebacc4877213f42a5d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 9 Jan 2021 13:44:47 +0100 Subject: [PATCH 30/46] Added tagExists to TagRepositoryTest --- .../test-db/Repository/TagRepositoryTest.php | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/module/Core/test-db/Repository/TagRepositoryTest.php b/module/Core/test-db/Repository/TagRepositoryTest.php index 9f8b9893..d066d8a8 100644 --- a/module/Core/test-db/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Repository/TagRepositoryTest.php @@ -5,11 +5,16 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Repository; use Doctrine\Common\Collections\ArrayCollection; +use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Visit; +use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Repository\TagRepository; +use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; +use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; +use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase; use function array_chunk; @@ -20,6 +25,8 @@ class TagRepositoryTest extends DatabaseTestCase Visit::class, ShortUrl::class, Tag::class, + ApiKey::class, + Domain::class, ]; private TagRepository $repo; @@ -97,4 +104,56 @@ class TagRepositoryTest extends DatabaseTestCase $result[3]->jsonSerialize(), ); } + + /** @test */ + public function tagExistsReturnsExpectedResultBasedOnApiKey(): void + { + $domain = new Domain('foo.com'); + $this->getEntityManager()->persist($domain); + $this->getEntityManager()->flush(); + + $authorApiKey = new ApiKey(null, [RoleDefinition::forAuthoredShortUrls()]); + $this->getEntityManager()->persist($authorApiKey); + $domainApiKey = new ApiKey(null, [RoleDefinition::forDomain($domain->getId())]); + $this->getEntityManager()->persist($domainApiKey); + + $names = ['foo', 'bar', 'baz', 'another']; + $tags = []; + foreach ($names as $name) { + $tag = new Tag($name); + $tags[] = $tag; + $this->getEntityManager()->persist($tag); + } + + [$firstUrlTags, $secondUrlTags] = array_chunk($tags, 3); + + $shortUrl = new ShortUrl('', ShortUrlMeta::fromRawData(['apiKey' => $authorApiKey])); + $shortUrl->setTags(new ArrayCollection($firstUrlTags)); + $this->getEntityManager()->persist($shortUrl); + + $shortUrl2 = new ShortUrl( + '', + ShortUrlMeta::fromRawData(['domain' => $domain->getAuthority()]), + new PersistenceShortUrlRelationResolver($this->getEntityManager()), + ); + $shortUrl2->setTags(new ArrayCollection($secondUrlTags)); + $this->getEntityManager()->persist($shortUrl2); + + $this->getEntityManager()->flush(); + + self::assertTrue($this->repo->tagExists('foo')); + self::assertTrue($this->repo->tagExists('bar')); + self::assertTrue($this->repo->tagExists('baz')); + self::assertTrue($this->repo->tagExists('another')); + + self::assertTrue($this->repo->tagExists('foo', $authorApiKey)); + self::assertTrue($this->repo->tagExists('bar', $authorApiKey)); + self::assertTrue($this->repo->tagExists('baz', $authorApiKey)); + self::assertFalse($this->repo->tagExists('another', $authorApiKey)); + + self::assertFalse($this->repo->tagExists('foo', $domainApiKey)); + self::assertFalse($this->repo->tagExists('bar', $domainApiKey)); + self::assertFalse($this->repo->tagExists('baz', $domainApiKey)); + self::assertTrue($this->repo->tagExists('another', $domainApiKey)); + } } From 01dceca9ef2761c58d5ff923f825de2c02c26aa1 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 9 Jan 2021 14:39:19 +0100 Subject: [PATCH 31/46] Enhanced ShorturlRepository::findOneMatching test to cover ApiKey use cases --- .../Repository/ShortUrlRepositoryTest.php | 87 +++++++++++++++++-- 1 file changed, 79 insertions(+), 8 deletions(-) diff --git a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php index 86eb2aa3..e0fa225a 100644 --- a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php @@ -16,8 +16,11 @@ use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; +use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; use Shlinkio\Shlink\Core\Util\TagManagerTrait; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; +use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; +use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase; use function count; @@ -31,6 +34,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase Visit::class, ShortUrl::class, Domain::class, + ApiKey::class, ]; private ShortUrlRepository $repo; @@ -308,17 +312,84 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); + $result = $this->repo->findOneMatching('foo', $tags, ShortUrlMeta::fromRawData($meta)); + + self::assertSame($shortUrl1, $result); + self::assertNotSame($shortUrl2, $result); + self::assertNotSame($shortUrl3, $result); + } + + /** @test */ + public function findOneMatchingAppliesProvidedApiKeyConditions(): void + { + $start = Chronos::parse('2020-03-05 20:18:30'); + + $wrongDomain = new Domain('wrong.com'); + $this->getEntityManager()->persist($wrongDomain); + $rightDomain = new Domain('right.com'); + $this->getEntityManager()->persist($rightDomain); + + $this->getEntityManager()->flush(); + + $apiKey = new ApiKey(null, [RoleDefinition::forAuthoredShortUrls()]); + $this->getEntityManager()->persist($apiKey); + $otherApiKey = new ApiKey(null, [RoleDefinition::forAuthoredShortUrls()]); + $this->getEntityManager()->persist($otherApiKey); + $wrongDomainApiKey = new ApiKey(null, [RoleDefinition::forDomain($wrongDomain->getId())]); + $this->getEntityManager()->persist($wrongDomainApiKey); + $rightDomainApiKey = new ApiKey(null, [RoleDefinition::forDomain($rightDomain->getId())]); + $this->getEntityManager()->persist($rightDomainApiKey); + + $shortUrl = new ShortUrl('foo', ShortUrlMeta::fromRawData( + ['validSince' => $start, 'apiKey' => $apiKey, 'domain' => $rightDomain->getAuthority()], + ), new PersistenceShortUrlRelationResolver($this->getEntityManager())); + $shortUrl->setTags($this->tagNamesToEntities($this->getEntityManager(), ['foo', 'bar'])); + $this->getEntityManager()->persist($shortUrl); + + $this->getEntityManager()->flush(); + self::assertSame( - $shortUrl1, - $this->repo->findOneMatching('foo', $tags, ShortUrlMeta::fromRawData($meta)), + $shortUrl, + $this->repo->findOneMatching('foo', ['foo', 'bar'], ShortUrlMeta::fromRawData(['validSince' => $start])), ); - self::assertNotSame( - $shortUrl2, - $this->repo->findOneMatching('foo', $tags, ShortUrlMeta::fromRawData($meta)), + self::assertSame($shortUrl, $this->repo->findOneMatching('foo', ['foo', 'bar'], ShortUrlMeta::fromRawData([ + 'validSince' => $start, + 'apiKey' => $apiKey, + ]))); + self::assertNull($this->repo->findOneMatching('foo', ['foo', 'bar'], ShortUrlMeta::fromRawData([ + 'validSince' => $start, + 'apiKey' => $otherApiKey, + ]))); + + self::assertSame( + $shortUrl, + $this->repo->findOneMatching('foo', ['foo', 'bar'], ShortUrlMeta::fromRawData([ + 'validSince' => $start, + 'domain' => $rightDomain->getAuthority(), + ])), ); - self::assertNotSame( - $shortUrl3, - $this->repo->findOneMatching('foo', $tags, ShortUrlMeta::fromRawData($meta)), + self::assertSame( + $shortUrl, + $this->repo->findOneMatching('foo', ['foo', 'bar'], ShortUrlMeta::fromRawData([ + 'validSince' => $start, + 'domain' => $rightDomain->getAuthority(), + 'apiKey' => $rightDomainApiKey, + ])), + ); + self::assertSame( + $shortUrl, + $this->repo->findOneMatching('foo', ['foo', 'bar'], ShortUrlMeta::fromRawData([ + 'validSince' => $start, + 'domain' => $rightDomain->getAuthority(), + 'apiKey' => $apiKey, + ])), + ); + self::assertNull( + $this->repo->findOneMatching('foo', ['foo', 'bar'], ShortUrlMeta::fromRawData([ + 'validSince' => $start, + 'domain' => $rightDomain->getAuthority(), + 'apiKey' => $wrongDomainApiKey, + ])), ); } From 14eeb91c5876360e684724065bc3e1ce5dbd5394 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 9 Jan 2021 17:54:04 +0100 Subject: [PATCH 32/46] Added db test for VisitRepository::countVisits --- .../Repository/DomainRepositoryTest.php | 8 +- .../Repository/ShortUrlRepositoryTest.php | 8 +- .../test-db/Repository/TagRepositoryTest.php | 4 +- .../Repository/VisitRepositoryTest.php | 87 ++++++++++++++----- module/Core/test/Domain/DomainServiceTest.php | 2 +- .../Core/test/Service/Tag/TagServiceTest.php | 4 +- module/Rest/src/Entity/ApiKey.php | 12 ++- 7 files changed, 87 insertions(+), 38 deletions(-) diff --git a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php index d377e326..74d5297e 100644 --- a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php +++ b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php @@ -53,9 +53,9 @@ class DomainRepositoryTest extends DatabaseTestCase /** @test */ public function findDomainsReturnsJustThoseMatchingProvidedApiKey(): void { - $authorApiKey = new ApiKey(null, [RoleDefinition::forAuthoredShortUrls()]); + $authorApiKey = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls()); $this->getEntityManager()->persist($authorApiKey); - $authorAndDomainApiKey = new ApiKey(null, [RoleDefinition::forAuthoredShortUrls()]); + $authorAndDomainApiKey = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls()); $this->getEntityManager()->persist($authorAndDomainApiKey); $fooDomain = new Domain('foo.com'); @@ -74,10 +74,10 @@ class DomainRepositoryTest extends DatabaseTestCase $authorAndDomainApiKey->registerRole(RoleDefinition::forDomain($fooDomain->getId())); - $fooDomainApiKey = new ApiKey(null, [RoleDefinition::forDomain($fooDomain->getId())]); + $fooDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($fooDomain->getId())); $this->getEntityManager()->persist($fooDomainApiKey); - $barDomainApiKey = new ApiKey(null, [RoleDefinition::forDomain($barDomain->getId())]); + $barDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($barDomain->getId())); $this->getEntityManager()->persist($fooDomainApiKey); $this->getEntityManager()->flush(); diff --git a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php index e0fa225a..a95308ff 100644 --- a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php @@ -331,13 +331,13 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); - $apiKey = new ApiKey(null, [RoleDefinition::forAuthoredShortUrls()]); + $apiKey = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls()); $this->getEntityManager()->persist($apiKey); - $otherApiKey = new ApiKey(null, [RoleDefinition::forAuthoredShortUrls()]); + $otherApiKey = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls()); $this->getEntityManager()->persist($otherApiKey); - $wrongDomainApiKey = new ApiKey(null, [RoleDefinition::forDomain($wrongDomain->getId())]); + $wrongDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($wrongDomain->getId())); $this->getEntityManager()->persist($wrongDomainApiKey); - $rightDomainApiKey = new ApiKey(null, [RoleDefinition::forDomain($rightDomain->getId())]); + $rightDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($rightDomain->getId())); $this->getEntityManager()->persist($rightDomainApiKey); $shortUrl = new ShortUrl('foo', ShortUrlMeta::fromRawData( diff --git a/module/Core/test-db/Repository/TagRepositoryTest.php b/module/Core/test-db/Repository/TagRepositoryTest.php index d066d8a8..e28b38fb 100644 --- a/module/Core/test-db/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Repository/TagRepositoryTest.php @@ -112,9 +112,9 @@ class TagRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($domain); $this->getEntityManager()->flush(); - $authorApiKey = new ApiKey(null, [RoleDefinition::forAuthoredShortUrls()]); + $authorApiKey = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls()); $this->getEntityManager()->persist($authorApiKey); - $domainApiKey = new ApiKey(null, [RoleDefinition::forDomain($domain->getId())]); + $domainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($domain->getId())); $this->getEntityManager()->persist($domainApiKey); $names = ['foo', 'bar', 'baz', 'another']; diff --git a/module/Core/test-db/Repository/VisitRepositoryTest.php b/module/Core/test-db/Repository/VisitRepositoryTest.php index f6df4b9b..516b1dd3 100644 --- a/module/Core/test-db/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Repository/VisitRepositoryTest.php @@ -15,7 +15,10 @@ use Shlinkio\Shlink\Core\Entity\VisitLocation; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Repository\VisitRepository; +use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; use Shlinkio\Shlink\IpGeolocation\Model\Location; +use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; +use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase; use function Functional\map; @@ -30,6 +33,7 @@ class VisitRepositoryTest extends DatabaseTestCase ShortUrl::class, Domain::class, Tag::class, + ApiKey::class, ]; private VisitRepository $repo; @@ -185,6 +189,49 @@ class VisitRepositoryTest extends DatabaseTestCase ))); } + /** @test */ + public function countReturnsExpectedResultBasedOnApiKey(): void + { + $domain = new Domain('foo.com'); + $this->getEntityManager()->persist($domain); + + $this->getEntityManager()->flush(); + + $apiKey1 = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls()); + $this->getEntityManager()->persist($apiKey1); + $shortUrl = new ShortUrl( + '', + ShortUrlMeta::fromRawData(['apiKey' => $apiKey1, 'domain' => $domain->getAuthority()]), + new PersistenceShortUrlRelationResolver($this->getEntityManager()), + ); + $this->getEntityManager()->persist($shortUrl); + $this->createVisitsForShortUrl($shortUrl, 4); + + $apiKey2 = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls()); + $this->getEntityManager()->persist($apiKey2); + $shortUrl2 = new ShortUrl('', ShortUrlMeta::fromRawData(['apiKey' => $apiKey2])); + $this->getEntityManager()->persist($shortUrl2); + $this->createVisitsForShortUrl($shortUrl2, 5); + + $shortUrl3 = new ShortUrl( + '', + ShortUrlMeta::fromRawData(['apiKey' => $apiKey2, 'domain' => $domain->getAuthority()]), + new PersistenceShortUrlRelationResolver($this->getEntityManager()), + ); + $this->getEntityManager()->persist($shortUrl3); + $this->createVisitsForShortUrl($shortUrl3, 7); + + $domainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($domain->getId())); + $this->getEntityManager()->persist($domainApiKey); + + $this->getEntityManager()->flush(); + + self::assertEquals(4 + 5 + 7, $this->repo->countVisits()); + self::assertEquals(4, $this->repo->countVisits($apiKey1)); + self::assertEquals(5 + 7, $this->repo->countVisits($apiKey2)); + self::assertEquals(4 + 7, $this->repo->countVisits($domainApiKey)); + } + private function createShortUrlsAndVisits(bool $withDomain = true): array { $shortUrl = new ShortUrl(''); @@ -192,7 +239,24 @@ class VisitRepositoryTest extends DatabaseTestCase $shortCode = $shortUrl->getShortCode(); $this->getEntityManager()->persist($shortUrl); - for ($i = 0; $i < 6; $i++) { + $this->createVisitsForShortUrl($shortUrl); + + if ($withDomain) { + $shortUrlWithDomain = new ShortUrl('', ShortUrlMeta::fromRawData([ + 'customSlug' => $shortCode, + 'domain' => $domain, + ])); + $this->getEntityManager()->persist($shortUrlWithDomain); + $this->createVisitsForShortUrl($shortUrlWithDomain, 3); + $this->getEntityManager()->flush(); + } + + return [$shortCode, $domain, $shortUrl]; + } + + private function createVisitsForShortUrl(ShortUrl $shortUrl, int $amount = 6): void + { + for ($i = 0; $i < $amount; $i++) { $visit = new Visit( $shortUrl, Visitor::emptyInstance(), @@ -201,26 +265,5 @@ class VisitRepositoryTest extends DatabaseTestCase ); $this->getEntityManager()->persist($visit); } - - if ($withDomain) { - $shortUrlWithDomain = new ShortUrl('', ShortUrlMeta::fromRawData([ - 'customSlug' => $shortCode, - 'domain' => $domain, - ])); - $this->getEntityManager()->persist($shortUrlWithDomain); - - for ($i = 0; $i < 3; $i++) { - $visit = new Visit( - $shortUrlWithDomain, - Visitor::emptyInstance(), - true, - Chronos::parse(sprintf('2016-01-0%s', $i + 1)), - ); - $this->getEntityManager()->persist($visit); - } - $this->getEntityManager()->flush(); - } - - return [$shortCode, $domain, $shortUrl]; } } diff --git a/module/Core/test/Domain/DomainServiceTest.php b/module/Core/test/Domain/DomainServiceTest.php index 5fc4aaaa..7c21014c 100644 --- a/module/Core/test/Domain/DomainServiceTest.php +++ b/module/Core/test/Domain/DomainServiceTest.php @@ -50,7 +50,7 @@ class DomainServiceTest extends TestCase { $default = new DomainItem('default.com', true); $adminApiKey = new ApiKey(); - $domainSpecificApiKey = new ApiKey(null, [RoleDefinition::forDomain('123')]); + $domainSpecificApiKey = ApiKey::withRoles(RoleDefinition::forDomain('123')); yield 'empty list without API key' => [[], [$default], null]; yield 'one item without API key' => [ diff --git a/module/Core/test/Service/Tag/TagServiceTest.php b/module/Core/test/Service/Tag/TagServiceTest.php index c0ef8760..f1965439 100644 --- a/module/Core/test/Service/Tag/TagServiceTest.php +++ b/module/Core/test/Service/Tag/TagServiceTest.php @@ -88,7 +88,7 @@ class TagServiceTest extends TestCase $this->expectExceptionMessage('You are not allowed to delete tags'); $delete->shouldNotBeCalled(); - $this->service->deleteTags(['foo', 'bar'], new ApiKey(null, [RoleDefinition::forAuthoredShortUrls()])); + $this->service->deleteTags(['foo', 'bar'], ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls())); } /** @test */ @@ -182,7 +182,7 @@ class TagServiceTest extends TestCase $this->service->renameTag( TagRenaming::fromNames('foo', 'bar'), - new ApiKey(null, [RoleDefinition::forAuthoredShortUrls()]), + ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls()), ); } } diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index 937e42f0..f91a9732 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -24,19 +24,25 @@ class ApiKey extends AbstractEntity private Collection $roles; /** - * @param RoleDefinition[] $roleDefinitions * @throws Exception */ - public function __construct(?Chronos $expirationDate = null, array $roleDefinitions = []) + public function __construct(?Chronos $expirationDate = null) { $this->key = Uuid::uuid4()->toString(); $this->expirationDate = $expirationDate; $this->enabled = true; $this->roles = new ArrayCollection(); + } + + public static function withRoles(RoleDefinition ...$roleDefinitions): self + { + $apiKey = new self(); foreach ($roleDefinitions as $roleDefinition) { - $this->registerRole($roleDefinition); + $apiKey->registerRole($roleDefinition); } + + return $apiKey; } public function getExpirationDate(): ?Chronos From 380915948b8e637634d75d7e2769a52a1f0f59a8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 9 Jan 2021 18:00:08 +0100 Subject: [PATCH 33/46] Improved TagRepositoryTest --- module/Core/test-db/Repository/TagRepositoryTest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/module/Core/test-db/Repository/TagRepositoryTest.php b/module/Core/test-db/Repository/TagRepositoryTest.php index e28b38fb..8f9894cd 100644 --- a/module/Core/test-db/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Repository/TagRepositoryTest.php @@ -145,15 +145,18 @@ class TagRepositoryTest extends DatabaseTestCase self::assertTrue($this->repo->tagExists('bar')); self::assertTrue($this->repo->tagExists('baz')); self::assertTrue($this->repo->tagExists('another')); + self::assertFalse($this->repo->tagExists('invalid')); self::assertTrue($this->repo->tagExists('foo', $authorApiKey)); self::assertTrue($this->repo->tagExists('bar', $authorApiKey)); self::assertTrue($this->repo->tagExists('baz', $authorApiKey)); self::assertFalse($this->repo->tagExists('another', $authorApiKey)); + self::assertFalse($this->repo->tagExists('invalid', $authorApiKey)); self::assertFalse($this->repo->tagExists('foo', $domainApiKey)); self::assertFalse($this->repo->tagExists('bar', $domainApiKey)); self::assertFalse($this->repo->tagExists('baz', $domainApiKey)); self::assertTrue($this->repo->tagExists('another', $domainApiKey)); + self::assertFalse($this->repo->tagExists('invalid', $domainApiKey)); } } From f827186c770187c0910333ad339384ee4c3c0b86 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 10 Jan 2021 08:40:18 +0100 Subject: [PATCH 34/46] Updated API test fixtures to include API keys with roles --- composer.json | 2 +- module/Rest/src/Entity/ApiKey.php | 8 +++++ .../Rest/test-api/Fixtures/ApiKeyFixture.php | 31 ++++++++++++++----- .../Rest/test-api/Fixtures/DomainFixture.php | 7 +++-- .../test-api/Fixtures/ShortUrlsFixture.php | 28 ++++++++++++----- 5 files changed, 57 insertions(+), 19 deletions(-) diff --git a/composer.json b/composer.json index 17928a2f..88919e4a 100644 --- a/composer.json +++ b/composer.json @@ -71,7 +71,7 @@ "phpunit/phpunit": "^9.5", "roave/security-advisories": "dev-master", "shlinkio/php-coding-standard": "~2.1.1", - "shlinkio/shlink-test-utils": "^1.6", + "shlinkio/shlink-test-utils": "^1.7", "symfony/var-dumper": "^5.2", "veewee/composer-run-parallel": "^0.1.0" }, diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index f91a9732..4538829c 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -45,6 +45,14 @@ class ApiKey extends AbstractEntity return $apiKey; } + public static function withKey(string $key, ?Chronos $expirationDate = null): self + { + $apiKey = new self($expirationDate); + $apiKey->key = $key; + + return $apiKey; + } + public function getExpirationDate(): ?Chronos { return $this->expirationDate; diff --git a/module/Rest/test-api/Fixtures/ApiKeyFixture.php b/module/Rest/test-api/Fixtures/ApiKeyFixture.php index 971054fd..d0a1f802 100644 --- a/module/Rest/test-api/Fixtures/ApiKeyFixture.php +++ b/module/Rest/test-api/Fixtures/ApiKeyFixture.php @@ -5,28 +5,43 @@ declare(strict_types=1); namespace ShlinkioApiTest\Shlink\Rest\Fixtures; use Cake\Chronos\Chronos; -use Doctrine\Common\DataFixtures\FixtureInterface; +use Doctrine\Common\DataFixtures\AbstractFixture; +use Doctrine\Common\DataFixtures\DependentFixtureInterface; use Doctrine\Persistence\ObjectManager; -use ReflectionObject; +use Shlinkio\Shlink\Core\Entity\Domain; +use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\Entity\ApiKey; -class ApiKeyFixture implements FixtureInterface +class ApiKeyFixture extends AbstractFixture implements DependentFixtureInterface { + public function getDependencies(): array + { + return [DomainFixture::class]; + } + public function load(ObjectManager $manager): void { $manager->persist($this->buildApiKey('valid_api_key', true)); $manager->persist($this->buildApiKey('disabled_api_key', false)); $manager->persist($this->buildApiKey('expired_api_key', true, Chronos::now()->subDay())); + + $authorApiKey = $this->buildApiKey('author_api_key', true); + $authorApiKey->registerRole(RoleDefinition::forAuthoredShortUrls()); + $manager->persist($authorApiKey); + $this->addReference('author_api_key', $authorApiKey); + + /** @var Domain $exampleDomain */ + $exampleDomain = $this->getReference('example_domain'); + $domainApiKey = $this->buildApiKey('domain_api_key', true); + $domainApiKey->registerRole(RoleDefinition::forDomain($exampleDomain->getId())); + $manager->persist($domainApiKey); + $manager->flush(); } private function buildApiKey(string $key, bool $enabled, ?Chronos $expiresAt = null): ApiKey { - $apiKey = new ApiKey($expiresAt); - $refObj = new ReflectionObject($apiKey); - $keyProp = $refObj->getProperty('key'); - $keyProp->setAccessible(true); - $keyProp->setValue($apiKey, $key); + $apiKey = ApiKey::withKey($key, $expiresAt); if (! $enabled) { $apiKey->disable(); diff --git a/module/Rest/test-api/Fixtures/DomainFixture.php b/module/Rest/test-api/Fixtures/DomainFixture.php index 4c30b5b8..576586a6 100644 --- a/module/Rest/test-api/Fixtures/DomainFixture.php +++ b/module/Rest/test-api/Fixtures/DomainFixture.php @@ -12,8 +12,11 @@ class DomainFixture extends AbstractFixture { public function load(ObjectManager $manager): void { - $orphanDomain = new Domain('this_domain_is_detached.com'); - $manager->persist($orphanDomain); + $domain = new Domain('example.com'); + $manager->persist($domain); + $this->addReference('example_domain', $domain); + + $manager->persist(new Domain('this_domain_is_detached.com')); $manager->flush(); } } diff --git a/module/Rest/test-api/Fixtures/ShortUrlsFixture.php b/module/Rest/test-api/Fixtures/ShortUrlsFixture.php index 0aa13a82..3b4f2828 100644 --- a/module/Rest/test-api/Fixtures/ShortUrlsFixture.php +++ b/module/Rest/test-api/Fixtures/ShortUrlsFixture.php @@ -6,34 +6,45 @@ namespace ShlinkioApiTest\Shlink\Rest\Fixtures; use Cake\Chronos\Chronos; use Doctrine\Common\DataFixtures\AbstractFixture; +use Doctrine\Common\DataFixtures\DependentFixtureInterface; use Doctrine\Persistence\ObjectManager; use ReflectionObject; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; +use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; +use Shlinkio\Shlink\Rest\Entity\ApiKey; -class ShortUrlsFixture extends AbstractFixture +class ShortUrlsFixture extends AbstractFixture implements DependentFixtureInterface { - /** - * Load data fixtures with the passed EntityManager - * - */ + public function getDependencies(): array + { + return [ApiKeyFixture::class]; + } + public function load(ObjectManager $manager): void { + /** @var ApiKey $authorApiKey */ + $authorApiKey = $this->getReference('author_api_key'); + $abcShortUrl = $this->setShortUrlDate( - new ShortUrl('https://shlink.io', ShortUrlMeta::fromRawData(['customSlug' => 'abc123'])), + new ShortUrl('https://shlink.io', ShortUrlMeta::fromRawData( + ['customSlug' => 'abc123', 'apiKey' => $authorApiKey], + )), '2018-05-01', ); $manager->persist($abcShortUrl); $defShortUrl = $this->setShortUrlDate(new ShortUrl( 'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/', - ShortUrlMeta::fromRawData(['validSince' => Chronos::parse('2020-05-01'), 'customSlug' => 'def456']), + ShortUrlMeta::fromRawData( + ['validSince' => Chronos::parse('2020-05-01'), 'customSlug' => 'def456', 'apiKey' => $authorApiKey], + ), ), '2019-01-01 00:00:10'); $manager->persist($defShortUrl); $customShortUrl = $this->setShortUrlDate(new ShortUrl( 'https://shlink.io', - ShortUrlMeta::fromRawData(['customSlug' => 'custom', 'maxVisits' => 2]), + ShortUrlMeta::fromRawData(['customSlug' => 'custom', 'maxVisits' => 2, 'apiKey' => $authorApiKey]), ), '2019-01-01 00:00:20'); $manager->persist($customShortUrl); @@ -46,6 +57,7 @@ class ShortUrlsFixture extends AbstractFixture $withDomainDuplicatingShortCode = $this->setShortUrlDate(new ShortUrl( 'https://blog.alejandrocelaya.com/2019/04/27/considerations-to-properly-use-open-source-software-projects/', ShortUrlMeta::fromRawData(['domain' => 'example.com', 'customSlug' => 'ghi789']), + new PersistenceShortUrlRelationResolver($manager), ), '2019-01-01 00:00:30'); $manager->persist($withDomainDuplicatingShortCode); From f17873b52766abed2888a49c9177e4a0a50018ee Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 10 Jan 2021 08:49:31 +0100 Subject: [PATCH 35/46] Added api tests for short URLs lists using API keys with permissions --- .../test-api/Action/ListShortUrlsTest.php | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/module/Rest/test-api/Action/ListShortUrlsTest.php b/module/Rest/test-api/Action/ListShortUrlsTest.php index 2f1cf484..1c7529cf 100644 --- a/module/Rest/test-api/Action/ListShortUrlsTest.php +++ b/module/Rest/test-api/Action/ListShortUrlsTest.php @@ -105,9 +105,9 @@ class ListShortUrlsTest extends ApiTestCase * @test * @dataProvider provideFilteredLists */ - public function shortUrlsAreProperlyListed(array $query, array $expectedShortUrls): void + public function shortUrlsAreProperlyListed(array $query, array $expectedShortUrls, string $apiKey): void { - $resp = $this->callApiWithKey(self::METHOD_GET, '/short-urls', [RequestOptions::QUERY => $query]); + $resp = $this->callApiWithKey(self::METHOD_GET, '/short-urls', [RequestOptions::QUERY => $query], $apiKey); $respPayload = $this->getJsonResponsePayload($resp); self::assertEquals(self::STATUS_OK, $resp->getStatusCode()); @@ -128,7 +128,7 @@ class ListShortUrlsTest extends ApiTestCase self::SHORT_URL_META, self::SHORT_URL_CUSTOM_SLUG, self::SHORT_URL_CUSTOM_DOMAIN, - ]]; + ], 'valid_api_key']; yield [['orderBy' => 'shortCode'], [ self::SHORT_URL_SHLINK, self::SHORT_URL_CUSTOM_SLUG, @@ -136,7 +136,7 @@ class ListShortUrlsTest extends ApiTestCase self::SHORT_URL_META, self::SHORT_URL_DOCS, self::SHORT_URL_CUSTOM_DOMAIN, - ]]; + ], 'valid_api_key']; yield [['orderBy' => ['shortCode' => 'DESC']], [ // Deprecated self::SHORT_URL_DOCS, self::SHORT_URL_CUSTOM_DOMAIN, @@ -144,7 +144,7 @@ class ListShortUrlsTest extends ApiTestCase self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, self::SHORT_URL_CUSTOM_SLUG, self::SHORT_URL_SHLINK, - ]]; + ], 'valid_api_key']; yield [['orderBy' => 'shortCode-DESC'], [ self::SHORT_URL_DOCS, self::SHORT_URL_CUSTOM_DOMAIN, @@ -152,34 +152,42 @@ class ListShortUrlsTest extends ApiTestCase self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, self::SHORT_URL_CUSTOM_SLUG, self::SHORT_URL_SHLINK, - ]]; + ], 'valid_api_key']; yield [['startDate' => Chronos::parse('2018-12-01')->toAtomString()], [ self::SHORT_URL_META, self::SHORT_URL_CUSTOM_SLUG, self::SHORT_URL_CUSTOM_DOMAIN, - ]]; + ], 'valid_api_key']; yield [['endDate' => Chronos::parse('2018-12-01')->toAtomString()], [ self::SHORT_URL_SHLINK, self::SHORT_URL_DOCS, self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, - ]]; + ], 'valid_api_key']; yield [['tags' => ['foo']], [ self::SHORT_URL_SHLINK, self::SHORT_URL_META, - ]]; + ], 'valid_api_key']; yield [['tags' => ['bar']], [ self::SHORT_URL_META, - ]]; + ], 'valid_api_key']; yield [['tags' => ['foo'], 'endDate' => Chronos::parse('2018-12-01')->toAtomString()], [ self::SHORT_URL_SHLINK, - ]]; + ], 'valid_api_key']; yield [['searchTerm' => 'alejandro'], [ self::SHORT_URL_META, self::SHORT_URL_CUSTOM_DOMAIN, - ]]; + ], 'valid_api_key']; yield [['searchTerm' => 'example.com'], [ self::SHORT_URL_CUSTOM_DOMAIN, - ]]; + ], 'valid_api_key']; + yield [[], [ + self::SHORT_URL_SHLINK, + self::SHORT_URL_META, + self::SHORT_URL_CUSTOM_SLUG, + ], 'author_api_key']; + yield [[], [ + self::SHORT_URL_CUSTOM_DOMAIN, + ], 'domain_api_key']; } private function buildPagination(int $itemsCount): array From ea05259bbe12939d7cdd0aa5642cba3146f7b3c7 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 10 Jan 2021 09:02:05 +0100 Subject: [PATCH 36/46] Improved api tests where a short URL needs to be resolved, covering cases where API key lacks permissions --- .../Action/DeleteShortUrlActionTest.php | 5 +++-- .../Action/EditShortUrlActionTest.php | 5 +++-- .../Action/EditShortUrlTagsActionTest.php | 5 +++-- .../Action/ResolveShortUrlActionTest.php | 5 +++-- .../Action/ShortUrlVisitsActionTest.php | 10 ++++++++-- .../Utils/NotFoundUrlHelpersTrait.php | 20 ++++++++++++++++--- 6 files changed, 37 insertions(+), 13 deletions(-) diff --git a/module/Rest/test-api/Action/DeleteShortUrlActionTest.php b/module/Rest/test-api/Action/DeleteShortUrlActionTest.php index 7c66ff0b..76968cbd 100644 --- a/module/Rest/test-api/Action/DeleteShortUrlActionTest.php +++ b/module/Rest/test-api/Action/DeleteShortUrlActionTest.php @@ -18,9 +18,10 @@ class DeleteShortUrlActionTest extends ApiTestCase public function notFoundErrorIsReturnWhenDeletingInvalidUrl( string $shortCode, ?string $domain, - string $expectedDetail + string $expectedDetail, + string $apiKey ): void { - $resp = $this->callApiWithKey(self::METHOD_DELETE, $this->buildShortUrlPath($shortCode, $domain)); + $resp = $this->callApiWithKey(self::METHOD_DELETE, $this->buildShortUrlPath($shortCode, $domain), [], $apiKey); $payload = $this->getJsonResponsePayload($resp); self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); diff --git a/module/Rest/test-api/Action/EditShortUrlActionTest.php b/module/Rest/test-api/Action/EditShortUrlActionTest.php index e6b37eba..a909130a 100644 --- a/module/Rest/test-api/Action/EditShortUrlActionTest.php +++ b/module/Rest/test-api/Action/EditShortUrlActionTest.php @@ -104,10 +104,11 @@ class EditShortUrlActionTest extends ApiTestCase public function tryingToEditInvalidUrlReturnsNotFoundError( string $shortCode, ?string $domain, - string $expectedDetail + string $expectedDetail, + string $apiKey ): void { $url = $this->buildShortUrlPath($shortCode, $domain); - $resp = $this->callApiWithKey(self::METHOD_PATCH, $url, [RequestOptions::JSON => []]); + $resp = $this->callApiWithKey(self::METHOD_PATCH, $url, [RequestOptions::JSON => []], $apiKey); $payload = $this->getJsonResponsePayload($resp); self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); diff --git a/module/Rest/test-api/Action/EditShortUrlTagsActionTest.php b/module/Rest/test-api/Action/EditShortUrlTagsActionTest.php index 84d2af80..7fe45c73 100644 --- a/module/Rest/test-api/Action/EditShortUrlTagsActionTest.php +++ b/module/Rest/test-api/Action/EditShortUrlTagsActionTest.php @@ -34,12 +34,13 @@ class EditShortUrlTagsActionTest extends ApiTestCase public function providingInvalidShortCodeReturnsBadRequest( string $shortCode, ?string $domain, - string $expectedDetail + string $expectedDetail, + string $apiKey ): void { $url = $this->buildShortUrlPath($shortCode, $domain, '/tags'); $resp = $this->callApiWithKey(self::METHOD_PUT, $url, [RequestOptions::JSON => [ 'tags' => ['foo', 'bar'], - ]]); + ]], $apiKey); $payload = $this->getJsonResponsePayload($resp); self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); diff --git a/module/Rest/test-api/Action/ResolveShortUrlActionTest.php b/module/Rest/test-api/Action/ResolveShortUrlActionTest.php index cf1a7212..7996e459 100644 --- a/module/Rest/test-api/Action/ResolveShortUrlActionTest.php +++ b/module/Rest/test-api/Action/ResolveShortUrlActionTest.php @@ -50,9 +50,10 @@ class ResolveShortUrlActionTest extends ApiTestCase public function tryingToResolveInvalidUrlReturnsNotFoundError( string $shortCode, ?string $domain, - string $expectedDetail + string $expectedDetail, + string $apiKey ): void { - $resp = $this->callApiWithKey(self::METHOD_GET, $this->buildShortUrlPath($shortCode, $domain)); + $resp = $this->callApiWithKey(self::METHOD_GET, $this->buildShortUrlPath($shortCode, $domain), [], $apiKey); $payload = $this->getJsonResponsePayload($resp); self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); diff --git a/module/Rest/test-api/Action/ShortUrlVisitsActionTest.php b/module/Rest/test-api/Action/ShortUrlVisitsActionTest.php index 6e2463a2..22864108 100644 --- a/module/Rest/test-api/Action/ShortUrlVisitsActionTest.php +++ b/module/Rest/test-api/Action/ShortUrlVisitsActionTest.php @@ -22,9 +22,15 @@ class ShortUrlVisitsActionTest extends ApiTestCase public function tryingToGetVisitsForInvalidUrlReturnsNotFoundError( string $shortCode, ?string $domain, - string $expectedDetail + string $expectedDetail, + string $apiKey ): void { - $resp = $this->callApiWithKey(self::METHOD_GET, $this->buildShortUrlPath($shortCode, $domain, '/visits')); + $resp = $this->callApiWithKey( + self::METHOD_GET, + $this->buildShortUrlPath($shortCode, $domain, '/visits'), + [], + $apiKey, + ); $payload = $this->getJsonResponsePayload($resp); self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); diff --git a/module/Rest/test-api/Utils/NotFoundUrlHelpersTrait.php b/module/Rest/test-api/Utils/NotFoundUrlHelpersTrait.php index 3cf2ad30..1c415208 100644 --- a/module/Rest/test-api/Utils/NotFoundUrlHelpersTrait.php +++ b/module/Rest/test-api/Utils/NotFoundUrlHelpersTrait.php @@ -4,25 +4,39 @@ declare(strict_types=1); namespace ShlinkioApiTest\Shlink\Rest\Utils; +use GuzzleHttp\Psr7\Query; use Laminas\Diactoros\Uri; -use function GuzzleHttp\Psr7\build_query; use function sprintf; trait NotFoundUrlHelpersTrait { public function provideInvalidUrls(): iterable { - yield 'invalid shortcode' => ['invalid', null, 'No URL found with short code "invalid"']; + yield 'invalid shortcode' => ['invalid', null, 'No URL found with short code "invalid"', 'valid_api_key']; yield 'invalid shortcode without domain' => [ 'abc123', 'example.com', 'No URL found with short code "abc123" for domain "example.com"', + 'valid_api_key', ]; yield 'invalid shortcode + domain' => [ 'custom-with-domain', 'example.com', 'No URL found with short code "custom-with-domain" for domain "example.com"', + 'valid_api_key', + ]; + yield 'valid shortcode with invalid API key' => [ + 'ghi789', + null, + 'No URL found with short code "ghi789"', + 'author_api_key', + ]; + yield 'valid shortcode + domain with invalid API key' => [ + 'custom-with-domain', + 'some-domain.com', + 'No URL found with short code "custom-with-domain" for domain "some-domain.com"', + 'domain_api_key', ]; } @@ -30,7 +44,7 @@ trait NotFoundUrlHelpersTrait { $url = new Uri(sprintf('/short-urls/%s%s', $shortCode, $suffix)); if ($domain !== null) { - $url = $url->withQuery(build_query(['domain' => $domain])); + $url = $url->withQuery(Query::build(['domain' => $domain])); } return (string) $url; From c56d56d38c7af5962b711264aa95672e3bdb2ff9 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 10 Jan 2021 09:09:56 +0100 Subject: [PATCH 37/46] Added api tests to cover implicit domain when creating short URLs with proper API key --- .../Action/CreateShortUrlActionTest.php | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/module/Rest/test-api/Action/CreateShortUrlActionTest.php b/module/Rest/test-api/Action/CreateShortUrlActionTest.php index c9bf6fe5..5e388b0d 100644 --- a/module/Rest/test-api/Action/CreateShortUrlActionTest.php +++ b/module/Rest/test-api/Action/CreateShortUrlActionTest.php @@ -244,18 +244,40 @@ class CreateShortUrlActionTest extends ApiTestCase self::assertNull($payload['domain']); } + /** + * @test + * @dataProvider provideDomains + */ + public function apiKeyDomainIsEnforced(?string $providedDomain): void + { + [$statusCode, ['domain' => $returnedDomain]] = $this->createShortUrl( + ['domain' => $providedDomain], + 'domain_api_key', + ); + + self::assertEquals(self::STATUS_OK, $statusCode); + self::assertEquals('example.com', $returnedDomain); + } + + public function provideDomains(): iterable + { + yield 'no domain' => [null]; + yield 'invalid domain' => ['this-will-be-overwritten.com']; + yield 'example domain' => ['example.com']; + } + /** * @return array { * @var int $statusCode * @var array $payload * } */ - private function createShortUrl(array $body = []): array + private function createShortUrl(array $body = [], string $apiKey = 'valid_api_key'): array { if (! isset($body['longUrl'])) { $body['longUrl'] = 'https://app.shlink.io'; } - $resp = $this->callApiWithKey(self::METHOD_POST, '/short-urls', [RequestOptions::JSON => $body]); + $resp = $this->callApiWithKey(self::METHOD_POST, '/short-urls', [RequestOptions::JSON => $body], $apiKey); $payload = $this->getJsonResponsePayload($resp); return [$resp->getStatusCode(), $payload]; From 5283ee2c6b31fae22221792036552773b563ced8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 10 Jan 2021 09:31:51 +0100 Subject: [PATCH 38/46] Moved common data provider for core unit tests to trait --- .../Service/ShortUrl/ShortUrlResolverTest.php | 12 ++++-------- module/Core/test/Service/ShortUrlServiceTest.php | 12 ++++-------- module/Core/test/Service/Tag/TagServiceTest.php | 8 ++------ module/Core/test/Service/VisitsTrackerTest.php | 12 ++++-------- module/Core/test/Util/ApiKeyHelpersTrait.php | 16 ++++++++++++++++ 5 files changed, 30 insertions(+), 30 deletions(-) create mode 100644 module/Core/test/Util/ApiKeyHelpersTrait.php diff --git a/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php b/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php index 54d2eeda..e7cc0041 100644 --- a/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php +++ b/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php @@ -19,12 +19,14 @@ use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolver; use Shlinkio\Shlink\Rest\Entity\ApiKey; +use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait; use function Functional\map; use function range; class ShortUrlResolverTest extends TestCase { + use ApiKeyHelpersTrait; use ProphecyTrait; private ShortUrlResolver $urlResolver; @@ -38,7 +40,7 @@ class ShortUrlResolverTest extends TestCase /** * @test - * @dataProvider provideApiKeys + * @dataProvider provideAdminApiKeys */ public function shortCodeIsProperlyParsed(?ApiKey $apiKey): void { @@ -58,7 +60,7 @@ class ShortUrlResolverTest extends TestCase /** * @test - * @dataProvider provideApiKeys + * @dataProvider provideAdminApiKeys */ public function exceptionIsThrownIfShortcodeIsNotFound(?ApiKey $apiKey): void { @@ -75,12 +77,6 @@ class ShortUrlResolverTest extends TestCase $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode), $apiKey); } - public function provideApiKeys(): iterable - { - yield 'no API key' => [null]; - yield 'API key' => [new ApiKey()]; - } - /** @test */ public function shortCodeToEnabledShortUrlProperlyParsesShortCode(): void { diff --git a/module/Core/test/Service/ShortUrlServiceTest.php b/module/Core/test/Service/ShortUrlServiceTest.php index bb2f685a..99f26a53 100644 --- a/module/Core/test/Service/ShortUrlServiceTest.php +++ b/module/Core/test/Service/ShortUrlServiceTest.php @@ -21,11 +21,13 @@ use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\Service\ShortUrlService; use Shlinkio\Shlink\Core\Util\UrlValidatorInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; +use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait; use function count; class ShortUrlServiceTest extends TestCase { + use ApiKeyHelpersTrait; use ProphecyTrait; private ShortUrlService $service; @@ -51,7 +53,7 @@ class ShortUrlServiceTest extends TestCase /** * @test - * @dataProvider provideApiKeys + * @dataProvider provideAdminApiKeys */ public function listedUrlsAreReturnedFromEntityManager(?ApiKey $apiKey): void { @@ -73,7 +75,7 @@ class ShortUrlServiceTest extends TestCase /** * @test - * @dataProvider provideApiKeys + * @dataProvider provideAdminApiKeys */ public function providedTagsAreGetFromRepoAndSetToTheShortUrl(?ApiKey $apiKey): void { @@ -92,12 +94,6 @@ class ShortUrlServiceTest extends TestCase $this->service->setTagsByShortCode(new ShortUrlIdentifier($shortCode), ['foo', 'bar'], $apiKey); } - public function provideApiKeys(): iterable - { - yield 'no API key' => [null]; - yield 'API key' => [new ApiKey()]; - } - /** * @test * @dataProvider provideShortUrlEdits diff --git a/module/Core/test/Service/Tag/TagServiceTest.php b/module/Core/test/Service/Tag/TagServiceTest.php index f1965439..5f518184 100644 --- a/module/Core/test/Service/Tag/TagServiceTest.php +++ b/module/Core/test/Service/Tag/TagServiceTest.php @@ -19,9 +19,11 @@ use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use Shlinkio\Shlink\Core\Tag\TagService; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\Entity\ApiKey; +use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait; class TagServiceTest extends TestCase { + use ApiKeyHelpersTrait; use ProphecyTrait; private TagService $service; @@ -165,12 +167,6 @@ class TagServiceTest extends TestCase $this->service->renameTag(TagRenaming::fromNames('foo', 'bar'), $apiKey); } - public function provideAdminApiKeys(): iterable - { - yield 'no API key' => [null]; - yield 'admin API key' => [new ApiKey()]; - } - /** @test */ public function renamingTagThrowsExceptionWhenProvidedApiKeyIsNotAdmin(): void { diff --git a/module/Core/test/Service/VisitsTrackerTest.php b/module/Core/test/Service/VisitsTrackerTest.php index 38facd24..ef894aaf 100644 --- a/module/Core/test/Service/VisitsTrackerTest.php +++ b/module/Core/test/Service/VisitsTrackerTest.php @@ -26,12 +26,14 @@ use Shlinkio\Shlink\Core\Repository\TagRepository; use Shlinkio\Shlink\Core\Repository\VisitRepository; use Shlinkio\Shlink\Core\Service\VisitsTracker; use Shlinkio\Shlink\Rest\Entity\ApiKey; +use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait; use function Functional\map; use function range; class VisitsTrackerTest extends TestCase { + use ApiKeyHelpersTrait; use ProphecyTrait; private VisitsTracker $visitsTracker; @@ -61,7 +63,7 @@ class VisitsTrackerTest extends TestCase /** * @test - * @dataProvider provideApiKeys + * @dataProvider provideAdminApiKeys */ public function infoReturnsVisitsForCertainShortCode(?ApiKey $apiKey): void { @@ -117,7 +119,7 @@ class VisitsTrackerTest extends TestCase /** * @test - * @dataProvider provideApiKeys + * @dataProvider provideAdminApiKeys */ public function visitsForTagAreReturnedAsExpected(?ApiKey $apiKey): void { @@ -139,10 +141,4 @@ class VisitsTrackerTest extends TestCase $tagExists->shouldHaveBeenCalledOnce(); $getRepo->shouldHaveBeenCalledOnce(); } - - public function provideApiKeys(): iterable - { - yield 'no API key' => [null]; - yield 'API key' => [new ApiKey()]; - } } diff --git a/module/Core/test/Util/ApiKeyHelpersTrait.php b/module/Core/test/Util/ApiKeyHelpersTrait.php new file mode 100644 index 00000000..0b21ed5f --- /dev/null +++ b/module/Core/test/Util/ApiKeyHelpersTrait.php @@ -0,0 +1,16 @@ + [null]; + yield 'admin API key' => [new ApiKey()]; + } +} From c8eb956778ab489301473a4c691c8c23e5d4de61 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 10 Jan 2021 09:32:19 +0100 Subject: [PATCH 39/46] Improved list domains api test to cover different API key cases --- .../Rest/test-api/Action/ListDomainsTest.php | 54 +++++++++++++------ 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/module/Rest/test-api/Action/ListDomainsTest.php b/module/Rest/test-api/Action/ListDomainsTest.php index 045197e8..cf3167f8 100644 --- a/module/Rest/test-api/Action/ListDomainsTest.php +++ b/module/Rest/test-api/Action/ListDomainsTest.php @@ -8,30 +8,50 @@ use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; class ListDomainsTest extends ApiTestCase { - /** @test */ - public function domainsAreProperlyListed(): void + /** + * @test + * @dataProvider provideApiKeysAndDomains + */ + public function domainsAreProperlyListed(string $apiKey, array $expectedDomains): void { - $resp = $this->callApiWithKey(self::METHOD_GET, '/domains'); + $resp = $this->callApiWithKey(self::METHOD_GET, '/domains', [], $apiKey); $respPayload = $this->getJsonResponsePayload($resp); self::assertEquals(self::STATUS_OK, $resp->getStatusCode()); self::assertEquals([ 'domains' => [ - 'data' => [ - [ - 'domain' => 'doma.in', - 'isDefault' => true, - ], - [ - 'domain' => 'example.com', - 'isDefault' => false, - ], - [ - 'domain' => 'some-domain.com', - 'isDefault' => false, - ], - ], + 'data' => $expectedDomains, ], ], $respPayload); } + + public function provideApiKeysAndDomains(): iterable + { + yield 'admin API key' => ['valid_api_key', [ + [ + 'domain' => 'doma.in', + 'isDefault' => true, + ], + [ + 'domain' => 'example.com', + 'isDefault' => false, + ], + [ + 'domain' => 'some-domain.com', + 'isDefault' => false, + ], + ]]; + yield 'author API key' => ['author_api_key', [ + [ + 'domain' => 'doma.in', + 'isDefault' => true, + ], + ]]; + yield 'domain API key' => ['domain_api_key', [ + [ + 'domain' => 'example.com', + 'isDefault' => false, + ], + ]]; + } } From fa5934b8b604af32c1cc1513c46c7b1b04477890 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 10 Jan 2021 09:36:10 +0100 Subject: [PATCH 40/46] Improved global visits api test to cover different API key cases --- .../test-api/Action/GlobalVisitsActionTest.php | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/module/Rest/test-api/Action/GlobalVisitsActionTest.php b/module/Rest/test-api/Action/GlobalVisitsActionTest.php index b6767c0f..9c09da10 100644 --- a/module/Rest/test-api/Action/GlobalVisitsActionTest.php +++ b/module/Rest/test-api/Action/GlobalVisitsActionTest.php @@ -8,14 +8,24 @@ use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; class GlobalVisitsActionTest extends ApiTestCase { - /** @test */ - public function returnsExpectedVisitsStats(): void + /** + * @test + * @dataProvider provideApiKeys + */ + public function returnsExpectedVisitsStats(string $apiKey, int $expectedVisits): void { - $resp = $this->callApiWithKey(self::METHOD_GET, '/visits'); + $resp = $this->callApiWithKey(self::METHOD_GET, '/visits', [], $apiKey); $payload = $this->getJsonResponsePayload($resp); self::assertArrayHasKey('visits', $payload); self::assertArrayHasKey('visitsCount', $payload['visits']); - self::assertEquals(7, $payload['visits']['visitsCount']); + self::assertEquals($expectedVisits, $payload['visits']['visitsCount']); + } + + public function provideApiKeys(): iterable + { + yield 'admin API key' => ['valid_api_key', 7]; + yield 'domain API key' => ['domain_api_key', 0]; + yield 'author API key' => ['author_api_key', 5]; } } From 13cc70e6d412a88526c9885fd4dc0b95a253fa42 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 10 Jan 2021 09:54:19 +0100 Subject: [PATCH 41/46] Added more tags to more fixture short URLs in API keys --- .../Rest/test-api/Action/ListShortUrlsTest.php | 3 ++- .../test-api/Action/ListTagsActionTest.php | 2 +- .../test-api/Action/TagVisitsActionTest.php | 18 ++++++++++++++---- .../test-api/Fixtures/ShortUrlsFixture.php | 1 + module/Rest/test-api/Fixtures/TagsFixture.php | 4 ++++ 5 files changed, 22 insertions(+), 6 deletions(-) diff --git a/module/Rest/test-api/Action/ListShortUrlsTest.php b/module/Rest/test-api/Action/ListShortUrlsTest.php index 1c7529cf..e38374c8 100644 --- a/module/Rest/test-api/Action/ListShortUrlsTest.php +++ b/module/Rest/test-api/Action/ListShortUrlsTest.php @@ -92,7 +92,7 @@ class ListShortUrlsTest extends ApiTestCase . '/considerations-to-properly-use-open-source-software-projects/', 'dateCreated' => '2019-01-01T00:00:30+00:00', 'visitsCount' => 0, - 'tags' => [], + 'tags' => ['foo'], 'meta' => [ 'validSince' => null, 'validUntil' => null, @@ -166,6 +166,7 @@ class ListShortUrlsTest extends ApiTestCase yield [['tags' => ['foo']], [ self::SHORT_URL_SHLINK, self::SHORT_URL_META, + self::SHORT_URL_CUSTOM_DOMAIN, ], 'valid_api_key']; yield [['tags' => ['bar']], [ self::SHORT_URL_META, diff --git a/module/Rest/test-api/Action/ListTagsActionTest.php b/module/Rest/test-api/Action/ListTagsActionTest.php index 9191b4e0..87b5b0c3 100644 --- a/module/Rest/test-api/Action/ListTagsActionTest.php +++ b/module/Rest/test-api/Action/ListTagsActionTest.php @@ -41,7 +41,7 @@ class ListTagsActionTest extends ApiTestCase ], [ 'tag' => 'foo', - 'shortUrlsCount' => 2, + 'shortUrlsCount' => 3, 'visitsCount' => 5, ], ], diff --git a/module/Rest/test-api/Action/TagVisitsActionTest.php b/module/Rest/test-api/Action/TagVisitsActionTest.php index d0f9838b..402b9ada 100644 --- a/module/Rest/test-api/Action/TagVisitsActionTest.php +++ b/module/Rest/test-api/Action/TagVisitsActionTest.php @@ -31,16 +31,26 @@ class TagVisitsActionTest extends ApiTestCase yield 'baz' => ['baz', 0]; } - /** @test */ - public function notFoundErrorIsReturnedForInvalidTags(): void + /** + * @test + * @dataProvider provideApiKeysAndTags + */ + public function notFoundErrorIsReturnedForInvalidTags(string $apiKey, string $tag): void { - $resp = $this->callApiWithKey(self::METHOD_GET, '/tags/invalid_tag/visits'); + $resp = $this->callApiWithKey(self::METHOD_GET, sprintf('/tags/%s/visits', $tag), [], $apiKey); $payload = $this->getJsonResponsePayload($resp); self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); self::assertEquals(self::STATUS_NOT_FOUND, $payload['status']); self::assertEquals('TAG_NOT_FOUND', $payload['type']); - self::assertEquals('Tag with name "invalid_tag" could not be found', $payload['detail']); + self::assertEquals(sprintf('Tag with name "%s" could not be found', $tag), $payload['detail']); self::assertEquals('Tag not found', $payload['title']); } + + public function provideApiKeysAndTags(): iterable + { + yield 'admin API key with invalid tag' => ['valid_api_key', 'invalid_tag']; + yield 'domain API key with valid tag not used' => ['domain_api_key', 'bar']; + yield 'author API key with valid tag not used' => ['author_api_key', 'baz']; + } } diff --git a/module/Rest/test-api/Fixtures/ShortUrlsFixture.php b/module/Rest/test-api/Fixtures/ShortUrlsFixture.php index 3b4f2828..954d2059 100644 --- a/module/Rest/test-api/Fixtures/ShortUrlsFixture.php +++ b/module/Rest/test-api/Fixtures/ShortUrlsFixture.php @@ -72,6 +72,7 @@ class ShortUrlsFixture extends AbstractFixture implements DependentFixtureInterf $this->addReference('abc123_short_url', $abcShortUrl); $this->addReference('def456_short_url', $defShortUrl); $this->addReference('ghi789_short_url', $ghiShortUrl); + $this->addReference('example_short_url', $withDomainDuplicatingShortCode); } private function setShortUrlDate(ShortUrl $shortUrl, string $date): ShortUrl diff --git a/module/Rest/test-api/Fixtures/TagsFixture.php b/module/Rest/test-api/Fixtures/TagsFixture.php index 5d3333cc..bf16104e 100644 --- a/module/Rest/test-api/Fixtures/TagsFixture.php +++ b/module/Rest/test-api/Fixtures/TagsFixture.php @@ -34,6 +34,10 @@ class TagsFixture extends AbstractFixture implements DependentFixtureInterface $defShortUrl = $this->getReference('def456_short_url'); $defShortUrl->setTags(new ArrayCollection([$fooTag, $barTag])); + /** @var ShortUrl $exampleShortUrl */ + $exampleShortUrl = $this->getReference('example_short_url'); + $exampleShortUrl->setTags(new ArrayCollection([$fooTag])); + $manager->flush(); } } From ff1af82ffd7beca13353146399ea46952c65a5e4 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 10 Jan 2021 10:00:00 +0100 Subject: [PATCH 42/46] Improved tag visits api test to cover different API key cases --- .../Rest/test-api/Action/TagVisitsActionTest.php | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/module/Rest/test-api/Action/TagVisitsActionTest.php b/module/Rest/test-api/Action/TagVisitsActionTest.php index 402b9ada..c1557bdd 100644 --- a/module/Rest/test-api/Action/TagVisitsActionTest.php +++ b/module/Rest/test-api/Action/TagVisitsActionTest.php @@ -14,11 +14,12 @@ class TagVisitsActionTest extends ApiTestCase * @test * @dataProvider provideTags */ - public function expectedVisitsAreReturned(string $tag, int $expectedVisitsAmount): void + public function expectedVisitsAreReturned(string $apiKey, string $tag, int $expectedVisitsAmount): void { - $resp = $this->callApiWithKey(self::METHOD_GET, sprintf('/tags/%s/visits', $tag)); + $resp = $this->callApiWithKey(self::METHOD_GET, sprintf('/tags/%s/visits', $tag), [], $apiKey); $payload = $this->getJsonResponsePayload($resp); + self::assertEquals(self::STATUS_OK, $resp->getStatusCode()); self::assertArrayHasKey('visits', $payload); self::assertArrayHasKey('data', $payload['visits']); self::assertCount($expectedVisitsAmount, $payload['visits']['data']); @@ -26,9 +27,12 @@ class TagVisitsActionTest extends ApiTestCase public function provideTags(): iterable { - yield 'foo' => ['foo', 5]; - yield 'bar' => ['bar', 2]; - yield 'baz' => ['baz', 0]; + yield 'foo with admin API key' => ['valid_api_key', 'foo', 5]; + yield 'bar with admin API key' => ['valid_api_key', 'bar', 2]; + yield 'baz with admin API key' => ['valid_api_key', 'baz', 0]; + yield 'foo with author API key' => ['author_api_key', 'foo', 5]; + yield 'bar with author API key' => ['author_api_key', 'bar', 2]; + yield 'foo with domain API key' => ['domain_api_key', 'foo', 0]; } /** From 2be0050f3d63486e24847e3e57098344ca967bfb Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 10 Jan 2021 10:17:27 +0100 Subject: [PATCH 43/46] Improved tag list api test to cover different API key cases --- .../test-api/Action/ListTagsActionTest.php | 41 +++++++++++++++++-- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/module/Rest/test-api/Action/ListTagsActionTest.php b/module/Rest/test-api/Action/ListTagsActionTest.php index 87b5b0c3..188e6bdf 100644 --- a/module/Rest/test-api/Action/ListTagsActionTest.php +++ b/module/Rest/test-api/Action/ListTagsActionTest.php @@ -13,9 +13,9 @@ class ListTagsActionTest extends ApiTestCase * @test * @dataProvider provideQueries */ - public function expectedListOfTagsIsReturned(array $query, array $expectedTags): void + public function expectedListOfTagsIsReturned(string $apiKey, array $query, array $expectedTags): void { - $resp = $this->callApiWithKey(self::METHOD_GET, '/tags', [RequestOptions::QUERY => $query]); + $resp = $this->callApiWithKey(self::METHOD_GET, '/tags', [RequestOptions::QUERY => $query], $apiKey); $payload = $this->getJsonResponsePayload($resp); self::assertEquals(['tags' => $expectedTags], $payload); @@ -23,10 +23,10 @@ class ListTagsActionTest extends ApiTestCase public function provideQueries(): iterable { - yield 'stats not requested' => [[], [ + yield 'admin API key without stats' => ['valid_api_key', [], [ 'data' => ['bar', 'baz', 'foo'], ]]; - yield 'stats requested' => [['withStats' => 'true'], [ + yield 'admin API key with stats' => ['valid_api_key', ['withStats' => 'true'], [ 'data' => ['bar', 'baz', 'foo'], 'stats' => [ [ @@ -46,5 +46,38 @@ class ListTagsActionTest extends ApiTestCase ], ], ]]; + + yield 'author API key without stats' => ['author_api_key', [], [ + 'data' => ['bar', 'foo'], + ]]; + yield 'author API key with stats' => ['author_api_key', ['withStats' => 'true'], [ + 'data' => ['bar', 'foo'], + 'stats' => [ + [ + 'tag' => 'bar', + 'shortUrlsCount' => 1, + 'visitsCount' => 2, + ], + [ + 'tag' => 'foo', + 'shortUrlsCount' => 2, + 'visitsCount' => 5, + ], + ], + ]]; + + yield 'domain API key without stats' => ['domain_api_key', [], [ + 'data' => ['foo'], + ]]; + yield 'domain API key with stats' => ['domain_api_key', ['withStats' => 'true'], [ + 'data' => ['foo'], + 'stats' => [ + [ + 'tag' => 'foo', + 'shortUrlsCount' => 1, + 'visitsCount' => 0, + ], + ], + ]]; } } From 34bb023b7d0dd5fadd066c07feba1e0c3dceb3a8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 10 Jan 2021 10:28:00 +0100 Subject: [PATCH 44/46] Created API tests to cover deletion and renaming of tags with non-admin API keys --- .../Rest/test-api/Action/DeleteTagsTest.php | 35 +++++++++++++++++ module/Rest/test-api/Action/RenameTagTest.php | 38 +++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 module/Rest/test-api/Action/DeleteTagsTest.php create mode 100644 module/Rest/test-api/Action/RenameTagTest.php diff --git a/module/Rest/test-api/Action/DeleteTagsTest.php b/module/Rest/test-api/Action/DeleteTagsTest.php new file mode 100644 index 00000000..ca175b69 --- /dev/null +++ b/module/Rest/test-api/Action/DeleteTagsTest.php @@ -0,0 +1,35 @@ +callApiWithKey(self::METHOD_DELETE, '/tags', [ + RequestOptions::QUERY => ['tags' => ['foo']], + ], $apiKey); + $payload = $this->getJsonResponsePayload($resp); + + self::assertEquals(self::STATUS_FORBIDDEN, $resp->getStatusCode()); + self::assertEquals(self::STATUS_FORBIDDEN, $payload['status']); + self::assertEquals('FORBIDDEN_OPERATION', $payload['type']); + self::assertEquals('You are not allowed to delete tags', $payload['detail']); + self::assertEquals('Forbidden tag operation', $payload['title']); + } + + public function provideNonAdminApiKeys(): iterable + { + yield 'author' => ['author_api_key']; + yield 'domain' => ['domain_api_key']; + } +} diff --git a/module/Rest/test-api/Action/RenameTagTest.php b/module/Rest/test-api/Action/RenameTagTest.php new file mode 100644 index 00000000..7ed4ff4f --- /dev/null +++ b/module/Rest/test-api/Action/RenameTagTest.php @@ -0,0 +1,38 @@ +callApiWithKey(self::METHOD_PUT, '/tags', [ + RequestOptions::JSON => [ + 'oldName' => 'foo', + 'newName' => 'foo_renamed', + ], + ], $apiKey); + $payload = $this->getJsonResponsePayload($resp); + + self::assertEquals(self::STATUS_FORBIDDEN, $resp->getStatusCode()); + self::assertEquals(self::STATUS_FORBIDDEN, $payload['status']); + self::assertEquals('FORBIDDEN_OPERATION', $payload['type']); + self::assertEquals('You are not allowed to rename tags', $payload['detail']); + self::assertEquals('Forbidden tag operation', $payload['title']); + } + + public function provideNonAdminApiKeys(): iterable + { + yield 'author' => ['author_api_key']; + yield 'domain' => ['domain_api_key']; + } +} From 5bec9f5b6538cf773d7924ac989a736bcfbb5f1c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 10 Jan 2021 11:07:17 +0100 Subject: [PATCH 45/46] Extended swagger docs with errors on delete/rename tags --- docs/swagger/paths/v1_tags.json | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/swagger/paths/v1_tags.json b/docs/swagger/paths/v1_tags.json index cb6a6bb3..8c3ada73 100644 --- a/docs/swagger/paths/v1_tags.json +++ b/docs/swagger/paths/v1_tags.json @@ -232,6 +232,16 @@ } } }, + "403": { + "description": "The API key you used does not have permissions to rename tags.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "../definitions/Error.json" + } + } + } + }, "404": { "description": "There's no tag found with the name provided in oldName param.", "content": { @@ -298,6 +308,16 @@ "204": { "description": "Tags properly deleted" }, + "403": { + "description": "The API key you used does not have permissions to delete tags.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "../definitions/Error.json" + } + } + } + }, "500": { "description": "Unexpected error.", "content": { From 91da241434b8c3accc84ea54957f3fbbe124f286 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 10 Jan 2021 11:12:22 +0100 Subject: [PATCH 46/46] Updated changelog --- CHANGELOG.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ce3a5d6..0003adfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ## [Unreleased] ### Added -* [#869](https://github.com/shlinkio/shlink/issues/869) Added support for Mercure Hub 0.10. +* [#795](https://github.com/shlinkio/shlink/issues/795) and [#882](https://github.com/shlinkio/shlink/issues/882) Added new roles system to API keys. + + API keys can have any combinations of these two roles now, allowing to limit their interactions: + + * Can interact only with short URLs created with that API key. + * Can interact only with short URLs for a specific domain. + * [#833](https://github.com/shlinkio/shlink/issues/833) Added support to connect through unix socket when using an external MySQL, MariaDB or Postgres database. It can be provided during the installation, or as the `DB_UNIX_SOCKET` env var for the docker image. +* [#869](https://github.com/shlinkio/shlink/issues/869) Added support for Mercure Hub 0.10. * [#896](https://github.com/shlinkio/shlink/issues/896) Added support for unicode characters in custom slugs. * [#930](https://github.com/shlinkio/shlink/issues/930) Added new `bin/set-option` script that allows changing individual configuration options on existing shlink instances. * [#877](https://github.com/shlinkio/shlink/issues/877) Improved API tests on CORS, and "refined" middleware handling it.