From fd007ea4a9962cfce588f9563a62c3fa07dc7e6e Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Sun, 22 Nov 2020 16:26:17 +0100
Subject: [PATCH 001/118] #869 Updated dependencies to support mercure 0.10
---
composer.json | 6 +++---
docker-compose.yml | 2 +-
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/composer.json b/composer.json
index c085f69b..679fdbe9 100644
--- a/composer.json
+++ b/composer.json
@@ -32,7 +32,7 @@
"laminas/laminas-paginator": "^2.8",
"laminas/laminas-servicemanager": "^3.4",
"laminas/laminas-stdlib": "^3.2",
- "lcobucci/jwt": "^4.0@alpha <4.0@beta",
+ "lcobucci/jwt": "^4.0@beta",
"league/uri": "^6.2",
"lstrojny/functional-php": "^1.9",
"mezzio/mezzio": "^3.2",
@@ -49,7 +49,7 @@
"predis/predis": "^1.1",
"pugx/shortid-php": "^0.5",
"ramsey/uuid": "^3.9",
- "shlinkio/shlink-common": "^3.3.0",
+ "shlinkio/shlink-common": "^3.3.2",
"shlinkio/shlink-config": "^1.0",
"shlinkio/shlink-event-dispatcher": "^1.4",
"shlinkio/shlink-importer": "^2.0.1",
@@ -58,7 +58,7 @@
"symfony/console": "^5.1",
"symfony/filesystem": "^5.1",
"symfony/lock": "^5.1",
- "symfony/mercure": "^0.3.0",
+ "symfony/mercure": "^0.4.0",
"symfony/process": "^5.1",
"symfony/string": "^5.1"
},
diff --git a/docker-compose.yml b/docker-compose.yml
index d700f3b3..ba4558e4 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -131,7 +131,7 @@ services:
shlink_mercure:
container_name: shlink_mercure
- image: dunglas/mercure:v0.9
+ image: dunglas/mercure:v0.10
ports:
- "3080:80"
environment:
From 600f7a738842bafc79c9d5da907fafeaf153dc7d Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Sun, 22 Nov 2020 16:27:24 +0100
Subject: [PATCH 002/118] #869 Updated changelog
---
CHANGELOG.md | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9ec8f4df..ee9d0b11 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
+## [Unreleased]
+### Added
+* [#869](https://github.com/shlinkio/shlink/issues/869) Added support for Mercure Hub 0.10.
+
+### Changed
+* *Nothing*
+
+### Deprecated
+* *Nothing*
+
+### Removed
+* *Nothing*
+
+### Fixed
+* *Nothing*
+
+
## [2.4.2] - 2020-11-22
### Added
* *Nothing*
From 49ea5cc78bf2a52d59737ac0edb9016b055e7e4d Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Sun, 22 Nov 2020 18:03:27 +0100
Subject: [PATCH 003/118] #912 Removed dependency on league/plates
---
composer.json | 1 -
config/autoload/templates.global.php | 17 -----------
config/config.php | 1 -
module/Core/config/dependencies.config.php | 5 ++--
module/Core/config/mezzio.config.php | 14 ---------
module/Core/config/templates.config.php | 13 ---------
.../ErrorHandler/NotFoundTemplateHandler.php | 29 +++++++++----------
module/Core/templates/404.html | 27 +++++++++++++++++
module/Core/templates/error/404.phtml | 19 ------------
module/Core/templates/error/error.phtml | 25 ----------------
module/Core/templates/invalid-short-code.html | 27 +++++++++++++++++
.../Core/templates/invalid-short-code.phtml | 19 ------------
module/Core/templates/layout/default.phtml | 23 ---------------
module/Core/test/ConfigProviderTest.php | 4 +--
14 files changed, 70 insertions(+), 154 deletions(-)
delete mode 100644 config/autoload/templates.global.php
delete mode 100644 module/Core/config/mezzio.config.php
delete mode 100644 module/Core/config/templates.config.php
create mode 100644 module/Core/templates/404.html
delete mode 100644 module/Core/templates/error/404.phtml
delete mode 100644 module/Core/templates/error/error.phtml
create mode 100644 module/Core/templates/invalid-short-code.html
delete mode 100644 module/Core/templates/invalid-short-code.phtml
delete mode 100644 module/Core/templates/layout/default.phtml
diff --git a/composer.json b/composer.json
index 679fdbe9..caf5e5dd 100644
--- a/composer.json
+++ b/composer.json
@@ -38,7 +38,6 @@
"mezzio/mezzio": "^3.2",
"mezzio/mezzio-fastroute": "^3.0",
"mezzio/mezzio-helpers": "^5.3",
- "mezzio/mezzio-platesrenderer": "^2.1",
"mezzio/mezzio-problem-details": "^1.1",
"mezzio/mezzio-swoole": "^2.6.4",
"monolog/monolog": "^2.0",
diff --git a/config/autoload/templates.global.php b/config/autoload/templates.global.php
deleted file mode 100644
index e1b457fa..00000000
--- a/config/autoload/templates.global.php
+++ /dev/null
@@ -1,17 +0,0 @@
- [
- 'extension' => 'phtml',
- ],
-
- 'plates' => [
- 'extensions' => [
- // extension service names or instances
- ],
- ],
-
-];
diff --git a/config/config.php b/config/config.php
index ba0657fc..cf9eb86b 100644
--- a/config/config.php
+++ b/config/config.php
@@ -15,7 +15,6 @@ return (new ConfigAggregator\ConfigAggregator([
Mezzio\ConfigProvider::class,
Mezzio\Router\ConfigProvider::class,
Mezzio\Router\FastRouteRouter\ConfigProvider::class,
- Mezzio\Plates\ConfigProvider::class,
Mezzio\Swoole\ConfigProvider::class,
ProblemDetails\ConfigProvider::class,
Diactoros\ConfigProvider::class,
diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php
index b6beb1ac..94b5858a 100644
--- a/module/Core/config/dependencies.config.php
+++ b/module/Core/config/dependencies.config.php
@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core;
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
-use Mezzio\Template\TemplateRendererInterface;
+use Laminas\ServiceManager\Factory\InvokableFactory;
use Psr\EventDispatcher\EventDispatcherInterface;
use Shlinkio\Shlink\Core\ErrorHandler;
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
@@ -16,7 +16,7 @@ return [
'dependencies' => [
'factories' => [
ErrorHandler\NotFoundRedirectHandler::class => ConfigAbstractFactory::class,
- ErrorHandler\NotFoundTemplateHandler::class => ConfigAbstractFactory::class,
+ ErrorHandler\NotFoundTemplateHandler::class => InvokableFactory::class,
Options\AppOptions::class => ConfigAbstractFactory::class,
Options\DeleteShortUrlsOptions::class => ConfigAbstractFactory::class,
@@ -60,7 +60,6 @@ return [
Util\RedirectResponseHelper::class,
'config.router.base_path',
],
- ErrorHandler\NotFoundTemplateHandler::class => [TemplateRendererInterface::class],
Options\AppOptions::class => ['config.app_options'],
Options\DeleteShortUrlsOptions::class => ['config.delete_short_urls'],
diff --git a/module/Core/config/mezzio.config.php b/module/Core/config/mezzio.config.php
deleted file mode 100644
index 5e4acb22..00000000
--- a/module/Core/config/mezzio.config.php
+++ /dev/null
@@ -1,14 +0,0 @@
- [
- 'error_handler' => [
- 'template_404' => 'ShlinkCore::error/404',
- 'template_error' => 'ShlinkCore::error/error',
- ],
- ],
-
-];
diff --git a/module/Core/config/templates.config.php b/module/Core/config/templates.config.php
deleted file mode 100644
index 784f731e..00000000
--- a/module/Core/config/templates.config.php
+++ /dev/null
@@ -1,13 +0,0 @@
- [
- 'paths' => [
- 'ShlinkCore' => __DIR__ . '/../templates',
- ],
- ],
-
-];
diff --git a/module/Core/src/ErrorHandler/NotFoundTemplateHandler.php b/module/Core/src/ErrorHandler/NotFoundTemplateHandler.php
index e5968a68..62b78973 100644
--- a/module/Core/src/ErrorHandler/NotFoundTemplateHandler.php
+++ b/module/Core/src/ErrorHandler/NotFoundTemplateHandler.php
@@ -4,40 +4,37 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ErrorHandler;
+use Closure;
use Fig\Http\Message\StatusCodeInterface;
-use InvalidArgumentException;
use Laminas\Diactoros\Response;
use Mezzio\Router\RouteResult;
-use Mezzio\Template\TemplateRendererInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
+use function file_get_contents;
+use function sprintf;
+
class NotFoundTemplateHandler implements RequestHandlerInterface
{
- public const NOT_FOUND_TEMPLATE = 'ShlinkCore::error/404';
- public const INVALID_SHORT_CODE_TEMPLATE = 'ShlinkCore::invalid-short-code';
+ private const TEMPLATES_BASE_DIR = __DIR__ . '/../../templates';
+ public const NOT_FOUND_TEMPLATE = '404.html';
+ public const INVALID_SHORT_CODE_TEMPLATE = 'invalid-short-code.html';
+ private Closure $readFile;
- private TemplateRendererInterface $renderer;
-
- public function __construct(TemplateRendererInterface $renderer)
+ public function __construct(?callable $readFile = null)
{
- $this->renderer = $renderer;
+ $this->readFile = $readFile ? Closure::fromCallable($readFile) : fn (string $file) => file_get_contents($file);
}
- /**
- * Dispatch the next available middleware and return the response.
- *
- *
- * @throws InvalidArgumentException
- */
public function handle(ServerRequestInterface $request): ResponseInterface
{
/** @var RouteResult $routeResult */
- $routeResult = $request->getAttribute(RouteResult::class, RouteResult::fromRouteFailure(null));
+ $routeResult = $request->getAttribute(RouteResult::class) ?? RouteResult::fromRouteFailure(null);
$status = StatusCodeInterface::STATUS_NOT_FOUND;
$template = $routeResult->isFailure() ? self::NOT_FOUND_TEMPLATE : self::INVALID_SHORT_CODE_TEMPLATE;
- return new Response\HtmlResponse($this->renderer->render($template), $status);
+ $templateContent = ($this->readFile)(sprintf('%s/%s', self::TEMPLATES_BASE_DIR, $template));
+ return new Response\HtmlResponse($templateContent, $status);
}
}
diff --git a/module/Core/templates/404.html b/module/Core/templates/404.html
new file mode 100644
index 00000000..93e6fb64
--- /dev/null
+++ b/module/Core/templates/404.html
@@ -0,0 +1,27 @@
+
+
+
+ Not Found | Shlink
+
+
+
+
+
+
+
+
+
+
+ 404
+
+ Page not found.
+ The page you requested could not be found.
+
+
+
+
diff --git a/module/Core/templates/error/404.phtml b/module/Core/templates/error/404.phtml
deleted file mode 100644
index 20ac4ff8..00000000
--- a/module/Core/templates/error/404.phtml
+++ /dev/null
@@ -1,19 +0,0 @@
-layout('ShlinkCore::layout/default') ?>
-
-start('title') ?>
- Not Found
-end() ?>
-
-start('stylesheets') ?>
-
-end() ?>
-
-start('main') ?>
- 404
-
- Page not found.
- The page you requested could not be found.
-end() ?>
diff --git a/module/Core/templates/error/error.phtml b/module/Core/templates/error/error.phtml
deleted file mode 100644
index 77108f26..00000000
--- a/module/Core/templates/error/error.phtml
+++ /dev/null
@@ -1,25 +0,0 @@
-layout('ShlinkCore::layout/default') ?>
-
-start('title') ?>
- = $this->e($status . ' ' . $reason) ?>
-end() ?>
-
-start('stylesheets') ?>
-
-end() ?>
-
-start('main') ?>
- Oops!
-
-
-
- = sprintf('We encountered a %s %s error.', $status, $reason) ?>
-
- 'This short URL doesn't seem to be valid.
- 'Make sure you included all the characters, with no extra punctuation.
-
-end() ?>
-
diff --git a/module/Core/templates/invalid-short-code.html b/module/Core/templates/invalid-short-code.html
new file mode 100644
index 00000000..61c5a804
--- /dev/null
+++ b/module/Core/templates/invalid-short-code.html
@@ -0,0 +1,27 @@
+
+
+
+ Invalid Short URL | Shlink
+
+
+
+
+
+
+
+
+
+
+ Oops!
+
+ This short URL doesn't seem to be valid.
+ Make sure you included all the characters, with no extra punctuation.
+
+
+
+
diff --git a/module/Core/templates/invalid-short-code.phtml b/module/Core/templates/invalid-short-code.phtml
deleted file mode 100644
index 47be4a16..00000000
--- a/module/Core/templates/invalid-short-code.phtml
+++ /dev/null
@@ -1,19 +0,0 @@
-layout('ShlinkCore::layout/default') ?>
-
-start('title') ?>
- Invalid Short URL
-end() ?>
-
-start('stylesheets') ?>
-
-end() ?>
-
-start('main') ?>
- Oops!
-
- This short URL doesn't seem to be valid.
- Make sure you included all the characters, with no extra punctuation.
-end() ?>
diff --git a/module/Core/templates/layout/default.phtml b/module/Core/templates/layout/default.phtml
deleted file mode 100644
index fbb78b26..00000000
--- a/module/Core/templates/layout/default.phtml
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
-
- = $this->section('title', '') ?> | Shlink
-
-
-
-
-
-
- = $this->section('stylesheets', '') ?>
-
-
-
-
- = $this->section('main', '') ?>
-
-
-
-
diff --git a/module/Core/test/ConfigProviderTest.php b/module/Core/test/ConfigProviderTest.php
index 2660803b..7f4eb8dd 100644
--- a/module/Core/test/ConfigProviderTest.php
+++ b/module/Core/test/ConfigProviderTest.php
@@ -19,11 +19,9 @@ class ConfigProviderTest extends TestCase
/** @test */
public function properConfigIsReturned(): void
{
- $config = $this->configProvider->__invoke();
+ $config = ($this->configProvider)();
self::assertArrayHasKey('routes', $config);
self::assertArrayHasKey('dependencies', $config);
- self::assertArrayHasKey('templates', $config);
- self::assertArrayHasKey('mezzio', $config);
}
}
From 39bda5113b73ce61037519e8bfa77e1d24622f45 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Sun, 22 Nov 2020 18:11:31 +0100
Subject: [PATCH 004/118] #912 Fixed unit tests
---
module/CLI/test/ConfigProviderTest.php | 3 +++
module/Core/test/ConfigProviderTest.php | 5 ++++
.../NotFoundTemplateHandlerTest.php | 25 +++++++++----------
module/Rest/test/ConfigProviderTest.php | 5 ++++
4 files changed, 25 insertions(+), 13 deletions(-)
diff --git a/module/CLI/test/ConfigProviderTest.php b/module/CLI/test/ConfigProviderTest.php
index 42a8f504..863b8a1f 100644
--- a/module/CLI/test/ConfigProviderTest.php
+++ b/module/CLI/test/ConfigProviderTest.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI;
+use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\ConfigProvider;
@@ -21,7 +22,9 @@ class ConfigProviderTest extends TestCase
{
$config = ($this->configProvider)();
+ self::assertCount(3, $config);
self::assertArrayHasKey('cli', $config);
self::assertArrayHasKey('dependencies', $config);
+ self::assertArrayHasKey(ConfigAbstractFactory::class, $config);
}
}
diff --git a/module/Core/test/ConfigProviderTest.php b/module/Core/test/ConfigProviderTest.php
index 7f4eb8dd..4044446a 100644
--- a/module/Core/test/ConfigProviderTest.php
+++ b/module/Core/test/ConfigProviderTest.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core;
+use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\ConfigProvider;
@@ -21,7 +22,11 @@ class ConfigProviderTest extends TestCase
{
$config = ($this->configProvider)();
+ self::assertCount(5, $config);
self::assertArrayHasKey('routes', $config);
self::assertArrayHasKey('dependencies', $config);
+ self::assertArrayHasKey('entity_manager', $config);
+ self::assertArrayHasKey('events', $config);
+ self::assertArrayHasKey(ConfigAbstractFactory::class, $config);
}
}
diff --git a/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php b/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php
index e10954ca..6b9f9989 100644
--- a/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php
+++ b/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php
@@ -4,29 +4,30 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\ErrorHandler;
+use Closure;
use Laminas\Diactoros\Response;
use Laminas\Diactoros\ServerRequestFactory;
use Mezzio\Router\Route;
use Mezzio\Router\RouteResult;
-use Mezzio\Template\TemplateRendererInterface;
use PHPUnit\Framework\TestCase;
-use Prophecy\PhpUnit\ProphecyTrait;
-use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Shlinkio\Shlink\Core\ErrorHandler\NotFoundTemplateHandler;
class NotFoundTemplateHandlerTest extends TestCase
{
- use ProphecyTrait;
-
private NotFoundTemplateHandler $handler;
- private ObjectProphecy $renderer;
+ private Closure $readFile;
+ private bool $readFileCalled;
public function setUp(): void
{
- $this->renderer = $this->prophesize(TemplateRendererInterface::class);
- $this->handler = new NotFoundTemplateHandler($this->renderer->reveal());
+ $this->readFileCalled = false;
+ $this->readFile = function (string $fileName): string {
+ $this->readFileCalled = true;
+ return $fileName;
+ };
+ $this->handler = new NotFoundTemplateHandler($this->readFile);
}
/**
@@ -35,13 +36,11 @@ class NotFoundTemplateHandlerTest extends TestCase
*/
public function properErrorTemplateIsRendered(ServerRequestInterface $request, string $expectedTemplate): void
{
- $request = $request->withHeader('Accept', 'text/html');
- $render = $this->renderer->render($expectedTemplate)->willReturn('');
-
- $resp = $this->handler->handle($request);
+ $resp = $this->handler->handle($request->withHeader('Accept', 'text/html'));
self::assertInstanceOf(Response\HtmlResponse::class, $resp);
- $render->shouldHaveBeenCalledOnce();
+ self::assertStringContainsString($expectedTemplate, (string) $resp->getBody());
+ self::assertTrue($this->readFileCalled);
}
public function provideTemplates(): iterable
diff --git a/module/Rest/test/ConfigProviderTest.php b/module/Rest/test/ConfigProviderTest.php
index 69f745ff..462947c9 100644
--- a/module/Rest/test/ConfigProviderTest.php
+++ b/module/Rest/test/ConfigProviderTest.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest;
+use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Rest\ConfigProvider;
@@ -21,8 +22,12 @@ class ConfigProviderTest extends TestCase
{
$config = ($this->configProvider)();
+ self::assertCount(5, $config);
self::assertArrayHasKey('routes', $config);
self::assertArrayHasKey('dependencies', $config);
+ self::assertArrayHasKey('auth', $config);
+ self::assertArrayHasKey('entity_manager', $config);
+ self::assertArrayHasKey(ConfigAbstractFactory::class, $config);
}
/**
From abe0fc16df8765d8489176c4061445729d0b88b6 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Sun, 22 Nov 2020 18:13:12 +0100
Subject: [PATCH 005/118] #912 Updated changelog
---
CHANGELOG.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ee9d0b11..06adfb76 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,7 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
* [#869](https://github.com/shlinkio/shlink/issues/869) Added support for Mercure Hub 0.10.
### Changed
-* *Nothing*
+* [#912](https://github.com/shlinkio/shlink/issues/912) Changed error templates to be plain html files, removing the dependency on `league/plates` package.
### Deprecated
* *Nothing*
From a72dc16d85c330aec54a23f49d9f8ce024aa00c8 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Fri, 27 Nov 2020 17:05:13 +0100
Subject: [PATCH 006/118] #917
---
module/Core/config/routes.config.php | 12 +++++++++++-
module/Core/src/Action/QrCodeAction.php | 11 +++++++----
2 files changed, 18 insertions(+), 5 deletions(-)
diff --git a/module/Core/config/routes.config.php b/module/Core/config/routes.config.php
index 82abef30..a95e8e96 100644
--- a/module/Core/config/routes.config.php
+++ b/module/Core/config/routes.config.php
@@ -29,7 +29,17 @@ return [
],
[
'name' => Action\QrCodeAction::class,
- 'path' => '/{shortCode}/qr-code[/{size:[0-9]+}]',
+ 'path' => '/{shortCode}/qr-code',
+ 'middleware' => [
+ Action\QrCodeAction::class,
+ ],
+ 'allowed_methods' => [RequestMethod::METHOD_GET],
+ ],
+
+ // Deprecated
+ [
+ 'name' => 'old_' . Action\QrCodeAction::class,
+ 'path' => '/{shortCode}/qr-code/{size:[0-9]+}',
'middleware' => [
Action\QrCodeAction::class,
],
diff --git a/module/Core/src/Action/QrCodeAction.php b/module/Core/src/Action/QrCodeAction.php
index 4a8b7db5..0fb5089b 100644
--- a/module/Core/src/Action/QrCodeAction.php
+++ b/module/Core/src/Action/QrCodeAction.php
@@ -48,11 +48,15 @@ class QrCodeAction implements MiddlewareInterface
return $handler->handle($request);
}
+ $query = $request->getQueryParams();
+ // Size attribute is deprecated
+ $size = $this->normalizeSize($request->getAttribute('size', $query['size'] ?? self::DEFAULT_SIZE));
+
$qrCode = new QrCode($shortUrl->toString($this->domainConfig));
- $qrCode->setSize($this->getSizeParam($request));
+ $qrCode->setSize($size);
$qrCode->setMargin(0);
- $format = $request->getQueryParams()['format'] ?? 'png';
+ $format = $query['format'] ?? 'png';
if ($format === 'svg') {
$qrCode->setWriter(new SvgWriter());
}
@@ -60,9 +64,8 @@ class QrCodeAction implements MiddlewareInterface
return new QrCodeResponse($qrCode);
}
- private function getSizeParam(Request $request): int
+ private function normalizeSize(int $size): int
{
- $size = (int) $request->getAttribute('size', self::DEFAULT_SIZE);
if ($size < self::MIN_SIZE) {
return self::MIN_SIZE;
}
From fe59a5ad8606a07738003d580cdda2248a831c7b Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Fri, 27 Nov 2020 17:16:46 +0100
Subject: [PATCH 007/118] #917 Fixed cast to int on QR code action
---
module/Core/src/Action/QrCodeAction.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/module/Core/src/Action/QrCodeAction.php b/module/Core/src/Action/QrCodeAction.php
index 0fb5089b..4cdecf97 100644
--- a/module/Core/src/Action/QrCodeAction.php
+++ b/module/Core/src/Action/QrCodeAction.php
@@ -50,7 +50,7 @@ class QrCodeAction implements MiddlewareInterface
$query = $request->getQueryParams();
// Size attribute is deprecated
- $size = $this->normalizeSize($request->getAttribute('size', $query['size'] ?? self::DEFAULT_SIZE));
+ $size = $this->normalizeSize((int) $request->getAttribute('size', $query['size'] ?? self::DEFAULT_SIZE));
$qrCode = new QrCode($shortUrl->toString($this->domainConfig));
$qrCode->setSize($size);
From 4f1ab977a1cd6a6c91be1a1bede5a06efc6672b1 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Fri, 27 Nov 2020 17:42:33 +0100
Subject: [PATCH 008/118] #917 Added tests covering the different ways to
provide sizes to the QR codes
---
.../src/Action/AbstractTrackingAction.php | 2 +-
module/Core/src/Action/QrCodeAction.php | 2 +-
module/Core/test/Action/QrCodeActionTest.php | 46 +++++++++++++------
3 files changed, 33 insertions(+), 17 deletions(-)
diff --git a/module/Core/src/Action/AbstractTrackingAction.php b/module/Core/src/Action/AbstractTrackingAction.php
index b121ae3a..86eb197b 100644
--- a/module/Core/src/Action/AbstractTrackingAction.php
+++ b/module/Core/src/Action/AbstractTrackingAction.php
@@ -41,7 +41,7 @@ abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMet
$this->urlResolver = $urlResolver;
$this->visitTracker = $visitTracker;
$this->appOptions = $appOptions;
- $this->logger = $logger ?: new NullLogger();
+ $this->logger = $logger ?? new NullLogger();
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
diff --git a/module/Core/src/Action/QrCodeAction.php b/module/Core/src/Action/QrCodeAction.php
index 4cdecf97..919682d5 100644
--- a/module/Core/src/Action/QrCodeAction.php
+++ b/module/Core/src/Action/QrCodeAction.php
@@ -34,7 +34,7 @@ class QrCodeAction implements MiddlewareInterface
) {
$this->urlResolver = $urlResolver;
$this->domainConfig = $domainConfig;
- $this->logger = $logger ?: new NullLogger();
+ $this->logger = $logger ?? new NullLogger();
}
public function process(Request $request, RequestHandlerInterface $handler): Response
diff --git a/module/Core/test/Action/QrCodeActionTest.php b/module/Core/test/Action/QrCodeActionTest.php
index 1237585c..76daa406 100644
--- a/module/Core/test/Action/QrCodeActionTest.php
+++ b/module/Core/test/Action/QrCodeActionTest.php
@@ -6,11 +6,13 @@ namespace ShlinkioTest\Shlink\Core\Action;
use Laminas\Diactoros\Response;
use Laminas\Diactoros\ServerRequest;
+use Laminas\Diactoros\ServerRequestFactory;
use Mezzio\Router\RouterInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
+use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Common\Response\QrCodeResponse;
use Shlinkio\Shlink\Core\Action\QrCodeAction;
@@ -19,6 +21,8 @@ use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
+use function getimagesizefromstring;
+
class QrCodeActionTest extends TestCase
{
use ProphecyTrait;
@@ -51,21 +55,6 @@ class QrCodeActionTest extends TestCase
$process->shouldHaveBeenCalledOnce();
}
- /** @test */
- public function anInvalidShortCodeWillReturnNotFoundResponse(): void
- {
- $shortCode = 'abc123';
- $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, ''))
- ->willThrow(ShortUrlNotFoundException::class)
- ->shouldBeCalledOnce();
- $delegate = $this->prophesize(RequestHandlerInterface::class);
- $process = $delegate->handle(Argument::any())->willReturn(new Response());
-
- $this->action->process((new ServerRequest())->withAttribute('shortCode', $shortCode), $delegate->reveal());
-
- $process->shouldHaveBeenCalledOnce();
- }
-
/** @test */
public function aCorrectRequestReturnsTheQrCodeResponse(): void
{
@@ -110,4 +99,31 @@ class QrCodeActionTest extends TestCase
yield 'svg format' => [['format' => 'svg'], 'image/svg+xml'];
yield 'unsupported format' => [['format' => 'jpg'], 'image/png'];
}
+
+ /**
+ * @test
+ * @dataProvider provideRequestsWithSize
+ */
+ public function imageIsReturnedWithExpectedSize(ServerRequestInterface $req, int $expectedSize): void
+ {
+ $code = 'abc123';
+ $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($code, ''))->willReturn(new ShortUrl(''));
+ $delegate = $this->prophesize(RequestHandlerInterface::class);
+
+ $resp = $this->action->process($req->withAttribute('shortCode', $code), $delegate->reveal());
+ [$size] = getimagesizefromstring((string) $resp->getBody());
+
+ self::assertEquals($expectedSize, $size);
+ }
+
+ public function provideRequestsWithSize(): iterable
+ {
+ yield 'no size' => [ServerRequestFactory::fromGlobals(), 300];
+ yield 'size in attr' => [ServerRequestFactory::fromGlobals()->withAttribute('size', '400'), 400];
+ yield 'size in query' => [ServerRequestFactory::fromGlobals()->withQueryParams(['size' => '123']), 123];
+ yield 'size in query and attr' => [
+ ServerRequestFactory::fromGlobals()->withAttribute('size', '350')->withQueryParams(['size' => '123']),
+ 350,
+ ];
+ }
}
From c13adb04ef43274c15e0ed7aa7b38bccada7791c Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Fri, 27 Nov 2020 17:47:52 +0100
Subject: [PATCH 009/118] #917 Documented QR endpoint with query size and path
size
---
docs/swagger/paths/{shortCode}_qr-code.json | 2 +-
.../paths/{shortCode}_qr-code_{size}.json | 66 +++++++++++++++++++
docs/swagger/swagger.json | 3 +
3 files changed, 70 insertions(+), 1 deletion(-)
create mode 100644 docs/swagger/paths/{shortCode}_qr-code_{size}.json
diff --git a/docs/swagger/paths/{shortCode}_qr-code.json b/docs/swagger/paths/{shortCode}_qr-code.json
index a3fdaffb..3714f802 100644
--- a/docs/swagger/paths/{shortCode}_qr-code.json
+++ b/docs/swagger/paths/{shortCode}_qr-code.json
@@ -18,7 +18,7 @@
},
{
"name": "size",
- "in": "path",
+ "in": "query",
"description": "The size of the image to be returned.",
"required": false,
"schema": {
diff --git a/docs/swagger/paths/{shortCode}_qr-code_{size}.json b/docs/swagger/paths/{shortCode}_qr-code_{size}.json
new file mode 100644
index 00000000..fb5dd33e
--- /dev/null
+++ b/docs/swagger/paths/{shortCode}_qr-code_{size}.json
@@ -0,0 +1,66 @@
+{
+ "get": {
+ "operationId": "shortUrlQrCodeSize",
+ "deprecated": true,
+ "tags": [
+ "URL Shortener"
+ ],
+ "summary": "Short URL QR code",
+ "description": "Generates a QR code image pointing to a short URL",
+ "parameters": [
+ {
+ "name": "shortCode",
+ "in": "path",
+ "description": "The short code to resolve.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "size",
+ "in": "path",
+ "description": "The size of the image to be returned.",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "minimum": 50,
+ "maximum": 1000,
+ "default": 300
+ }
+ },
+ {
+ "name": "format",
+ "in": "query",
+ "description": "The format for the QR code image, being valid values png and svg. Not providing the param or providing any other value will fall back to png.",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "png",
+ "svg"
+ ]
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "QR code in PNG format",
+ "content": {
+ "image/png": {
+ "schema": {
+ "type": "string",
+ "format": "binary"
+ }
+ },
+ "image/svg+xml": {
+ "schema": {
+ "type": "string",
+ "format": "binary"
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json
index 8dc04412..dc834905 100644
--- a/docs/swagger/swagger.json
+++ b/docs/swagger/swagger.json
@@ -116,6 +116,9 @@
},
"/{shortCode}/qr-code": {
"$ref": "paths/{shortCode}_qr-code.json"
+ },
+ "/{shortCode}/qr-code/{size}": {
+ "$ref": "paths/{shortCode}_qr-code_{size}.json"
}
}
}
From cfdf2f9480bfcf614206058c40331048719f7d8d Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Fri, 27 Nov 2020 17:50:09 +0100
Subject: [PATCH 010/118] #917 Updated changelog
---
CHANGELOG.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 06adfb76..4336c93d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,7 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
* [#912](https://github.com/shlinkio/shlink/issues/912) Changed error templates to be plain html files, removing the dependency on `league/plates` package.
### Deprecated
-* *Nothing*
+* [#917](https://github.com/shlinkio/shlink/issues/917) Deprecated `/{shortCode}/qr-code/{size}` URL, in favor of providing the size in the query instead, `/{shortCode}/qr-code?size={size}`.
### Removed
* *Nothing*
From 1975a358370070090f36af2bb1ed93383d9e0379 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Sun, 29 Nov 2020 12:54:22 +0100
Subject: [PATCH 011/118] Updated to lcobucci/json 4.0 stable
---
composer.json | 2 +-
.../Adapter/ShortUrlRepositoryAdapter.php | 15 ---------------
.../Core/src/Repository/ShortUrlRepository.php | 17 +++++------------
3 files changed, 6 insertions(+), 28 deletions(-)
diff --git a/composer.json b/composer.json
index caf5e5dd..53a469fe 100644
--- a/composer.json
+++ b/composer.json
@@ -32,7 +32,7 @@
"laminas/laminas-paginator": "^2.8",
"laminas/laminas-servicemanager": "^3.4",
"laminas/laminas-stdlib": "^3.2",
- "lcobucci/jwt": "^4.0@beta",
+ "lcobucci/jwt": "^4.0",
"league/uri": "^6.2",
"lstrojny/functional-php": "^1.9",
"mezzio/mezzio": "^3.2",
diff --git a/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php b/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php
index f395412c..59d48a82 100644
--- a/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php
+++ b/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php
@@ -19,12 +19,6 @@ class ShortUrlRepositoryAdapter implements AdapterInterface
$this->params = $params;
}
- /**
- * Returns a collection of items for a page.
- *
- * @param int $offset Page offset
- * @param int $itemCountPerPage Number of items per page
- */
public function getItems($offset, $itemCountPerPage): array // phpcs:ignore
{
return $this->repository->findList(
@@ -37,15 +31,6 @@ class ShortUrlRepositoryAdapter implements AdapterInterface
);
}
- /**
- * Count elements of an object
- * @link http://php.net/manual/en/countable.count.php
- * @return int The custom count as an integer.
- *
- *
- * The return value is cast to an integer.
- * @since 5.1.0
- */
public function count(): int
{
return $this->repository->countList(
diff --git a/module/Core/src/Repository/ShortUrlRepository.php b/module/Core/src/Repository/ShortUrlRepository.php
index 27dac54b..95e50f40 100644
--- a/module/Core/src/Repository/ShortUrlRepository.php
+++ b/module/Core/src/Repository/ShortUrlRepository.php
@@ -33,15 +33,9 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
?DateRange $dateRange = null
): array {
$qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange);
- $qb->select('DISTINCT s');
-
- // Set limit and offset
- if ($limit !== null) {
- $qb->setMaxResults($limit);
- }
- if ($offset !== null) {
- $qb->setFirstResult($offset);
- }
+ $qb->select('DISTINCT s')
+ ->setMaxResults($limit)
+ ->setFirstResult($offset);
// In case the ordering has been specified, the query could be more complex. Process it
if ($orderBy !== null && $orderBy->hasOrderField()) {
@@ -147,7 +141,7 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
WHERE s.shortCode = :shortCode
AND (s.domain IS NULL OR d.authority = :domain)
ORDER BY s.domain {$ordering}
-DQL;
+ DQL;
$query = $this->getEntityManager()->createQuery($dql);
$query->setMaxResults(1)
@@ -220,9 +214,8 @@ DQL;
}
if ($meta->hasValidUntil()) {
$qb->andWhere($qb->expr()->eq('s.validUntil', ':validUntil'))
- ->setParameter('validUntil', $meta->getValidUntil());
+ ->setParameter('validUntil', $meta->getValidUntil());
}
-
if ($meta->hasDomain()) {
$qb->join('s.domain', 'd')
->andWhere($qb->expr()->eq('d.authority', ':domain'))
From e54745b250a38b9e45c27fe63869484bd227c3c5 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Sun, 29 Nov 2020 14:01:26 +0100
Subject: [PATCH 012/118] #833 Enabled unix socket option during installation
---
composer.json | 2 +-
config/autoload/installer.global.php | 1 +
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/composer.json b/composer.json
index 53a469fe..d8479f51 100644
--- a/composer.json
+++ b/composer.json
@@ -52,7 +52,7 @@
"shlinkio/shlink-config": "^1.0",
"shlinkio/shlink-event-dispatcher": "^1.4",
"shlinkio/shlink-importer": "^2.0.1",
- "shlinkio/shlink-installer": "^5.1.0",
+ "shlinkio/shlink-installer": "^5.2.0",
"shlinkio/shlink-ip-geolocation": "^1.5",
"symfony/console": "^5.1",
"symfony/filesystem": "^5.1",
diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php
index ba0b8332..a04d874b 100644
--- a/config/autoload/installer.global.php
+++ b/config/autoload/installer.global.php
@@ -14,6 +14,7 @@ return [
Option\Database\DatabasePortConfigOption::class,
Option\Database\DatabaseUserConfigOption::class,
Option\Database\DatabasePasswordConfigOption::class,
+ Option\Database\DatabaseUnixSocketConfigOption::class,
Option\Database\DatabaseSqlitePathConfigOption::class,
Option\Database\DatabaseMySqlOptionsConfigOption::class,
Option\UrlShortener\ShortDomainHostConfigOption::class,
From f34033aa9c6737de1e5deaa25118175c92297b5d Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Sun, 29 Nov 2020 19:46:34 +0100
Subject: [PATCH 013/118] Documented how to provide the unix socket to connect
to mysql, maria and postgres databases
---
docker/README.md | 7 ++++++-
docker/config/shlink_in_docker.local.php | 6 ++++--
module/Core/src/Config/SimplifiedConfigParser.php | 1 +
3 files changed, 11 insertions(+), 3 deletions(-)
diff --git a/docker/README.md b/docker/README.md
index 2cc0b5b9..c7627d7f 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -157,6 +157,7 @@ This is the complete list of supported env vars:
* **mysql** or **maria** -> `3306`
* **postgres** -> `5432`
* **mssql** -> `1433`
+* `DB_UNIX_SOCKET`: Alternatively to the `DB_HOST`, you can provide this to connect through unix sockets when using `mysql`, `maria` or `postgres` drivers.
* `DISABLE_TRACK_PARAM`: The name of a query param that can be used to visit short URLs avoiding the visit to be tracked. This feature won't be available if not value is provided.
* `DELETE_SHORT_URL_THRESHOLD`: The amount of visits on short URLs which will not allow them to be deleted. Defaults to `15`.
* `VALIDATE_URLS`: Boolean which tells if shlink should validate a status 20x is returned (after following redirects) when trying to shorten a URL. Defaults to `false`.
@@ -215,7 +216,11 @@ docker run \
shlinkio/shlink:stable
```
-## Provide config via volumes
+## [DEPRECATED] Provide config via volumes
+
+> As of v2.5.0, providing config through volumes is deprecated, and no new options will be added anymore. Use env vars instead.
+>
+> Support for config options through volumes will be removed in Shlink v3.0.0
Rather than providing custom configuration via env vars, it is also possible ot provide config files in json format.
diff --git a/docker/config/shlink_in_docker.local.php b/docker/config/shlink_in_docker.local.php
index c4502b7c..c6d7f69e 100644
--- a/docker/config/shlink_in_docker.local.php
+++ b/docker/config/shlink_in_docker.local.php
@@ -34,6 +34,7 @@ $helper = new class {
public function getDbConfig(): array
{
$driver = env('DB_DRIVER');
+ $isMysql = contains(['maria', 'mysql'], $driver);
if ($driver === null || $driver === 'sqlite') {
return [
'driver' => 'pdo_sqlite',
@@ -41,7 +42,7 @@ $helper = new class {
];
}
- $driverOptions = ! contains(['maria', 'mysql'], $driver) ? [] : [
+ $driverOptions = ! $isMysql ? [] : [
// 1002 -> PDO::MYSQL_ATTR_INIT_COMMAND
1002 => 'SET NAMES utf8',
// 1000 -> PDO::MYSQL_ATTR_USE_BUFFERED_QUERY
@@ -52,9 +53,10 @@ $helper = new class {
'dbname' => env('DB_NAME', 'shlink'),
'user' => env('DB_USER'),
'password' => env('DB_PASSWORD'),
- 'host' => env('DB_HOST'),
+ 'host' => env('DB_HOST', $driver === 'postgres' ? env('DB_UNIX_SOCKET') : null),
'port' => env('DB_PORT', self::DB_PORTS_MAP[$driver]),
'driverOptions' => $driverOptions,
+ 'unix_socket' => $isMysql ? env('DB_UNIX_SOCKET') : null,
];
}
diff --git a/module/Core/src/Config/SimplifiedConfigParser.php b/module/Core/src/Config/SimplifiedConfigParser.php
index aebeb2c3..b578799b 100644
--- a/module/Core/src/Config/SimplifiedConfigParser.php
+++ b/module/Core/src/Config/SimplifiedConfigParser.php
@@ -15,6 +15,7 @@ use function Functional\contains;
use function Functional\reduce_left;
use function uksort;
+/** @deprecated */
class SimplifiedConfigParser
{
private const SIMPLIFIED_CONFIG_MAPPING = [
From bfd886604e4354b85a11dedaffb144eb10bfea12 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Sun, 29 Nov 2020 19:50:39 +0100
Subject: [PATCH 014/118] Updated changelog
---
CHANGELOG.md | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4336c93d..24bd6df0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,12 +7,16 @@ 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.
+* [#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.
### Changed
* [#912](https://github.com/shlinkio/shlink/issues/912) Changed error templates to be plain html files, removing the dependency on `league/plates` package.
### Deprecated
* [#917](https://github.com/shlinkio/shlink/issues/917) Deprecated `/{shortCode}/qr-code/{size}` URL, in favor of providing the size in the query instead, `/{shortCode}/qr-code?size={size}`.
+* [#924](https://github.com/shlinkio/shlink/issues/924) Deprecated mechanism to provide config options to the docker image through volumes. Use the env vars instead as a direct replacement.
### Removed
* *Nothing*
From 55ea8a6912e1ff7e1587e9d684cdfbad63174d14 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Wed, 2 Dec 2020 12:00:05 +0100
Subject: [PATCH 015/118] #896 Added support for unicode characters in custom
slugs
---
module/Core/functions/functions.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php
index 2f7f86e9..6c82cbc7 100644
--- a/module/Core/functions/functions.php
+++ b/module/Core/functions/functions.php
@@ -18,7 +18,7 @@ const MIN_SHORT_CODES_LENGTH = 4;
const DEFAULT_REDIRECT_STATUS_CODE = StatusCodeInterface::STATUS_FOUND;
const DEFAULT_REDIRECT_CACHE_LIFETIME = 30;
const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory';
-const CUSTOM_SLUGS_REGEXP = '/[^A-Za-z0-9._~]+/';
+const CUSTOM_SLUGS_REGEXP = '/[^\pL\pN._~]/u'; // Any unicode word or number, plus ".", "_" and "~" chars
function generateRandomShortCode(int $length): string
{
From 6e9fa6553d137c7e165845f2343bf8db191116d7 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Wed, 2 Dec 2020 12:01:35 +0100
Subject: [PATCH 016/118] Updated changelog
---
CHANGELOG.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 24bd6df0..11690ba5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
It can be provided during the installation, or as the `DB_UNIX_SOCKET` env var for the docker image.
+* [#896](https://github.com/shlinkio/shlink/issues/896) Added support for unicode characters in custom slugs.
+
### Changed
* [#912](https://github.com/shlinkio/shlink/issues/912) Changed error templates to be plain html files, removing the dependency on `league/plates` package.
From 7ac1c32ad6b890a7c8360e4b458e116840d7eb3a Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Wed, 2 Dec 2020 12:02:49 +0100
Subject: [PATCH 017/118] Fixed typo
---
module/Core/functions/functions.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php
index 6c82cbc7..6ae232be 100644
--- a/module/Core/functions/functions.php
+++ b/module/Core/functions/functions.php
@@ -18,7 +18,7 @@ const MIN_SHORT_CODES_LENGTH = 4;
const DEFAULT_REDIRECT_STATUS_CODE = StatusCodeInterface::STATUS_FOUND;
const DEFAULT_REDIRECT_CACHE_LIFETIME = 30;
const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory';
-const CUSTOM_SLUGS_REGEXP = '/[^\pL\pN._~]/u'; // Any unicode word or number, plus ".", "_" and "~" chars
+const CUSTOM_SLUGS_REGEXP = '/[^\pL\pN._~]/u'; // Any unicode letter or number, plus ".", "_" and "~" chars
function generateRandomShortCode(int $length): string
{
From 6bedca4ee690927188195c94f559788bf97b4e3a Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Wed, 2 Dec 2020 18:38:23 +0100
Subject: [PATCH 018/118] Added more tests covering unicode in custom slugs
---
module/Core/src/Validation/ShortUrlMetaInputFilter.php | 1 +
module/Core/test/Model/ShortUrlMetaTest.php | 5 +++++
2 files changed, 6 insertions(+)
diff --git a/module/Core/src/Validation/ShortUrlMetaInputFilter.php b/module/Core/src/Validation/ShortUrlMetaInputFilter.php
index e3b630e4..9d3f8ec5 100644
--- a/module/Core/src/Validation/ShortUrlMetaInputFilter.php
+++ b/module/Core/src/Validation/ShortUrlMetaInputFilter.php
@@ -54,6 +54,7 @@ class ShortUrlMetaInputFilter extends InputFilter
$customSlug->getFilterChain()->attach(new Validation\SluggerFilter(new CocurSymfonySluggerBridge(new Slugify([
'regexp' => CUSTOM_SLUGS_REGEXP,
'lowercase' => false, // We want to keep it case sensitive
+ 'rulesets' => ['default'],
]))));
$customSlug->getValidatorChain()->attach(new Validator\NotEmpty([
Validator\NotEmpty::STRING,
diff --git a/module/Core/test/Model/ShortUrlMetaTest.php b/module/Core/test/Model/ShortUrlMetaTest.php
index c8cc3ff6..3c45dad9 100644
--- a/module/Core/test/Model/ShortUrlMetaTest.php
+++ b/module/Core/test/Model/ShortUrlMetaTest.php
@@ -88,5 +88,10 @@ class ShortUrlMetaTest extends TestCase
yield ['wp-admin.php', 'wp-admin.php'];
yield ['UPPER_lower', 'UPPER_lower'];
yield ['more~url_special.chars', 'more~url_special.chars'];
+ yield ['äéñ', 'äen'];
+ yield ['구글', '구글'];
+ yield ['グーグル', 'グーグル'];
+ yield ['谷歌', '谷歌'];
+ yield ['гугл', 'гугл'];
}
}
From 8c79619ff2388007cb696b6b512f2ebab46d3624 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Mon, 30 Nov 2020 22:33:42 +0100
Subject: [PATCH 019/118] Updated to PHP8 compatible versions of
symfony/mercure and pugx/shortid-php
---
composer.json | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/composer.json b/composer.json
index d8479f51..1106096f 100644
--- a/composer.json
+++ b/composer.json
@@ -46,7 +46,7 @@
"phly/phly-event-dispatcher": "^1.0",
"php-middleware/request-id": "^4.0",
"predis/predis": "^1.1",
- "pugx/shortid-php": "^0.5",
+ "pugx/shortid-php": "^0.7",
"ramsey/uuid": "^3.9",
"shlinkio/shlink-common": "^3.3.2",
"shlinkio/shlink-config": "^1.0",
@@ -57,7 +57,7 @@
"symfony/console": "^5.1",
"symfony/filesystem": "^5.1",
"symfony/lock": "^5.1",
- "symfony/mercure": "^0.4.0",
+ "symfony/mercure": "^0.4.1",
"symfony/process": "^5.1",
"symfony/string": "^5.1"
},
From d3a4ed607c2bb9df986516254cd3569f19f2eb94 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Thu, 3 Dec 2020 22:27:25 +0100
Subject: [PATCH 020/118] Replaced --ignore-platform-reqs by
--ignore-platform-req=php when running build on PHP 8
---
.travis.yml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/.travis.yml b/.travis.yml
index 3bf55b55..732d635b 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -16,12 +16,12 @@ cache:
jobs:
fast_finish: true
allow_failures:
- - php: 'nightly'
+ - php: '8.0'
include:
- name: "CI - 8.0"
- php: 'nightly'
+ php: '8.0'
env:
- - COMPOSER_FLAGS='--ignore-platform-reqs'
+ - COMPOSER_FLAGS='--ignore-platform-req=php'
- name: "CI - 7.4"
php: '7.4'
env:
From 55bfa9776a8541fb5adf5d22b5c7be417f159106 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Thu, 3 Dec 2020 23:25:27 +0100
Subject: [PATCH 021/118] Updated to shlinkio/shlink-event-dispatcher 1.6
---
composer.json | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/composer.json b/composer.json
index 1106096f..21c178cb 100644
--- a/composer.json
+++ b/composer.json
@@ -43,14 +43,13 @@
"monolog/monolog": "^2.0",
"nikolaposa/monolog-factory": "^3.0",
"ocramius/proxy-manager": "^2.7.0",
- "phly/phly-event-dispatcher": "^1.0",
"php-middleware/request-id": "^4.0",
"predis/predis": "^1.1",
"pugx/shortid-php": "^0.7",
"ramsey/uuid": "^3.9",
"shlinkio/shlink-common": "^3.3.2",
"shlinkio/shlink-config": "^1.0",
- "shlinkio/shlink-event-dispatcher": "^1.4",
+ "shlinkio/shlink-event-dispatcher": "^1.6",
"shlinkio/shlink-importer": "^2.0.1",
"shlinkio/shlink-installer": "^5.2.0",
"shlinkio/shlink-ip-geolocation": "^1.5",
From b7a0d319b32e41c0f8c6caa064cc9a0ac8d095f5 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Fri, 4 Dec 2020 18:50:00 +0100
Subject: [PATCH 022/118] Updated more dependencies to support PHP8
---
composer.json | 4 ++--
module/Core/src/Repository/ShortUrlRepository.php | 9 +++++----
2 files changed, 7 insertions(+), 6 deletions(-)
diff --git a/composer.json b/composer.json
index 21c178cb..26e61e06 100644
--- a/composer.json
+++ b/composer.json
@@ -21,7 +21,7 @@
"doctrine/cache": "^1.9",
"doctrine/dbal": "^2.10",
"doctrine/migrations": "^3.0.1",
- "doctrine/orm": "^2.7",
+ "doctrine/orm": "^2.8",
"endroid/qr-code": "^3.6",
"geoip2/geoip2": "^2.9",
"guzzlehttp/guzzle": "^7.0",
@@ -50,7 +50,7 @@
"shlinkio/shlink-common": "^3.3.2",
"shlinkio/shlink-config": "^1.0",
"shlinkio/shlink-event-dispatcher": "^1.6",
- "shlinkio/shlink-importer": "^2.0.1",
+ "shlinkio/shlink-importer": "^2.1",
"shlinkio/shlink-installer": "^5.2.0",
"shlinkio/shlink-ip-geolocation": "^1.5",
"symfony/console": "^5.1",
diff --git a/module/Core/src/Repository/ShortUrlRepository.php b/module/Core/src/Repository/ShortUrlRepository.php
index 95e50f40..fc6ace41 100644
--- a/module/Core/src/Repository/ShortUrlRepository.php
+++ b/module/Core/src/Repository/ShortUrlRepository.php
@@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\Repository;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
+use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
@@ -93,11 +94,11 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
if ($dateRange !== null && $dateRange->getStartDate() !== null) {
$qb->andWhere($qb->expr()->gte('s.dateCreated', ':startDate'));
- $qb->setParameter('startDate', $dateRange->getStartDate());
+ $qb->setParameter('startDate', $dateRange->getStartDate(), ChronosDateTimeType::CHRONOS_DATETIME);
}
if ($dateRange !== null && $dateRange->getEndDate() !== null) {
$qb->andWhere($qb->expr()->lte('s.dateCreated', ':endDate'));
- $qb->setParameter('endDate', $dateRange->getEndDate());
+ $qb->setParameter('endDate', $dateRange->getEndDate(), ChronosDateTimeType::CHRONOS_DATETIME);
}
// Apply search term to every searchable field if not empty
@@ -210,11 +211,11 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
}
if ($meta->hasValidSince()) {
$qb->andWhere($qb->expr()->eq('s.validSince', ':validSince'))
- ->setParameter('validSince', $meta->getValidSince());
+ ->setParameter('validSince', $meta->getValidSince(), ChronosDateTimeType::CHRONOS_DATETIME);
}
if ($meta->hasValidUntil()) {
$qb->andWhere($qb->expr()->eq('s.validUntil', ':validUntil'))
- ->setParameter('validUntil', $meta->getValidUntil());
+ ->setParameter('validUntil', $meta->getValidUntil(), ChronosDateTimeType::CHRONOS_DATETIME);
}
if ($meta->hasDomain()) {
$qb->join('s.domain', 'd')
From c78991761f10abbc85800a436d73260f4dd9814d Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Sun, 6 Dec 2020 11:29:23 +0100
Subject: [PATCH 023/118] Fixed quotes in travis config
---
.travis.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.travis.yml b/.travis.yml
index 732d635b..13193a0d 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -18,11 +18,11 @@ jobs:
allow_failures:
- php: '8.0'
include:
- - name: "CI - 8.0"
+ - name: 'CI - 8.0'
php: '8.0'
env:
- COMPOSER_FLAGS='--ignore-platform-req=php'
- - name: "CI - 7.4"
+ - name: 'CI - 7.4'
php: '7.4'
env:
- COMPOSER_FLAGS=''
From 40105d7aaf6fb908b34d56e474f41b272137a0d7 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Sun, 6 Dec 2020 11:41:27 +0100
Subject: [PATCH 024/118] Updated to latest swoole and pdo_sqlsrv extensions
---
.github/workflows/publish-release.yml | 2 +-
.travis.yml | 2 +-
Dockerfile | 2 +-
data/infra/swoole.Dockerfile | 2 +-
4 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml
index 78f981ab..c1009f1c 100644
--- a/.github/workflows/publish-release.yml
+++ b/.github/workflows/publish-release.yml
@@ -16,7 +16,7 @@ jobs:
with:
php-version: '7.4' # Publish release with lowest supported PHP version
tools: composer
- extensions: swoole-4.5.5
+ extensions: swoole-4.5.9
- name: Generate release assets
run: ./build.sh ${GITHUB_REF#refs/tags/v}
- name: Publish release with assets
diff --git a/.travis.yml b/.travis.yml
index 13193a0d..f3857e5d 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -32,7 +32,7 @@ before_install:
- phpenv config-rm xdebug.ini || return 0
- sudo ./data/infra/ci/install-ms-odbc.sh
- docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_ms shlink_db shlink_db_postgres shlink_db_maria
- - yes | pecl install pdo_sqlsrv-5.9.0preview1 swoole-4.5.5 pcov
+ - yes | pecl install pdo_sqlsrv-5.9.0beta2 swoole-4.5.9 pcov
install:
- composer self-update
diff --git a/Dockerfile b/Dockerfile
index cc7c403d..9d7e0bef 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,7 +2,7 @@ FROM php:7.4.11-alpine3.12 as base
ARG SHLINK_VERSION=2.4.0
ENV SHLINK_VERSION ${SHLINK_VERSION}
-ENV SWOOLE_VERSION 4.5.5
+ENV SWOOLE_VERSION 4.5.9
ENV LC_ALL "C"
WORKDIR /etc/shlink
diff --git a/data/infra/swoole.Dockerfile b/data/infra/swoole.Dockerfile
index 00d197ba..b6bfb5a7 100644
--- a/data/infra/swoole.Dockerfile
+++ b/data/infra/swoole.Dockerfile
@@ -4,7 +4,7 @@ MAINTAINER Alejandro Celaya
ENV APCU_VERSION 5.1.18
ENV APCU_BC_VERSION 1.0.5
ENV INOTIFY_VERSION 2.0.0
-ENV SWOOLE_VERSION 4.5.5
+ENV SWOOLE_VERSION 4.5.9
RUN apk update
From b2658073b3022f7e5759392171fa38d21348cb88 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Fri, 11 Dec 2020 21:42:40 +0100
Subject: [PATCH 025/118] Created script to update config options
---
README.md | 11 +++++++++++
bin/set-option | 14 ++++++++++++++
composer.json | 8 ++++----
3 files changed, 29 insertions(+), 4 deletions(-)
create mode 100755 bin/set-option
diff --git a/README.md b/README.md
index 1b03c048..1f6d2149 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,7 @@ A PHP-based self-hosted URL shortener that can be used to serve shortened URLs u
- [Serve](#serve)
- [Bonus](#bonus)
- [Update to new version](#update-to-new-version)
+ - [Update a configuration option](#update-a-configuration-option)
- [Using a docker image](#using-a-docker-image)
- [Using shlink](#using-shlink)
- [Shlink CLI Help](#shlink-cli-help)
@@ -225,6 +226,16 @@ The `bin/update` will use the location from previous shlink version to import th
**Important!** It is recommended that you don't skip any version when using this process. The update tool gets better on every version, but older versions might make assumptions.
+### Update a configuration option
+
+Sometimes you need to update the configuration on your shlink instance. Maybe you want to change the GeoLite2 license key, or move from http to https.
+
+In order to do that, run `bin/set-option` and follow the instructions. You will be asked to select the option to change, and then you will be asked to provide the new value.
+
+This script will take care of updating that value without changing anything else, and it will also delete the configuration cache so that the new value is applied.
+
+> This script will fail if you didn't run `bin/install` at least once.
+
## Using a docker image
Starting with version 1.15.0, an official docker image is provided. You can learn how to use it by reading [the docs](docker/README.md).
diff --git a/bin/set-option b/bin/set-option
new file mode 100755
index 00000000..ff727f30
--- /dev/null
+++ b/bin/set-option
@@ -0,0 +1,14 @@
+#!/usr/bin/env php
+
Date: Fri, 11 Dec 2020 21:43:43 +0100
Subject: [PATCH 026/118] Updated changelog
---
CHANGELOG.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 11690ba5..b4554f60 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
It can be provided during the installation, or as the `DB_UNIX_SOCKET` env var for the docker image.
* [#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.
### Changed
* [#912](https://github.com/shlinkio/shlink/issues/912) Changed error templates to be plain html files, removing the dependency on `league/plates` package.
From 77deb9c1114de83b2b487de964e955a7ff453a7d Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Sun, 13 Dec 2020 10:44:02 +0100
Subject: [PATCH 027/118] Created first version of the ci workflow
---
.github/workflows/ci.yml | 118 +++++++++++++++++++++++++++++++++++++++
1 file changed, 118 insertions(+)
create mode 100644 .github/workflows/ci.yml
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 00000000..d6baae37
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,118 @@
+name: Continuous integration
+
+on:
+ pull_request: null
+ push:
+ branches:
+ - main
+ - develop
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v2
+ - name: Use PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '7.4'
+ tools: composer
+ extensions: swoole-4.5.9
+ - run: composer install --no-interaction --prefer-dist
+ - run: composer cs
+
+ unit-tests:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ php-version:
+ - 7.4
+ - 8.0
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v2
+ - name: Use PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-version }}
+ tools: composer
+ extensions: swoole-4.5.9
+ - if: ${{ matrix.php-version == '8.0' }}
+ run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
+ - if: ${{ matrix.php-version != '8.0' }}
+ run: composer install --no-interaction --prefer-dist
+ - run: composer test:unit:ci
+ # TODO Upload code coverage
+
+ db-tests-sqlite:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ php-version:
+ - 7.4
+ - 8.0
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v2
+ - name: Use PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-version }}
+ tools: composer
+ extensions: swoole-4.5.9
+ - if: ${{ matrix.php-version == '8.0' }}
+ run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
+ - if: ${{ matrix.php-version != '8.0' }}
+ run: composer install --no-interaction --prefer-dist
+ - run: composer test:db:sqlite:ci
+ # TODO Upload code coverage
+
+ db-tests-mysql:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ php-version:
+ - 7.4
+ - 8.0
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v2
+ - name: Start database
+ run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db
+ - name: Use PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-version }}
+ tools: composer
+ extensions: swoole-4.5.9
+ - if: ${{ matrix.php-version == '8.0' }}
+ run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
+ - if: ${{ matrix.php-version != '8.0' }}
+ run: composer install --no-interaction --prefer-dist
+ - run: composer test:db:mysql
+
+ api-tests:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ php-version:
+ - 7.4
+ - 8.0
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v2
+ - name: Start database
+ run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db
+ - name: Use PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-version }}
+ tools: composer
+ extensions: swoole-4.5.9
+ - if: ${{ matrix.php-version == '8.0' }}
+ run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
+ - if: ${{ matrix.php-version != '8.0' }}
+ run: composer install --no-interaction --prefer-dist
+ - run: bin/test/run-api-tests.sh
+ # TODO Upload code coverage
From 5040f5b1773e6a97509b0a5d79a8fe5e005de63a Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Sun, 13 Dec 2020 11:07:37 +0100
Subject: [PATCH 028/118] Changed condition to determine if tests are run in CI
---
config/test/test_config.global.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/config/test/test_config.global.php b/config/test/test_config.global.php
index 6b3c6612..3608257e 100644
--- a/config/test/test_config.global.php
+++ b/config/test/test_config.global.php
@@ -36,7 +36,7 @@ if ($isApiTest) {
$buildDbConnection = function (): array {
$driver = env('DB_DRIVER', 'sqlite');
- $isCi = env('TRAVIS', false);
+ $isCi = env('CI', false);
$getMysqlHost = fn (string $driver) => sprintf('shlink_db%s', $driver === 'mysql' ? '' : '_maria');
$getCiMysqlPort = fn (string $driver) => $driver === 'mysql' ? '3307' : '3308';
From 0e58d1a242a21babb15d3658aaa10d003d112dd1 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Sun, 13 Dec 2020 11:37:45 +0100
Subject: [PATCH 029/118] Added pcov as code coverage driver in github action
---
.github/workflows/ci.yml | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d6baae37..4ad1809e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -38,6 +38,8 @@ jobs:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.5.9
+ coverage: pcov
+ ini-values: pcov.directory=module
- if: ${{ matrix.php-version == '8.0' }}
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
- if: ${{ matrix.php-version != '8.0' }}
@@ -61,6 +63,8 @@ jobs:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.5.9
+ coverage: pcov
+ ini-values: pcov.directory=module
- if: ${{ matrix.php-version == '8.0' }}
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
- if: ${{ matrix.php-version != '8.0' }}
@@ -110,6 +114,8 @@ jobs:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.5.9
+ coverage: pcov
+ ini-values: pcov.directory=module
- if: ${{ matrix.php-version == '8.0' }}
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
- if: ${{ matrix.php-version != '8.0' }}
From ac85b913c20026ae06c04c89dc523329ce158e74 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Sun, 13 Dec 2020 12:31:34 +0100
Subject: [PATCH 030/118] Added other database test envs to ci workflow
---
.github/workflows/ci.yml | 104 ++++++++++++++++++++++++++++++++-------
1 file changed, 87 insertions(+), 17 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4ad1809e..df73356b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -10,25 +10,28 @@ on:
jobs:
lint:
runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ php-version: ['7.4']
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Use PHP
uses: shivammathur/setup-php@v2
with:
- php-version: '7.4'
+ php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.5.9
+ coverage: none
- run: composer install --no-interaction --prefer-dist
- run: composer cs
unit-tests:
runs-on: ubuntu-latest
+ continue-on-error: ${{ matrix.php-version == '8.0' }}
strategy:
matrix:
- php-version:
- - 7.4
- - 8.0
+ php-version: ['7.4', '8.0']
steps:
- name: Checkout code
uses: actions/checkout@v2
@@ -45,15 +48,13 @@ jobs:
- if: ${{ matrix.php-version != '8.0' }}
run: composer install --no-interaction --prefer-dist
- run: composer test:unit:ci
- # TODO Upload code coverage
+ # TODO Upload code coverage on one PHP version only
db-tests-sqlite:
runs-on: ubuntu-latest
strategy:
matrix:
- php-version:
- - 7.4
- - 8.0
+ php-version: ['7.4', '8.0']
steps:
- name: Checkout code
uses: actions/checkout@v2
@@ -70,15 +71,13 @@ jobs:
- if: ${{ matrix.php-version != '8.0' }}
run: composer install --no-interaction --prefer-dist
- run: composer test:db:sqlite:ci
- # TODO Upload code coverage
+ # TODO Upload code coverage on one PHP version only
db-tests-mysql:
runs-on: ubuntu-latest
strategy:
matrix:
- php-version:
- - 7.4
- - 8.0
+ php-version: ['7.4', '8.0']
steps:
- name: Checkout code
uses: actions/checkout@v2
@@ -90,19 +89,90 @@ jobs:
php-version: ${{ matrix.php-version }}
tools: composer
extensions: swoole-4.5.9
+ coverage: none
- if: ${{ matrix.php-version == '8.0' }}
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
- if: ${{ matrix.php-version != '8.0' }}
run: composer install --no-interaction --prefer-dist
- run: composer test:db:mysql
- api-tests:
+ db-tests-maria:
runs-on: ubuntu-latest
strategy:
matrix:
- php-version:
- - 7.4
- - 8.0
+ php-version: ['7.4', '8.0']
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v2
+ - name: Start database
+ run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_maria
+ - name: Use PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-version }}
+ tools: composer
+ extensions: swoole-4.5.9
+ coverage: none
+ - if: ${{ matrix.php-version == '8.0' }}
+ run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
+ - if: ${{ matrix.php-version != '8.0' }}
+ run: composer install --no-interaction --prefer-dist
+ - run: composer test:db:maria
+
+ db-tests-postgres:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ php-version: ['7.4', '8.0']
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v2
+ - name: Start database
+ run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres
+ - name: Use PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-version }}
+ tools: composer
+ extensions: swoole-4.5.9
+ coverage: none
+ - if: ${{ matrix.php-version == '8.0' }}
+ run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
+ - if: ${{ matrix.php-version != '8.0' }}
+ run: composer install --no-interaction --prefer-dist
+ - run: composer test:db:postgres
+
+ db-tests-ms:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ php-version: ['7.4', '8.0']
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v2
+ - name: Install MSSQL ODBC
+ run: ./data/infra/ci/install-ms-odbc.sh
+ - name: Start database
+ run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_ms
+ - name: Use PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-version }}
+ tools: composer
+ extensions: swoole-4.5.9, pdo_sqlsrv-5.9.0beta2
+ coverage: none
+ - if: ${{ matrix.php-version == '8.0' }}
+ run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
+ - if: ${{ matrix.php-version != '8.0' }}
+ run: composer install --no-interaction --prefer-dist
+ - run: composer test:db:ms
+
+ api-tests:
+ runs-on: ubuntu-latest
+ continue-on-error: ${{ matrix.php-version == '8.0' }}
+ strategy:
+ matrix:
+ php-version: ['7.4', '8.0']
steps:
- name: Checkout code
uses: actions/checkout@v2
@@ -121,4 +191,4 @@ jobs:
- if: ${{ matrix.php-version != '8.0' }}
run: composer install --no-interaction --prefer-dist
- run: bin/test/run-api-tests.sh
- # TODO Upload code coverage
+ # TODO Upload code coverage on one PHP version only
From 7fe7354a27bf8628ec1cd37f6550ba2f82a039c6 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Sun, 13 Dec 2020 12:38:12 +0100
Subject: [PATCH 031/118] Ensured mssql odbc installation is done as super user
---
.github/workflows/ci.yml | 2 +-
data/infra/ci/install-ms-odbc.sh | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index df73356b..56d3ac11 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -151,7 +151,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@v2
- name: Install MSSQL ODBC
- run: ./data/infra/ci/install-ms-odbc.sh
+ run: sudo ./data/infra/ci/install-ms-odbc.sh
- name: Start database
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_ms
- name: Use PHP
diff --git a/data/infra/ci/install-ms-odbc.sh b/data/infra/ci/install-ms-odbc.sh
index 8cd60580..1efdf8a3 100755
--- a/data/infra/ci/install-ms-odbc.sh
+++ b/data/infra/ci/install-ms-odbc.sh
@@ -3,7 +3,7 @@
set -ex
curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add -
-curl https://packages.microsoft.com/config/ubuntu/16.04/prod.list > /etc/apt/sources.list.d/mssql-release.list
+curl https://packages.microsoft.com/config/ubuntu/20.04/prod.list > /etc/apt/sources.list.d/mssql-release.list
apt-get update
ACCEPT_EULA=Y apt-get install msodbcsql17
apt-get install unixodbc-dev
From 5dc2c1640acdf1ceeca1887b56050a8ee80e6c38 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Sun, 13 Dec 2020 12:40:38 +0100
Subject: [PATCH 032/118] Added command to create mssql database for tests
---
.github/workflows/ci.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 56d3ac11..16c99d75 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -165,6 +165,7 @@ jobs:
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
- if: ${{ matrix.php-version != '8.0' }}
run: composer install --no-interaction --prefer-dist
+ - run: docker-compose exec -T shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;"
- run: composer test:db:ms
api-tests:
From ac5a22a3d0404e9183b40c62864dd5df86360107 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Sun, 13 Dec 2020 12:59:06 +0100
Subject: [PATCH 033/118] Added static analysis and generation of code coverage
artifacts
---
.github/workflows/ci.yml | 52 +++++++++++++++++++++++++++++++++-------
1 file changed, 43 insertions(+), 9 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 16c99d75..d96e5ce5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -26,6 +26,24 @@ jobs:
- run: composer install --no-interaction --prefer-dist
- run: composer cs
+ static-analysis:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ php-version: ['7.4']
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v2
+ - name: Use PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-version }}
+ tools: composer
+ extensions: swoole-4.5.9
+ coverage: none
+ - run: composer install --no-interaction --prefer-dist
+ - run: composer stan
+
unit-tests:
runs-on: ubuntu-latest
continue-on-error: ${{ matrix.php-version == '8.0' }}
@@ -48,7 +66,12 @@ jobs:
- if: ${{ matrix.php-version != '8.0' }}
run: composer install --no-interaction --prefer-dist
- run: composer test:unit:ci
- # TODO Upload code coverage on one PHP version only
+ - uses: actions/upload-artifact@v2
+ with:
+ name: coverage-unit
+ path: |
+ build/coverage-unit
+ build/coverage-unit.cov
db-tests-sqlite:
runs-on: ubuntu-latest
@@ -71,7 +94,12 @@ jobs:
- if: ${{ matrix.php-version != '8.0' }}
run: composer install --no-interaction --prefer-dist
- run: composer test:db:sqlite:ci
- # TODO Upload code coverage on one PHP version only
+ - uses: actions/upload-artifact@v2
+ with:
+ name: coverage-db
+ path: |
+ build/coverage-db
+ build/coverage-db.cov
db-tests-mysql:
runs-on: ubuntu-latest
@@ -81,7 +109,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v2
- - name: Start database
+ - name: Start database server
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db
- name: Use PHP
uses: shivammathur/setup-php@v2
@@ -104,7 +132,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v2
- - name: Start database
+ - name: Start database server
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_maria
- name: Use PHP
uses: shivammathur/setup-php@v2
@@ -127,7 +155,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v2
- - name: Start database
+ - name: Start database server
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres
- name: Use PHP
uses: shivammathur/setup-php@v2
@@ -152,7 +180,7 @@ jobs:
uses: actions/checkout@v2
- name: Install MSSQL ODBC
run: sudo ./data/infra/ci/install-ms-odbc.sh
- - name: Start database
+ - name: Start database server
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_ms
- name: Use PHP
uses: shivammathur/setup-php@v2
@@ -165,7 +193,8 @@ jobs:
run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
- if: ${{ matrix.php-version != '8.0' }}
run: composer install --no-interaction --prefer-dist
- - run: docker-compose exec -T shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;"
+ - name: Create test database
+ run: docker-compose exec -T shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;"
- run: composer test:db:ms
api-tests:
@@ -177,7 +206,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v2
- - name: Start database
+ - name: Start database server
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db
- name: Use PHP
uses: shivammathur/setup-php@v2
@@ -192,4 +221,9 @@ jobs:
- if: ${{ matrix.php-version != '8.0' }}
run: composer install --no-interaction --prefer-dist
- run: bin/test/run-api-tests.sh
- # TODO Upload code coverage on one PHP version only
+ - uses: actions/upload-artifact@v2
+ with:
+ name: coverage-api
+ path: |
+ build/coverage-api
+ build/coverage-api.cov
From d8a7f3e08c5b161b45e3673654dd0f5f5523e5df Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Sun, 13 Dec 2020 13:11:41 +0100
Subject: [PATCH 034/118] Added mutation-tests step in ci workflow
---
.github/workflows/ci.yml | 35 +++++++++++++++++++++++++++++++++++
composer.json | 3 ++-
2 files changed, 37 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d96e5ce5..652a66d4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -67,6 +67,7 @@ jobs:
run: composer install --no-interaction --prefer-dist
- run: composer test:unit:ci
- uses: actions/upload-artifact@v2
+ if: ${{ matrix.php-version == '7.4' }}
with:
name: coverage-unit
path: |
@@ -95,6 +96,7 @@ jobs:
run: composer install --no-interaction --prefer-dist
- run: composer test:db:sqlite:ci
- uses: actions/upload-artifact@v2
+ if: ${{ matrix.php-version == '7.4' }}
with:
name: coverage-db
path: |
@@ -222,8 +224,41 @@ jobs:
run: composer install --no-interaction --prefer-dist
- run: bin/test/run-api-tests.sh
- uses: actions/upload-artifact@v2
+ if: ${{ matrix.php-version == '7.4' }}
with:
name: coverage-api
path: |
build/coverage-api
build/coverage-api.cov
+
+ mutation-tests:
+ needs:
+ - unit-tests
+ - db-tests-sqlite
+ - api-tests
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ php-version: ['7.4']
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v2
+ - name: Start database server
+ run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db
+ - name: Use PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-version }}
+ tools: composer
+ extensions: swoole-4.5.9
+ coverage: pcov
+ ini-values: pcov.directory=module
+ - if: ${{ matrix.php-version == '8.0' }}
+ run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
+ - if: ${{ matrix.php-version != '8.0' }}
+ run: composer install --no-interaction --prefer-dist
+ - uses: actions/download-artifact@v2
+ with:
+ path: build
+ - run: ls -l build
+ - run: composer infect:ci
diff --git a/composer.json b/composer.json
index b89d3e63..19c4097e 100644
--- a/composer.json
+++ b/composer.json
@@ -115,7 +115,8 @@
],
"test:ci": [
"@test:unit:ci",
- "@test:db"
+ "@test:db",
+ "@test:api"
],
"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",
From 53726bc679f03c50919c92c24a412711408372f3 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Sun, 13 Dec 2020 13:34:22 +0100
Subject: [PATCH 035/118] Added steps to upload code coverage and delete
artifacts to ci workflow
---
.github/workflows/ci.yml | 36 +++++++++++++++++++++++++++++++++++-
1 file changed, 35 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 652a66d4..02308b9d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -260,5 +260,39 @@ jobs:
- uses: actions/download-artifact@v2
with:
path: build
- - run: ls -l build
- run: composer infect:ci
+
+ upload-coverage:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ php-version: ['7.4']
+ steps:
+ - name: Use PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-version }}
+ coverage: none
+ - uses: actions/download-artifact@v2
+ with:
+ path: build
+ - run: mv build/coverage-unit/coverage-unit.cov build/coverage-unit.cov
+ - run: mv build/coverage-db/coverage-db.cov build/coverage-db.cov
+ - run: mv build/coverage-api/coverage-api.cov build/coverage-api.cov
+ - run: wget https://phar.phpunit.de/phpcov-7.0.2.phar
+ - run: php phpcov-7.0.2.phar merge build --clover build/clover.xml
+ - run: wget https://scrutinizer-ci.com/ocular.phar
+ - run: php ocular.phar code-coverage:upload --format=php-clover build/clover.xml
+
+ delete-artifacts:
+ needs:
+ - mutation-tests
+ - upload-coverage
+ runs-on: ubuntu-latest
+ steps:
+ - uses: geekyeggo/delete-artifact@v1
+ with:
+ name: |
+ coverage-unit
+ coverage-db
+ coverage-api
From 1a42ca9239411e51dde6b4df7228921ed277938f Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Sun, 13 Dec 2020 17:17:16 +0100
Subject: [PATCH 036/118] Added missing dependency between upload coverage job
and test jobs
---
.github/workflows/ci.yml | 28 ++++++++++++++++------------
1 file changed, 16 insertions(+), 12 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 02308b9d..f4535352 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -9,7 +9,7 @@ on:
jobs:
lint:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4']
@@ -27,7 +27,7 @@ jobs:
- run: composer cs
static-analysis:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4']
@@ -45,7 +45,7 @@ jobs:
- run: composer stan
unit-tests:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-20.04
continue-on-error: ${{ matrix.php-version == '8.0' }}
strategy:
matrix:
@@ -75,7 +75,7 @@ jobs:
build/coverage-unit.cov
db-tests-sqlite:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
@@ -104,7 +104,7 @@ jobs:
build/coverage-db.cov
db-tests-mysql:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
@@ -127,7 +127,7 @@ jobs:
- run: composer test:db:mysql
db-tests-maria:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
@@ -150,7 +150,7 @@ jobs:
- run: composer test:db:maria
db-tests-postgres:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
@@ -173,7 +173,7 @@ jobs:
- run: composer test:db:postgres
db-tests-ms:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4', '8.0']
@@ -200,7 +200,7 @@ jobs:
- run: composer test:db:ms
api-tests:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-20.04
continue-on-error: ${{ matrix.php-version == '8.0' }}
strategy:
matrix:
@@ -236,7 +236,7 @@ jobs:
- unit-tests
- db-tests-sqlite
- api-tests
- runs-on: ubuntu-latest
+ runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4']
@@ -263,7 +263,11 @@ jobs:
- run: composer infect:ci
upload-coverage:
- runs-on: ubuntu-latest
+ needs:
+ - unit-tests
+ - db-tests-sqlite
+ - api-tests
+ runs-on: ubuntu-20.04
strategy:
matrix:
php-version: ['7.4']
@@ -288,7 +292,7 @@ jobs:
needs:
- mutation-tests
- upload-coverage
- runs-on: ubuntu-latest
+ runs-on: ubuntu-20.04
steps:
- uses: geekyeggo/delete-artifact@v1
with:
From f2210ca0cb85b9a2e740fc11098f21560ef77c9e Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Sun, 13 Dec 2020 17:23:58 +0100
Subject: [PATCH 037/118] Added coverage driver to upload coverage job
---
.github/workflows/ci.yml | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f4535352..b75e3861 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -276,7 +276,8 @@ jobs:
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
- coverage: none
+ coverage: pcov
+ ini-values: pcov.directory=module
- uses: actions/download-artifact@v2
with:
path: build
From a9e9f897999ae532e1e0dfe9af6599676255517b Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Sun, 13 Dec 2020 17:31:22 +0100
Subject: [PATCH 038/118] Ensured code is cloned before using ocular to upload
code coverage to scrutinizer during ci workflow
---
.github/workflows/ci.yml | 2 ++
1 file changed, 2 insertions(+)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b75e3861..22b459b9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -272,6 +272,8 @@ jobs:
matrix:
php-version: ['7.4']
steps:
+ - name: Checkout code
+ uses: actions/checkout@v2
- name: Use PHP
uses: shivammathur/setup-php@v2
with:
From 625c87041721d0a65774cdd1fb89ad93d6683658 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Sun, 13 Dec 2020 17:45:48 +0100
Subject: [PATCH 039/118] Added step to build docker image, and deleted travis
config file
---
.github/workflows/ci.yml | 14 ++++++++++
.travis.yml | 56 ----------------------------------------
README.md | 4 +--
3 files changed, 16 insertions(+), 58 deletions(-)
delete mode 100644 .travis.yml
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 22b459b9..4e5ff5b7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -303,3 +303,17 @@ jobs:
coverage-unit
coverage-db
coverage-api
+
+ build-docker-image:
+ runs-on: ubuntu-20.04
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v2
+ - uses: marceloprado/has-changed-path@v1
+ id: changed-dockerfile
+ with:
+ paths: ./Dockerfile
+ - if: ${{ steps.changed-dockerfile.outputs.changed == 'true' }}
+ run: docker build -t shlink-docker-image:temp .
+ - if: ${{ steps.changed-dockerfile.outputs.changed != 'true' }}
+ run: echo "Dockerfile didn't change. Skipped"
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index f3857e5d..00000000
--- a/.travis.yml
+++ /dev/null
@@ -1,56 +0,0 @@
-dist: bionic
-
-language: php
-
-branches:
- only:
- - /.*/
-
-services:
- - docker
-
-cache:
- directories:
- - $HOME/.composer/cache/files
-
-jobs:
- fast_finish: true
- allow_failures:
- - php: '8.0'
- include:
- - name: 'CI - 8.0'
- php: '8.0'
- env:
- - COMPOSER_FLAGS='--ignore-platform-req=php'
- - name: 'CI - 7.4'
- php: '7.4'
- env:
- - COMPOSER_FLAGS=''
-
-before_install:
- - echo 'extension = apcu.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini
- - phpenv config-rm xdebug.ini || return 0
- - sudo ./data/infra/ci/install-ms-odbc.sh
- - docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_ms shlink_db shlink_db_postgres shlink_db_maria
- - yes | pecl install pdo_sqlsrv-5.9.0beta2 swoole-4.5.9 pcov
-
-install:
- - composer self-update
- - composer install --no-interaction --prefer-dist $COMPOSER_FLAGS
-
-before_script:
- - docker-compose exec shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;"
- - mkdir build
- - export DOCKERFILE_CHANGED=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/main} --name-only | grep Dockerfile)
-
-script:
- - composer ci
- - bin/test/run-api-tests.sh
- - if [[ ! -z "${DOCKERFILE_CHANGED}" && "${TRAVIS_PHP_VERSION}" == "7.4" ]]; then docker build -t shlink-docker-image:temp . ; fi
-
-after_success:
- - rm -f build/clover.xml
- - wget https://phar.phpunit.de/phpcov-7.0.2.phar
- - php phpcov-7.0.2.phar merge build --clover build/clover.xml
- - wget https://scrutinizer-ci.com/ocular.phar
- - php ocular.phar code-coverage:upload --format=php-clover build/clover.xml
diff --git a/README.md b/README.md
index 1f6d2149..255c719e 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,10 @@

-[](https://travis-ci.com/shlinkio/shlink)
+[](https://github.com/shlinkio/shlink/actions?query=workflow%3A%22Continuous+integration%22)
[](https://scrutinizer-ci.com/g/shlinkio/shlink/)
[](https://scrutinizer-ci.com/g/shlinkio/shlink/)
[](https://packagist.org/packages/shlinkio/shlink)
-[](https://hub.docker.com/r/shlinkio/shlink/)
+[](https://hub.docker.com/r/shlinkio/shlink/)
[](https://github.com/shlinkio/shlink/blob/main/LICENSE)
[](https://slnk.to/donate)
From 291393eeebdb23cad7e119c90c0e44199302e6fa Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Sun, 13 Dec 2020 18:07:13 +0100
Subject: [PATCH 040/118] Fixed branch for build badge
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 255c719e..7756c063 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@

-[](https://github.com/shlinkio/shlink/actions?query=workflow%3A%22Continuous+integration%22)
+[](https://github.com/shlinkio/shlink/actions?query=workflow%3A%22Continuous+integration%22)
[](https://scrutinizer-ci.com/g/shlinkio/shlink/)
[](https://scrutinizer-ci.com/g/shlinkio/shlink/)
[](https://packagist.org/packages/shlinkio/shlink)
From 71a83aa3844c82b3e3363a315e99e4455ee3478c Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Sat, 19 Dec 2020 10:04:00 +0100
Subject: [PATCH 041/118] Added PHP 8 on mutation tests
---
.github/workflows/ci.yml | 4 +---
composer.json | 4 ++--
2 files changed, 3 insertions(+), 5 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4e5ff5b7..0442604b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -239,12 +239,10 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
- php-version: ['7.4']
+ php-version: ['7.4', '8.0']
steps:
- name: Checkout code
uses: actions/checkout@v2
- - name: Start database server
- run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db
- name: Use PHP
uses: shivammathur/setup-php@v2
with:
diff --git a/composer.json b/composer.json
index 19c4097e..652ff9b0 100644
--- a/composer.json
+++ b/composer.json
@@ -34,14 +34,14 @@
"laminas/laminas-stdlib": "^3.2",
"lcobucci/jwt": "^4.0",
"league/uri": "^6.2",
- "lstrojny/functional-php": "^1.9",
+ "lstrojny/functional-php": "dev-master#5b78132 as 1.15",
"mezzio/mezzio": "^3.2",
"mezzio/mezzio-fastroute": "^3.0",
"mezzio/mezzio-helpers": "^5.3",
"mezzio/mezzio-problem-details": "^1.1",
"mezzio/mezzio-swoole": "^2.6.4",
"monolog/monolog": "^2.0",
- "nikolaposa/monolog-factory": "^3.0",
+ "nikolaposa/monolog-factory": "^3.1",
"ocramius/proxy-manager": "^2.7.0",
"php-middleware/request-id": "^4.1",
"predis/predis": "^1.1",
From 3a4a2e4483b306e29fdd24e50fc235e50c662792 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Sat, 19 Dec 2020 10:25:19 +0100
Subject: [PATCH 042/118] Replaced scrutinizer with codecov
---
.gitattributes | 1 -
.github/workflows/ci.yml | 10 ++++++----
.scrutinizer.yml | 16 ----------------
README.md | 2 +-
4 files changed, 7 insertions(+), 22 deletions(-)
delete mode 100644 .scrutinizer.yml
diff --git a/.gitattributes b/.gitattributes
index 53b0a935..4d66fe58 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -10,7 +10,6 @@
.gitattributes export-ignore
.gitignore export-ignore
.phpstorm.meta.php export-ignore
-.scrutinizer.yml export-ignore
.travis.yml export-ignore
build.sh export-ignore
CHANGELOG.md export-ignore
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0442604b..c426f4a3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -284,10 +284,12 @@ jobs:
- run: mv build/coverage-unit/coverage-unit.cov build/coverage-unit.cov
- run: mv build/coverage-db/coverage-db.cov build/coverage-db.cov
- run: mv build/coverage-api/coverage-api.cov build/coverage-api.cov
- - run: wget https://phar.phpunit.de/phpcov-7.0.2.phar
- - run: php phpcov-7.0.2.phar merge build --clover build/clover.xml
- - run: wget https://scrutinizer-ci.com/ocular.phar
- - run: php ocular.phar code-coverage:upload --format=php-clover build/clover.xml
+ - run: wget https://phar.phpunit.de/phpcov-8.2.0.phar
+ - run: php phpcov-8.2.0.phar merge build --clover build/clover.xml
+ - name: Publish coverage
+ uses: codecov/codecov-action@v1
+ with:
+ file: ./build/clover.xml
delete-artifacts:
needs:
diff --git a/.scrutinizer.yml b/.scrutinizer.yml
deleted file mode 100644
index ed831706..00000000
--- a/.scrutinizer.yml
+++ /dev/null
@@ -1,16 +0,0 @@
-tools:
- external_code_coverage:
- timeout: 600
-checks:
- php:
- code_rating: true
- duplication: true
-build:
- dependencies:
- override:
- - composer install --no-interaction --no-scripts --ignore-platform-reqs
- nodes:
- analysis:
- tests:
- override:
- - php-scrutinizer-run
diff --git a/README.md b/README.md
index 7756c063..d8263f6b 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@

[](https://github.com/shlinkio/shlink/actions?query=workflow%3A%22Continuous+integration%22)
+[](https://app.codecov.io/gh/shlinkio/shlink)
[](https://scrutinizer-ci.com/g/shlinkio/shlink/)
-[](https://scrutinizer-ci.com/g/shlinkio/shlink/)
[](https://packagist.org/packages/shlinkio/shlink)
[](https://hub.docker.com/r/shlinkio/shlink/)
[](https://github.com/shlinkio/shlink/blob/main/LICENSE)
From 40676f21671480ac0c06b802e71c68d35bffc506 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Sat, 19 Dec 2020 10:37:24 +0100
Subject: [PATCH 043/118] Removed scrutinizer coverage
---
README.md | 1 -
1 file changed, 1 deletion(-)
diff --git a/README.md b/README.md
index d8263f6b..a54c20c8 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,6 @@
[](https://github.com/shlinkio/shlink/actions?query=workflow%3A%22Continuous+integration%22)
[](https://app.codecov.io/gh/shlinkio/shlink)
-[](https://scrutinizer-ci.com/g/shlinkio/shlink/)
[](https://packagist.org/packages/shlinkio/shlink)
[](https://hub.docker.com/r/shlinkio/shlink/)
[](https://github.com/shlinkio/shlink/blob/main/LICENSE)
From 22124aced71d9ebe6f4fee48767825d6f2ac1396 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Tue, 22 Dec 2020 09:30:35 +0100
Subject: [PATCH 044/118] Updated more dependencies for PHP 8 compatibility
---
composer.json | 18 +++++++++---------
module/Core/src/Model/ShortUrlsOrdering.php | 2 +-
2 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/composer.json b/composer.json
index 652ff9b0..b4ed33a2 100644
--- a/composer.json
+++ b/composer.json
@@ -34,9 +34,9 @@
"laminas/laminas-stdlib": "^3.2",
"lcobucci/jwt": "^4.0",
"league/uri": "^6.2",
- "lstrojny/functional-php": "dev-master#5b78132 as 1.15",
+ "lstrojny/functional-php": "^1.15",
"mezzio/mezzio": "^3.2",
- "mezzio/mezzio-fastroute": "^3.0",
+ "mezzio/mezzio-fastroute": "^3.1",
"mezzio/mezzio-helpers": "^5.3",
"mezzio/mezzio-problem-details": "^1.1",
"mezzio/mezzio-swoole": "^2.6.4",
@@ -61,18 +61,18 @@
"symfony/string": "^5.1"
},
"require-dev": {
- "devster/ubench": "^2.0",
- "dms/phpunit-arraysubset-asserts": "^0.2.0",
+ "devster/ubench": "^2.1",
+ "dms/phpunit-arraysubset-asserts": "^0.2.1",
"eaglewu/swoole-ide-helper": "dev-master",
- "infection/infection": "^0.20.0",
+ "infection/infection": "^0.20.2",
"phpspec/prophecy-phpunit": "^2.0",
- "phpstan/phpstan": "^0.12.52",
+ "phpstan/phpstan": "^0.12.64",
"phpunit/php-code-coverage": "^9.2",
- "phpunit/phpunit": "^9.4",
+ "phpunit/phpunit": "^9.5",
"roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.1.1",
- "shlinkio/shlink-test-utils": "^1.5",
- "symfony/var-dumper": "^5.1"
+ "shlinkio/shlink-test-utils": "^1.6",
+ "symfony/var-dumper": "^5.2"
},
"autoload": {
"psr-4": {
diff --git a/module/Core/src/Model/ShortUrlsOrdering.php b/module/Core/src/Model/ShortUrlsOrdering.php
index 25c7c940..e1708a86 100644
--- a/module/Core/src/Model/ShortUrlsOrdering.php
+++ b/module/Core/src/Model/ShortUrlsOrdering.php
@@ -35,7 +35,6 @@ final class ShortUrlsOrdering
*/
private function validateAndInit(array $data): void
{
- /** @var string|array|null $orderBy */
$orderBy = $data[self::ORDER_BY] ?? null;
if ($orderBy === null) {
return;
@@ -49,6 +48,7 @@ final class ShortUrlsOrdering
]);
}
+ /** @var string|array $orderBy */
if (! $isArray) {
$parts = explode('-', $orderBy);
$this->orderField = $parts[0];
From f42e2d87b320477bf1fc9f89209c7ecf4c312454 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Tue, 22 Dec 2020 16:12:39 +0100
Subject: [PATCH 045/118] Small update in docker docs
---
docker/README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docker/README.md b/docker/README.md
index c7627d7f..3a940f4e 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -53,7 +53,7 @@ docker exec -it shlink_container shlink
## Use an external DB
-The image comes with a working sqlite database, but in production you will probably want to usa a distributed database.
+The image comes with a working sqlite database, but in production, it's strongly recommended using a distributed database.
It is possible to use a set of env vars to make this shlink instance interact with an external MySQL, MariaDB, PostgreSQL or Microsoft SQL Server database.
From 202a7327d3db669f3a1993ced7683e1990bc7029 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Thu, 24 Dec 2020 10:37:07 +0100
Subject: [PATCH 046/118] Updated more deps to increase PHP 8 compatibility
---
composer.json | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/composer.json b/composer.json
index b4ed33a2..b3de8371 100644
--- a/composer.json
+++ b/composer.json
@@ -20,9 +20,10 @@
"cocur/slugify": "^4.0",
"doctrine/cache": "^1.9",
"doctrine/dbal": "^2.10",
- "doctrine/migrations": "^3.0.1",
+ "doctrine/migrations": "^3.0.2",
"doctrine/orm": "^2.8",
"endroid/qr-code": "^3.6",
+ "friendsofphp/proxy-manager-lts": "^1.0",
"geoip2/geoip2": "^2.9",
"guzzlehttp/guzzle": "^7.0",
"laminas/laminas-config": "^3.3",
@@ -42,7 +43,6 @@
"mezzio/mezzio-swoole": "^2.6.4",
"monolog/monolog": "^2.0",
"nikolaposa/monolog-factory": "^3.1",
- "ocramius/proxy-manager": "^2.7.0",
"php-middleware/request-id": "^4.1",
"predis/predis": "^1.1",
"pugx/shortid-php": "^0.7",
From 84331135f784f731a58fd49bf5c0fed68ab35418 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Thu, 31 Dec 2020 13:28:06 +0100
Subject: [PATCH 047/118] Created API tests for CORS
---
composer.json | 2 +-
config/autoload/cors.global.php | 11 +++
module/Rest/config/dependencies.config.php | 3 +-
.../src/Middleware/CrossDomainMiddleware.php | 9 +-
module/Rest/test-api/Middleware/CorsTest.php | 83 +++++++++++++++++++
.../Middleware/CrossDomainMiddlewareTest.php | 2 +-
6 files changed, 106 insertions(+), 4 deletions(-)
create mode 100644 config/autoload/cors.global.php
create mode 100644 module/Rest/test-api/Middleware/CorsTest.php
diff --git a/composer.json b/composer.json
index b3de8371..e503edcb 100644
--- a/composer.json
+++ b/composer.json
@@ -23,7 +23,6 @@
"doctrine/migrations": "^3.0.2",
"doctrine/orm": "^2.8",
"endroid/qr-code": "^3.6",
- "friendsofphp/proxy-manager-lts": "^1.0",
"geoip2/geoip2": "^2.9",
"guzzlehttp/guzzle": "^7.0",
"laminas/laminas-config": "^3.3",
@@ -43,6 +42,7 @@
"mezzio/mezzio-swoole": "^2.6.4",
"monolog/monolog": "^2.0",
"nikolaposa/monolog-factory": "^3.1",
+ "ocramius/proxy-manager": "^2.11",
"php-middleware/request-id": "^4.1",
"predis/predis": "^1.1",
"pugx/shortid-php": "^0.7",
diff --git a/config/autoload/cors.global.php b/config/autoload/cors.global.php
new file mode 100644
index 00000000..58ad9428
--- /dev/null
+++ b/config/autoload/cors.global.php
@@ -0,0 +1,11 @@
+ [
+ 'max_age' => 3600,
+ ],
+
+];
diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php
index bdd9d3a9..8c1cdb8e 100644
--- a/module/Rest/config/dependencies.config.php
+++ b/module/Rest/config/dependencies.config.php
@@ -41,7 +41,7 @@ return [
ImplicitOptionsMiddleware::class => Middleware\EmptyResponseImplicitOptionsMiddlewareFactory::class,
Middleware\BodyParserMiddleware::class => InvokableFactory::class,
- Middleware\CrossDomainMiddleware::class => InvokableFactory::class,
+ Middleware\CrossDomainMiddleware::class => ConfigAbstractFactory::class,
Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class => InvokableFactory::class,
Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ConfigAbstractFactory::class,
Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => ConfigAbstractFactory::class,
@@ -76,6 +76,7 @@ return [
Action\Tag\UpdateTagAction::class => [TagService::class],
Action\Domain\ListDomainsAction::class => [DomainService::class, 'config.url_shortener.domain.hostname'],
+ Middleware\CrossDomainMiddleware::class => ['config.cors'],
Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ['config.url_shortener.domain.hostname'],
Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => [
'config.url_shortener.default_short_codes_length',
diff --git a/module/Rest/src/Middleware/CrossDomainMiddleware.php b/module/Rest/src/Middleware/CrossDomainMiddleware.php
index 171142a1..88d62904 100644
--- a/module/Rest/src/Middleware/CrossDomainMiddleware.php
+++ b/module/Rest/src/Middleware/CrossDomainMiddleware.php
@@ -17,6 +17,13 @@ use function implode;
class CrossDomainMiddleware implements MiddlewareInterface, RequestMethodInterface
{
+ private array $config;
+
+ public function __construct(array $config)
+ {
+ $this->config = $config;
+ }
+
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$response = $handler->handle($request);
@@ -48,7 +55,7 @@ class CrossDomainMiddleware implements MiddlewareInterface, RequestMethodInterfa
];
$corsHeaders = [
'Access-Control-Allow-Methods' => implode(',', $matchedMethods),
- 'Access-Control-Max-Age' => '1000',
+ 'Access-Control-Max-Age' => $this->config['max_age'],
'Access-Control-Allow-Headers' => $request->getHeaderLine('Access-Control-Request-Headers'),
];
diff --git a/module/Rest/test-api/Middleware/CorsTest.php b/module/Rest/test-api/Middleware/CorsTest.php
new file mode 100644
index 00000000..4e060352
--- /dev/null
+++ b/module/Rest/test-api/Middleware/CorsTest.php
@@ -0,0 +1,83 @@
+callApiWithKey(self::METHOD_GET, '/short-urls');
+
+ self::assertEquals(200, $resp->getStatusCode());
+ self::assertFalse($resp->hasHeader('Access-Control-Allow-Origin'));
+ self::assertFalse($resp->hasHeader('Access-Control-Expose-Headers'));
+ self::assertFalse($resp->hasHeader('Access-Control-Allow-Methods'));
+ self::assertFalse($resp->hasHeader('Access-Control-Max-Age'));
+ self::assertFalse($resp->hasHeader('Access-Control-Allow-Headers'));
+ }
+
+ /**
+ * @test
+ * @dataProvider provideOrigins
+ */
+ public function responseIncludesCorsHeadersIfOriginIsSent(
+ string $origin,
+ string $endpoint,
+ int $expectedStatusCode
+ ): void {
+ $resp = $this->callApiWithKey(self::METHOD_GET, $endpoint, [
+ RequestOptions::HEADERS => ['Origin' => $origin],
+ ]);
+
+ self::assertEquals($expectedStatusCode, $resp->getStatusCode());
+ self::assertEquals($origin, $resp->getHeaderLine('Access-Control-Allow-Origin'));
+ self::assertEquals('X-Api-Key', $resp->getHeaderLine('Access-Control-Expose-Headers'));
+ self::assertFalse($resp->hasHeader('Access-Control-Allow-Methods'));
+ self::assertFalse($resp->hasHeader('Access-Control-Max-Age'));
+ self::assertFalse($resp->hasHeader('Access-Control-Allow-Headers'));
+ }
+
+ public function provideOrigins(): iterable
+ {
+ yield 'foo.com' => ['foo.com', '/short-urls', 200];
+ yield 'bar.io' => ['bar.io', '/foo/bar', 404];
+ yield 'baz.dev' => ['baz.dev', '/short-urls', 200];
+ }
+
+ /**
+ * @test
+ * @dataProvider providePreflightEndpoints
+ */
+ public function preflightRequestsIncludeExtraCorsHeaders(string $endpoint, string $expectedAllowedMethods): void
+ {
+ $allowedHeaders = 'Authorization';
+ $resp = $this->callApiWithKey(self::METHOD_OPTIONS, $endpoint, [
+ RequestOptions::HEADERS => [
+ 'Origin' => 'foo.com',
+ 'Access-Control-Request-Headers' => $allowedHeaders,
+ ],
+ ]);
+
+ self::assertEquals(204, $resp->getStatusCode());
+ self::assertTrue($resp->hasHeader('Access-Control-Allow-Origin'));
+ self::assertTrue($resp->hasHeader('Access-Control-Expose-Headers'));
+ self::assertTrue($resp->hasHeader('Access-Control-Max-Age'));
+ self::assertEquals($expectedAllowedMethods, $resp->getHeaderLine('Access-Control-Allow-Methods'));
+ self::assertEquals($allowedHeaders, $resp->getHeaderLine('Access-Control-Allow-Headers'));
+ }
+
+ public function providePreflightEndpoints(): iterable
+ {
+ yield 'invalid route' => ['/foo/bar', 'GET,POST,PUT,PATCH,DELETE,OPTIONS'];
+ yield 'short URLs routes' => ['/short-urls', 'GET,POST,PUT,PATCH,DELETE,OPTIONS'];
+// yield 'short URLs routes' => ['/short-urls', 'GET,POST']; // TODO This should be the good one
+ yield 'tags routes' => ['/tags', 'GET,POST,PUT,PATCH,DELETE,OPTIONS'];
+// yield 'tags routes' => ['/short-urls', 'GET,POST,PUT,DELETE']; // TODO This should be the good one
+ }
+}
diff --git a/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php b/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php
index 03675fce..72e95a36 100644
--- a/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php
+++ b/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php
@@ -26,7 +26,7 @@ class CrossDomainMiddlewareTest extends TestCase
public function setUp(): void
{
- $this->middleware = new CrossDomainMiddleware();
+ $this->middleware = new CrossDomainMiddleware(['max_age' => 1000]);
$this->handler = $this->prophesize(RequestHandlerInterface::class);
}
From 850a5b412c4240becfdb35abf5d692a23eb31f31 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Thu, 31 Dec 2020 15:41:02 +0100
Subject: [PATCH 048/118] Removed Access-Control-Expose-Headers header from
CrossDomainM;iddleware, as it's actually not correct
---
module/Rest/src/Middleware/CrossDomainMiddleware.php | 7 ++++---
module/Rest/test-api/Middleware/CorsTest.php | 3 ---
module/Rest/test/Middleware/CrossDomainMiddlewareTest.php | 3 ---
3 files changed, 4 insertions(+), 9 deletions(-)
diff --git a/module/Rest/src/Middleware/CrossDomainMiddleware.php b/module/Rest/src/Middleware/CrossDomainMiddleware.php
index 88d62904..b438f7ec 100644
--- a/module/Rest/src/Middleware/CrossDomainMiddleware.php
+++ b/module/Rest/src/Middleware/CrossDomainMiddleware.php
@@ -32,8 +32,7 @@ class CrossDomainMiddleware implements MiddlewareInterface, RequestMethodInterfa
}
// Add Allow-Origin header
- $response = $response->withHeader('Access-Control-Allow-Origin', $request->getHeader('Origin'))
- ->withHeader('Access-Control-Expose-Headers', AuthenticationMiddleware::API_KEY_HEADER);
+ $response = $response->withHeader('Access-Control-Allow-Origin', $request->getHeader('Origin'));
if ($request->getMethod() !== self::METHOD_OPTIONS) {
return $response;
}
@@ -43,6 +42,8 @@ class CrossDomainMiddleware implements MiddlewareInterface, RequestMethodInterfa
private function addOptionsHeaders(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
+ // TODO This won't work. The route has to be matched from the router as this middleware needs to be executed
+ // before trying to match the route
/** @var RouteResult|null $matchedRoute */
$matchedRoute = $request->getAttribute(RouteResult::class);
$matchedMethods = $matchedRoute !== null ? $matchedRoute->getAllowedMethods() : [
@@ -55,8 +56,8 @@ class CrossDomainMiddleware implements MiddlewareInterface, RequestMethodInterfa
];
$corsHeaders = [
'Access-Control-Allow-Methods' => implode(',', $matchedMethods),
- 'Access-Control-Max-Age' => $this->config['max_age'],
'Access-Control-Allow-Headers' => $request->getHeaderLine('Access-Control-Request-Headers'),
+ 'Access-Control-Max-Age' => $this->config['max_age'],
];
// Options requests should always be empty and have a 204 status code
diff --git a/module/Rest/test-api/Middleware/CorsTest.php b/module/Rest/test-api/Middleware/CorsTest.php
index 4e060352..a1ca9901 100644
--- a/module/Rest/test-api/Middleware/CorsTest.php
+++ b/module/Rest/test-api/Middleware/CorsTest.php
@@ -16,7 +16,6 @@ class CorsTest extends ApiTestCase
self::assertEquals(200, $resp->getStatusCode());
self::assertFalse($resp->hasHeader('Access-Control-Allow-Origin'));
- self::assertFalse($resp->hasHeader('Access-Control-Expose-Headers'));
self::assertFalse($resp->hasHeader('Access-Control-Allow-Methods'));
self::assertFalse($resp->hasHeader('Access-Control-Max-Age'));
self::assertFalse($resp->hasHeader('Access-Control-Allow-Headers'));
@@ -37,7 +36,6 @@ class CorsTest extends ApiTestCase
self::assertEquals($expectedStatusCode, $resp->getStatusCode());
self::assertEquals($origin, $resp->getHeaderLine('Access-Control-Allow-Origin'));
- self::assertEquals('X-Api-Key', $resp->getHeaderLine('Access-Control-Expose-Headers'));
self::assertFalse($resp->hasHeader('Access-Control-Allow-Methods'));
self::assertFalse($resp->hasHeader('Access-Control-Max-Age'));
self::assertFalse($resp->hasHeader('Access-Control-Allow-Headers'));
@@ -66,7 +64,6 @@ class CorsTest extends ApiTestCase
self::assertEquals(204, $resp->getStatusCode());
self::assertTrue($resp->hasHeader('Access-Control-Allow-Origin'));
- self::assertTrue($resp->hasHeader('Access-Control-Expose-Headers'));
self::assertTrue($resp->hasHeader('Access-Control-Max-Age'));
self::assertEquals($expectedAllowedMethods, $resp->getHeaderLine('Access-Control-Allow-Methods'));
self::assertEquals($allowedHeaders, $resp->getHeaderLine('Access-Control-Allow-Headers'));
diff --git a/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php b/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php
index 72e95a36..907fb678 100644
--- a/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php
+++ b/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php
@@ -42,7 +42,6 @@ class CrossDomainMiddlewareTest extends TestCase
self::assertSame($originalResponse, $response);
self::assertEquals(404, $response->getStatusCode());
self::assertArrayNotHasKey('Access-Control-Allow-Origin', $headers);
- self::assertArrayNotHasKey('Access-Control-Expose-Headers', $headers);
self::assertArrayNotHasKey('Access-Control-Allow-Methods', $headers);
self::assertArrayNotHasKey('Access-Control-Max-Age', $headers);
self::assertArrayNotHasKey('Access-Control-Allow-Headers', $headers);
@@ -63,7 +62,6 @@ class CrossDomainMiddlewareTest extends TestCase
$headers = $response->getHeaders();
self::assertEquals('local', $response->getHeaderLine('Access-Control-Allow-Origin'));
- self::assertEquals('X-Api-Key', $response->getHeaderLine('Access-Control-Expose-Headers'));
self::assertArrayNotHasKey('Access-Control-Allow-Methods', $headers);
self::assertArrayNotHasKey('Access-Control-Max-Age', $headers);
self::assertArrayNotHasKey('Access-Control-Allow-Headers', $headers);
@@ -85,7 +83,6 @@ class CrossDomainMiddlewareTest extends TestCase
$headers = $response->getHeaders();
self::assertEquals('local', $response->getHeaderLine('Access-Control-Allow-Origin'));
- self::assertEquals('X-Api-Key', $response->getHeaderLine('Access-Control-Expose-Headers'));
self::assertArrayHasKey('Access-Control-Allow-Methods', $headers);
self::assertEquals('1000', $response->getHeaderLine('Access-Control-Max-Age'));
self::assertEquals('foo, bar, baz', $response->getHeaderLine('Access-Control-Allow-Headers'));
From 9e7f2aea0d8101642c51671648634767cae348f7 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Thu, 31 Dec 2020 15:42:00 +0100
Subject: [PATCH 049/118] Updated changelog
---
CHANGELOG.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b4554f60..4ce3a5d6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
* [#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.
### Changed
* [#912](https://github.com/shlinkio/shlink/issues/912) Changed error templates to be plain html files, removing the dependency on `league/plates` package.
From 6eb3dae8c382d5d39d77f803a24f7d8899648965 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Fri, 1 Jan 2021 11:13:51 +0100
Subject: [PATCH 050/118] Added dependency on composer parallel to speed-up dev
commnds
---
composer.json | 13 ++++++++++---
1 file changed, 10 insertions(+), 3 deletions(-)
diff --git a/composer.json b/composer.json
index e503edcb..9c82c6ed 100644
--- a/composer.json
+++ b/composer.json
@@ -72,7 +72,8 @@
"roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.1.1",
"shlinkio/shlink-test-utils": "^1.6",
- "symfony/var-dumper": "^5.2"
+ "symfony/var-dumper": "^5.2",
+ "veewee/composer-run-parallel": "^0.1.0"
},
"autoload": {
"psr-4": {
@@ -105,6 +106,10 @@
"@test:ci",
"@infect:ci"
],
+ "ci:parallel": [
+ "@parallel cs stan test:unit:ci test:db:sqlite:ci test:db:mysql test:db:maria test:db:postgres test:db:ms",
+ "@parallel test:api infect:ci:unit infect:ci:db"
+ ],
"cs": "phpcs",
"cs:fix": "phpcbf",
"stan": "phpstan analyse module/*/src/ module/*/config config docker/config data/migrations --level=6",
@@ -137,9 +142,11 @@
"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:base --coverage=build/coverage-unit",
- "@infect:ci:base --coverage=build/coverage-db --test-framework-options=--configuration=phpunit-db.xml"
+ "@infect:ci:unit",
+ "@infect:ci:db"
],
"infect:test": [
"@test:unit:ci",
From f3b4e94def96b7b90fb84423480b8ed79c6ac8bb Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Fri, 1 Jan 2021 11:19:57 +0100
Subject: [PATCH 051/118] Documented missing composer commands
---
composer.json | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/composer.json b/composer.json
index 9c82c6ed..b2828ef9 100644
--- a/composer.json
+++ b/composer.json
@@ -157,6 +157,7 @@
},
"scripts-descriptions": {
"ci": "Alias for \"cs\", \"stan\", \"test:ci\" and \"infect:ci\">",
+ "ci:parallel": "Same as \"ci\", but parallelizing tasks as much as possible>",
"cs": "Checks coding styles>",
"cs:fix": "Fixes coding styles, when possible>",
"stan": "Inspects code with phpstan>",
@@ -166,14 +167,17 @@
"test:unit:ci": "Runs unit test suites, generating all needed reports and logs for CI envs>",
"test:db": "Runs database test suites on a SQLite, MySQL, MariaDB, PostgreSQL and MsSQL>",
"test:db:sqlite": "Runs database test suites on a SQLite database>",
+ "test:db:sqlite:ci": "Runs database test suites on a SQLite database, generating all needed reports and logs for CI envs>",
"test:db:mysql": "Runs database test suites on a MySQL database>",
"test:db:maria": "Runs database test suites on a MariaDB database>",
"test:db:postgres": "Runs database test suites on a PostgreSQL database>",
+ "test:db:ms": "Runs database test suites on a Miscrosoft SQL Server database>",
"test:api": "Runs API test suites>",
"test:unit:pretty": "Runs unit test suites and generates an HTML code coverage report>",
- "infect": "Checks unit tests quality applying mutation testing>",
- "infect:ci": "Checks unit tests quality applying mutation testing with existing reports and logs>",
- "infect:test": "Checks unit tests quality applying mutation testing>",
+ "infect:ci": "Checks unit and db tests quality applying mutation testing with existing reports and logs>",
+ "infect:ci:unit": "Checks unit tests quality applying mutation testing with existing reports and logs>",
+ "infect:ci:db": "Checks db tests quality applying mutation testing with existing reports and logs>",
+ "infect:test": "Runs unit and db tests, then checks tests quality applying mutation testing>",
"clean:dev": "Deletes artifacts which are gitignored and could affect dev env>"
},
"config": {
From 90551ff3bc75033e10de93dbc0844239f5e700ef Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Sun, 8 Nov 2020 11:28:27 +0100
Subject: [PATCH 052/118] 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 053/118] 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 054/118] 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 055/118] 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 056/118] 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 057/118] 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 058/118] 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 059/118] 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 060/118] 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 061/118] 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 062/118] 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 063/118] 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 064/118] 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 065/118] 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 066/118] 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 067/118] 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 068/118] 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 069/118] 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 070/118] 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 071/118] 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 072/118] 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 073/118] 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 074/118] 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 075/118] 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 076/118] 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 077/118] 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 078/118] 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 079/118] 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 080/118] 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 081/118] 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 082/118] 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 083/118] 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 084/118] 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 085/118] 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 086/118] 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 087/118] 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 088/118] 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 089/118] 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 090/118] 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 091/118] 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 092/118] 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 093/118] 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 094/118] 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 095/118] 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 096/118] 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 097/118] 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.
From c9ff2b383497086dd5dc8d83504227bcdc8d2514 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Sun, 10 Jan 2021 20:05:14 +0100
Subject: [PATCH 098/118] Updated services required to initialize API keys with
roles
---
composer.json | 2 +-
module/Core/src/Domain/DomainService.php | 15 ++++++++++
.../src/Domain/DomainServiceInterface.php | 6 ++++
module/Core/test/Domain/DomainServiceTest.php | 30 +++++++++++++++++++
module/Rest/src/Service/ApiKeyService.php | 9 ++++--
.../src/Service/ApiKeyServiceInterface.php | 3 +-
.../Rest/test/Service/ApiKeyServiceTest.php | 14 ++++++---
7 files changed, 70 insertions(+), 9 deletions(-)
diff --git a/composer.json b/composer.json
index 88919e4a..5fd37265 100644
--- a/composer.json
+++ b/composer.json
@@ -30,7 +30,7 @@
"laminas/laminas-diactoros": "^2.1.3",
"laminas/laminas-inputfilter": "^2.10",
"laminas/laminas-paginator": "^2.8",
- "laminas/laminas-servicemanager": "^3.4",
+ "laminas/laminas-servicemanager": "^3.6",
"laminas/laminas-stdlib": "^3.2",
"lcobucci/jwt": "^4.0",
"league/uri": "^6.2",
diff --git a/module/Core/src/Domain/DomainService.php b/module/Core/src/Domain/DomainService.php
index e80f36b7..5a573799 100644
--- a/module/Core/src/Domain/DomainService.php
+++ b/module/Core/src/Domain/DomainService.php
@@ -45,6 +45,9 @@ class DomainService implements DomainServiceInterface
];
}
+ /**
+ * @throws DomainNotFoundException
+ */
public function getDomain(string $domainId): Domain
{
/** @var Domain|null $domain */
@@ -55,4 +58,16 @@ class DomainService implements DomainServiceInterface
return $domain;
}
+
+ public function getOrCreate(string $authority): Domain
+ {
+ $repo = $this->em->getRepository(Domain::class);
+ /** @var Domain|null $domain */
+ $domain = $repo->findOneBy(['authority' => $authority]) ?? new Domain($authority);
+
+ $this->em->persist($domain);
+ $this->em->flush();
+
+ return $domain;
+ }
}
diff --git a/module/Core/src/Domain/DomainServiceInterface.php b/module/Core/src/Domain/DomainServiceInterface.php
index 0a2ef914..3588fbc6 100644
--- a/module/Core/src/Domain/DomainServiceInterface.php
+++ b/module/Core/src/Domain/DomainServiceInterface.php
@@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Domain;
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
use Shlinkio\Shlink\Core\Entity\Domain;
+use Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
interface DomainServiceInterface
@@ -15,5 +16,10 @@ interface DomainServiceInterface
*/
public function listDomains(?ApiKey $apiKey = null): array;
+ /**
+ * @throws DomainNotFoundException
+ */
public function getDomain(string $domainId): Domain;
+
+ public function getOrCreate(string $authority): Domain;
}
diff --git a/module/Core/test/Domain/DomainServiceTest.php b/module/Core/test/Domain/DomainServiceTest.php
index 7c21014c..6a1ccef8 100644
--- a/module/Core/test/Domain/DomainServiceTest.php
+++ b/module/Core/test/Domain/DomainServiceTest.php
@@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\Core\Domain;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
+use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Domain\DomainService;
@@ -111,4 +112,33 @@ class DomainServiceTest extends TestCase
self::assertSame($domain, $result);
$find->shouldHaveBeenCalledOnce();
}
+
+ /**
+ * @test
+ * @dataProvider provideFoundDomains
+ */
+ public function getOrCreateAlwaysPersistsDomain(?Domain $foundDomain): void
+ {
+ $authority = 'example.com';
+ $repo = $this->prophesize(DomainRepositoryInterface::class);
+ $repo->findOneBy(['authority' => $authority])->willReturn($foundDomain);
+ $getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal());
+ $persist = $this->em->persist($foundDomain !== null ? $foundDomain : Argument::type(Domain::class));
+ $flush = $this->em->flush();
+
+ $result = $this->domainService->getOrCreate($authority);
+
+ if ($foundDomain !== null) {
+ self::assertSame($result, $foundDomain);
+ }
+ $getRepo->shouldHaveBeenCalledOnce();
+ $persist->shouldHaveBeenCalledOnce();
+ $flush->shouldHaveBeenCalledOnce();
+ }
+
+ public function provideFoundDomains(): iterable
+ {
+ yield 'domain not found' => [null];
+ yield 'domain found' => [new Domain('')];
+ }
}
diff --git a/module/Rest/src/Service/ApiKeyService.php b/module/Rest/src/Service/ApiKeyService.php
index 6fb61be9..917cf048 100644
--- a/module/Rest/src/Service/ApiKeyService.php
+++ b/module/Rest/src/Service/ApiKeyService.php
@@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Rest\Service;
use Cake\Chronos\Chronos;
use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
+use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use function sprintf;
@@ -20,9 +21,13 @@ class ApiKeyService implements ApiKeyServiceInterface
$this->em = $em;
}
- public function create(?Chronos $expirationDate = null): ApiKey
+ public function create(?Chronos $expirationDate = null, RoleDefinition ...$roleDefinitions): ApiKey
{
$key = new ApiKey($expirationDate);
+ foreach ($roleDefinitions as $definition) {
+ $key->registerRole($definition);
+ }
+
$this->em->persist($key);
$this->em->flush();
@@ -31,7 +36,6 @@ class ApiKeyService implements ApiKeyServiceInterface
public function check(string $key): ApiKeyCheckResult
{
- /** @var ApiKey|null $apiKey */
$apiKey = $this->getByKey($key);
return new ApiKeyCheckResult($apiKey);
}
@@ -41,7 +45,6 @@ class ApiKeyService implements ApiKeyServiceInterface
*/
public function disable(string $key): ApiKey
{
- /** @var ApiKey|null $apiKey */
$apiKey = $this->getByKey($key);
if ($apiKey === null) {
throw new InvalidArgumentException(sprintf('API key "%s" does not exist and can\'t be disabled', $key));
diff --git a/module/Rest/src/Service/ApiKeyServiceInterface.php b/module/Rest/src/Service/ApiKeyServiceInterface.php
index e8c6d0ea..562f106b 100644
--- a/module/Rest/src/Service/ApiKeyServiceInterface.php
+++ b/module/Rest/src/Service/ApiKeyServiceInterface.php
@@ -6,11 +6,12 @@ namespace Shlinkio\Shlink\Rest\Service;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
+use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
interface ApiKeyServiceInterface
{
- public function create(?Chronos $expirationDate = null): ApiKey;
+ public function create(?Chronos $expirationDate = null, RoleDefinition ...$roleDefinitions): ApiKey;
public function check(string $key): ApiKeyCheckResult;
diff --git a/module/Rest/test/Service/ApiKeyServiceTest.php b/module/Rest/test/Service/ApiKeyServiceTest.php
index 6d228661..3bbdbf64 100644
--- a/module/Rest/test/Service/ApiKeyServiceTest.php
+++ b/module/Rest/test/Service/ApiKeyServiceTest.php
@@ -12,6 +12,7 @@ use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
+use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
@@ -31,21 +32,26 @@ class ApiKeyServiceTest extends TestCase
/**
* @test
* @dataProvider provideCreationDate
+ * @param RoleDefinition[] $roles
*/
- public function apiKeyIsProperlyCreated(?Chronos $date): void
+ public function apiKeyIsProperlyCreated(?Chronos $date, array $roles): void
{
$this->em->flush()->shouldBeCalledOnce();
$this->em->persist(Argument::type(ApiKey::class))->shouldBeCalledOnce();
- $key = $this->service->create($date);
+ $key = $this->service->create($date, ...$roles);
self::assertEquals($date, $key->getExpirationDate());
+ foreach ($roles as $roleDefinition) {
+ self::assertTrue($key->hasRole($roleDefinition->roleName()));
+ }
}
public function provideCreationDate(): iterable
{
- yield 'no expiration date' => [null];
- yield 'expiration date' => [Chronos::parse('2030-01-01')];
+ yield 'no expiration date' => [null, []];
+ yield 'expiration date' => [Chronos::parse('2030-01-01'), []];
+ yield 'roles' => [null, [RoleDefinition::forDomain('123'), RoleDefinition::forAuthoredShortUrls()]];
}
/**
From a639a4eb94191a3fb17a1cfa7588fc7c3c8e2dee Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Sun, 10 Jan 2021 20:14:06 +0100
Subject: [PATCH 099/118] Added role capabilities to api-key:generate command
---
module/CLI/config/dependencies.config.php | 11 +++--
module/CLI/src/ApiKey/RoleResolver.php | 36 ++++++++++++++
.../CLI/src/ApiKey/RoleResolverInterface.php | 19 ++++++++
.../src/Command/Api/GenerateKeyCommand.php | 47 +++++++++++++++++--
.../Command/Api/GenerateKeyCommandTest.php | 8 +++-
5 files changed, 110 insertions(+), 11 deletions(-)
create mode 100644 module/CLI/src/ApiKey/RoleResolver.php
create mode 100644 module/CLI/src/ApiKey/RoleResolverInterface.php
diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php
index 313d0022..3c9d74ce 100644
--- a/module/CLI/config/dependencies.config.php
+++ b/module/CLI/config/dependencies.config.php
@@ -8,7 +8,6 @@ use Doctrine\DBAL\Connection;
use GeoIp2\Database\Reader;
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Laminas\ServiceManager\Factory\InvokableFactory;
-use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory;
use Shlinkio\Shlink\Core\Domain\DomainService;
use Shlinkio\Shlink\Core\Service;
@@ -32,7 +31,8 @@ return [
SymfonyCli\Helper\ProcessHelper::class => ProcessHelperFactory::class,
PhpExecutableFinder::class => InvokableFactory::class,
- GeolocationDbUpdater::class => ConfigAbstractFactory::class,
+ Util\GeolocationDbUpdater::class => ConfigAbstractFactory::class,
+ ApiKey\RoleResolver::class => ConfigAbstractFactory::class,
Command\ShortUrl\GenerateShortUrlCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\ResolveUrlCommand::class => ConfigAbstractFactory::class,
@@ -59,7 +59,8 @@ return [
],
ConfigAbstractFactory::class => [
- GeolocationDbUpdater::class => [DbUpdater::class, Reader::class, LOCAL_LOCK_FACTORY],
+ Util\GeolocationDbUpdater::class => [DbUpdater::class, Reader::class, LOCAL_LOCK_FACTORY],
+ ApiKey\RoleResolver::class => [DomainService::class],
Command\ShortUrl\GenerateShortUrlCommand::class => [
Service\UrlShortener::class,
@@ -75,10 +76,10 @@ return [
Visit\VisitLocator::class,
IpLocationResolverInterface::class,
LockFactory::class,
- GeolocationDbUpdater::class,
+ Util\GeolocationDbUpdater::class,
],
- Command\Api\GenerateKeyCommand::class => [ApiKeyService::class],
+ Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, ApiKey\RoleResolver::class],
Command\Api\DisableKeyCommand::class => [ApiKeyService::class],
Command\Api\ListKeysCommand::class => [ApiKeyService::class],
diff --git a/module/CLI/src/ApiKey/RoleResolver.php b/module/CLI/src/ApiKey/RoleResolver.php
new file mode 100644
index 00000000..d007697a
--- /dev/null
+++ b/module/CLI/src/ApiKey/RoleResolver.php
@@ -0,0 +1,36 @@
+domainService = $domainService;
+ }
+
+ public function determineRoles(InputInterface $input): array
+ {
+ $domainAuthority = $input->getOption('domain-only');
+ $author = $input->getOption('author-only');
+
+ $roleDefinitions = [];
+ if ($author) {
+ $roleDefinitions[] = RoleDefinition::forAuthoredShortUrls();
+ }
+ if ($domainAuthority !== null) {
+ $domain = $this->domainService->getOrCreate($domainAuthority);
+ $roleDefinitions[] = RoleDefinition::forDomain($domain->getId());
+ }
+
+ return $roleDefinitions;
+ }
+}
diff --git a/module/CLI/src/ApiKey/RoleResolverInterface.php b/module/CLI/src/ApiKey/RoleResolverInterface.php
new file mode 100644
index 00000000..98d50483
--- /dev/null
+++ b/module/CLI/src/ApiKey/RoleResolverInterface.php
@@ -0,0 +1,19 @@
+%command.name% generates a new valid API key.
+
+ %command.full_name%
+
+ You can optionally set its expiration date with --expirationDate or -e:
+
+ %command.full_name% --expirationDate 2020-01-01
+
+ You can also set roles to the API key:
+
+ * Can interact with short URLs created with this API key: %command.full_name% --author-only
+ * Can interact with short URLs for one domain only: %command.full_name% --domain-only=example.com
+ * Both: %command.full_name% --author-only --domain-only=example.com
+ HELP;
private ApiKeyServiceInterface $apiKeyService;
+ private RoleResolverInterface $roleResolver;
- public function __construct(ApiKeyServiceInterface $apiKeyService)
+ public function __construct(ApiKeyServiceInterface $apiKeyService, RoleResolverInterface $roleResolver)
{
- $this->apiKeyService = $apiKeyService;
parent::__construct();
+ $this->apiKeyService = $apiKeyService;
+ $this->roleResolver = $roleResolver;
}
protected function configure(): void
@@ -37,15 +56,33 @@ class GenerateKeyCommand extends Command
'e',
InputOption::VALUE_REQUIRED,
'The date in which the API key should expire. Use any valid PHP format.',
- );
+ )
+ ->addOption(
+ RoleResolverInterface::AUTHOR_ONLY_PARAM,
+ 'a',
+ InputOption::VALUE_NONE,
+ sprintf('Adds the "%s" role to the new API key.', Role::AUTHORED_SHORT_URLS),
+ )
+ ->addOption(
+ RoleResolverInterface::DOMAIN_ONLY_PARAM,
+ 'd',
+ InputOption::VALUE_REQUIRED,
+ sprintf('Adds the "%s" role to the new API key, with the domain provided.', Role::DOMAIN_SPECIFIC),
+ )
+ ->setHelp(self::HELP);
}
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$expirationDate = $input->getOption('expirationDate');
- $apiKey = $this->apiKeyService->create(isset($expirationDate) ? Chronos::parse($expirationDate) : null);
+ $apiKey = $this->apiKeyService->create(
+ isset($expirationDate) ? Chronos::parse($expirationDate) : null,
+ ...$this->roleResolver->determineRoles($input),
+ );
+
+ // TODO Print permissions that have been set
+ (new SymfonyStyle($input, $output))->success(sprintf('Generated API key: "%s"', $apiKey->toString()));
- (new SymfonyStyle($input, $output))->success(sprintf('Generated API key: "%s"', $apiKey));
return ExitCodes::EXIT_SUCCESS;
}
}
diff --git a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php
index 7ff87a3f..744fb482 100644
--- a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php
+++ b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php
@@ -9,10 +9,12 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
+use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface;
use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Application;
+use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Tester\CommandTester;
class GenerateKeyCommandTest extends TestCase
@@ -21,11 +23,15 @@ class GenerateKeyCommandTest extends TestCase
private CommandTester $commandTester;
private ObjectProphecy $apiKeyService;
+ private ObjectProphecy $roleResolver;
public function setUp(): void
{
$this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class);
- $command = new GenerateKeyCommand($this->apiKeyService->reveal());
+ $this->roleResolver = $this->prophesize(RoleResolverInterface::class);
+ $this->roleResolver->determineRoles(Argument::type(InputInterface::class))->willReturn([]);
+
+ $command = new GenerateKeyCommand($this->apiKeyService->reveal(), $this->roleResolver->reveal());
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
From 7a19b8765d2cb335e5771acc3e14c365a7a6394b Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Sun, 10 Jan 2021 20:24:13 +0100
Subject: [PATCH 100/118] Created RoleResolverTest
---
module/CLI/test/ApiKey/RoleResolverTest.php | 81 +++++++++++++++++++++
1 file changed, 81 insertions(+)
create mode 100644 module/CLI/test/ApiKey/RoleResolverTest.php
diff --git a/module/CLI/test/ApiKey/RoleResolverTest.php b/module/CLI/test/ApiKey/RoleResolverTest.php
new file mode 100644
index 00000000..4e498761
--- /dev/null
+++ b/module/CLI/test/ApiKey/RoleResolverTest.php
@@ -0,0 +1,81 @@
+domainService = $this->prophesize(DomainServiceInterface::class);
+ $this->resolver = new RoleResolver($this->domainService->reveal());
+ }
+
+ /**
+ * @test
+ * @dataProvider provideRoles
+ */
+ public function properRolesAreResolvedBasedOnInput(
+ InputInterface $input,
+ array $expectedRoles,
+ int $expectedDomainCalls
+ ): void {
+ $getDomain = $this->domainService->getOrCreate('example.com')->willReturn(
+ (new Domain('example.com'))->setId('1'),
+ );
+
+ $result = $this->resolver->determineRoles($input);
+
+ self::assertEquals($expectedRoles, $result);
+ $getDomain->shouldHaveBeenCalledTimes($expectedDomainCalls);
+ }
+
+ public function provideRoles(): iterable
+ {
+ $buildInput = function (array $definition): InputInterface {
+ $input = $this->prophesize(InputInterface::class);
+
+ foreach ($definition as $name => $value) {
+ $input->getOption($name)->willReturn($value);
+ }
+
+ return $input->reveal();
+ };
+
+ yield 'no roles' => [
+ $buildInput([RoleResolver::DOMAIN_ONLY_PARAM => null, RoleResolver::AUTHOR_ONLY_PARAM => false]),
+ [],
+ 0,
+ ];
+ yield 'domain role only' => [
+ $buildInput([RoleResolver::DOMAIN_ONLY_PARAM => 'example.com', RoleResolver::AUTHOR_ONLY_PARAM => false]),
+ [RoleDefinition::forDomain('1')],
+ 1,
+ ];
+ yield 'author role only' => [
+ $buildInput([RoleResolver::DOMAIN_ONLY_PARAM => null, RoleResolver::AUTHOR_ONLY_PARAM => true]),
+ [RoleDefinition::forAuthoredShortUrls()],
+ 0,
+ ];
+ yield 'both roles' => [
+ $buildInput([RoleResolver::DOMAIN_ONLY_PARAM => 'example.com', RoleResolver::AUTHOR_ONLY_PARAM => true]),
+ [RoleDefinition::forAuthoredShortUrls(), RoleDefinition::forDomain('1')],
+ 1,
+ ];
+ }
+}
From 1f2e16184cc4312396eecabf4412c90841f66ec5 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Sun, 10 Jan 2021 20:28:52 +0100
Subject: [PATCH 101/118] Extracted function to render arrays from inside
ValidationException
---
module/Core/functions/functions.php | 12 ++++++++++++
module/Core/src/Exception/ValidationException.php | 15 ++-------------
.../test/Exception/ValidationExceptionTest.php | 6 +++---
3 files changed, 17 insertions(+), 16 deletions(-)
diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php
index 6ae232be..c766b767 100644
--- a/module/Core/functions/functions.php
+++ b/module/Core/functions/functions.php
@@ -10,6 +10,9 @@ use Fig\Http\Message\StatusCodeInterface;
use Laminas\InputFilter\InputFilter;
use PUGX\Shortid\Factory as ShortIdFactory;
+use function Functional\reduce_left;
+use function is_array;
+use function print_r;
use function sprintf;
const DEFAULT_DELETE_SHORT_URL_THRESHOLD = 15;
@@ -75,3 +78,12 @@ function getOptionalBoolFromInputFilter(InputFilter $inputFilter, string $fieldN
$value = $inputFilter->getValue($fieldName);
return $value !== null ? (bool) $value : null;
}
+
+function arrayToString(array $array): string
+{
+ return reduce_left($array, fn ($messages, string $name, $_, string $acc) => $acc . sprintf(
+ "\n '%s' => %s",
+ $name,
+ is_array($messages) ? print_r($messages, true) : $messages,
+ ), '');
+}
diff --git a/module/Core/src/Exception/ValidationException.php b/module/Core/src/Exception/ValidationException.php
index fc090279..195cfa42 100644
--- a/module/Core/src/Exception/ValidationException.php
+++ b/module/Core/src/Exception/ValidationException.php
@@ -11,9 +11,7 @@ use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
use Throwable;
use function array_keys;
-use function Functional\reduce_left;
-use function is_array;
-use function print_r;
+use function Shlinkio\Shlink\Core\arrayToString;
use function sprintf;
use const PHP_EOL;
@@ -60,19 +58,10 @@ class ValidationException extends InvalidArgumentException implements ProblemDet
$this->getMessage(),
$this->getFile(),
$this->getLine(),
- $this->invalidElementsToString(),
+ arrayToString($this->getInvalidElements()),
PHP_EOL,
PHP_EOL,
$this->getTraceAsString(),
);
}
-
- private function invalidElementsToString(): string
- {
- return reduce_left($this->getInvalidElements(), fn ($messages, string $name, $_, string $acc) => $acc . sprintf(
- "\n '%s' => %s",
- $name,
- is_array($messages) ? print_r($messages, true) : $messages,
- ), '');
- }
}
diff --git a/module/Core/test/Exception/ValidationExceptionTest.php b/module/Core/test/Exception/ValidationExceptionTest.php
index 44a46c1f..fab70760 100644
--- a/module/Core/test/Exception/ValidationExceptionTest.php
+++ b/module/Core/test/Exception/ValidationExceptionTest.php
@@ -32,9 +32,9 @@ class ValidationExceptionTest extends TestCase
];
$barValue = print_r(['baz', 'foo'], true);
$expectedStringRepresentation = << bar
- 'something' => {$barValue}
-EOT;
+ 'foo' => bar
+ 'something' => {$barValue}
+ EOT;
$inputFilter = $this->prophesize(InputFilterInterface::class);
$getMessages = $inputFilter->getMessages()->willReturn($invalidData);
From c49a0ca0402cc0cd501932ce6d5fbe692efc3ab8 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Mon, 11 Jan 2021 15:20:26 +0100
Subject: [PATCH 102/118] Added list of roles to print after an API is
generated
---
.../src/Command/Api/GenerateKeyCommand.php | 14 +++++++++++-
module/Core/functions/functions.php | 22 ++++++++++++++-----
.../src/Exception/ValidationException.php | 3 ++-
.../Exception/ValidationExceptionTest.php | 4 ++--
module/Rest/src/Entity/ApiKey.php | 5 +++++
5 files changed, 38 insertions(+), 10 deletions(-)
diff --git a/module/CLI/src/Command/Api/GenerateKeyCommand.php b/module/CLI/src/Command/Api/GenerateKeyCommand.php
index 23f0f8fc..37f0aaca 100644
--- a/module/CLI/src/Command/Api/GenerateKeyCommand.php
+++ b/module/CLI/src/Command/Api/GenerateKeyCommand.php
@@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\CLI\Command\Api;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
+use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Rest\ApiKey\Role;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Command\Command;
@@ -15,6 +16,7 @@ use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
+use function Shlinkio\Shlink\Core\arrayToString;
use function sprintf;
class GenerateKeyCommand extends Command
@@ -81,7 +83,17 @@ class GenerateKeyCommand extends Command
);
// TODO Print permissions that have been set
- (new SymfonyStyle($input, $output))->success(sprintf('Generated API key: "%s"', $apiKey->toString()));
+ $io = new SymfonyStyle($input, $output);
+ $io->success(sprintf('Generated API key: "%s"', $apiKey->toString()));
+
+ if (! $apiKey->isAdmin()) {
+ ShlinkTable::fromOutput($io)->render(
+ ['Role name', 'Role metadata'],
+ $apiKey->mapRoles(fn (string $name, array $meta) => [$name, arrayToString($meta, 0)]),
+ null,
+ 'Roles',
+ );
+ }
return ExitCodes::EXIT_SUCCESS;
}
diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php
index c766b767..076de6a0 100644
--- a/module/Core/functions/functions.php
+++ b/module/Core/functions/functions.php
@@ -14,6 +14,7 @@ use function Functional\reduce_left;
use function is_array;
use function print_r;
use function sprintf;
+use function str_repeat;
const DEFAULT_DELETE_SHORT_URL_THRESHOLD = 15;
const DEFAULT_SHORT_CODES_LENGTH = 5;
@@ -79,11 +80,20 @@ function getOptionalBoolFromInputFilter(InputFilter $inputFilter, string $fieldN
return $value !== null ? (bool) $value : null;
}
-function arrayToString(array $array): string
+function arrayToString(array $array, int $indentSize = 4): string
{
- return reduce_left($array, fn ($messages, string $name, $_, string $acc) => $acc . sprintf(
- "\n '%s' => %s",
- $name,
- is_array($messages) ? print_r($messages, true) : $messages,
- ), '');
+ $indent = str_repeat(' ', $indentSize);
+ $index = 0;
+
+ return reduce_left($array, static function ($messages, string $name, $_, string $acc) use (&$index, $indent) {
+ $index++;
+
+ return $acc . sprintf(
+ "%s%s'%s' => %s",
+ $index === 1 ? '' : "\n",
+ $indent,
+ $name,
+ is_array($messages) ? print_r($messages, true) : $messages,
+ );
+ }, '');
}
diff --git a/module/Core/src/Exception/ValidationException.php b/module/Core/src/Exception/ValidationException.php
index 195cfa42..3a211592 100644
--- a/module/Core/src/Exception/ValidationException.php
+++ b/module/Core/src/Exception/ValidationException.php
@@ -53,11 +53,12 @@ class ValidationException extends InvalidArgumentException implements ProblemDet
public function __toString(): string
{
return sprintf(
- '%s %s in %s:%s%s%sStack trace:%s%s',
+ '%s %s in %s:%s%s%s%sStack trace:%s%s',
__CLASS__,
$this->getMessage(),
$this->getFile(),
$this->getLine(),
+ PHP_EOL,
arrayToString($this->getInvalidElements()),
PHP_EOL,
PHP_EOL,
diff --git a/module/Core/test/Exception/ValidationExceptionTest.php b/module/Core/test/Exception/ValidationExceptionTest.php
index fab70760..a0980738 100644
--- a/module/Core/test/Exception/ValidationExceptionTest.php
+++ b/module/Core/test/Exception/ValidationExceptionTest.php
@@ -39,7 +39,7 @@ class ValidationExceptionTest extends TestCase
$inputFilter = $this->prophesize(InputFilterInterface::class);
$getMessages = $inputFilter->getMessages()->willReturn($invalidData);
- $e = ValidationException::fromInputFilter($inputFilter->reveal());
+ $e = ValidationException::fromInputFilter($inputFilter->reveal(), $prev);
self::assertEquals($invalidData, $e->getInvalidElements());
self::assertEquals(['invalidElements' => array_keys($invalidData)], $e->getAdditionalData());
@@ -52,6 +52,6 @@ class ValidationExceptionTest extends TestCase
public function provideExceptions(): iterable
{
- return [[null, new RuntimeException(), new LogicException()]];
+ return [[null], [new RuntimeException()], [new LogicException()]];
}
}
diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php
index 4538829c..62729031 100644
--- a/module/Rest/src/Entity/ApiKey.php
+++ b/module/Rest/src/Entity/ApiKey.php
@@ -115,6 +115,11 @@ class ApiKey extends AbstractEntity
return $role === null ? [] : $role->meta();
}
+ public function mapRoles(callable $fun): array
+ {
+ return $this->roles->map(fn (ApiKeyRole $role) => $fun($role->name(), $role->meta()))->getValues();
+ }
+
public function registerRole(RoleDefinition $roleDefinition): void
{
$roleName = $roleDefinition->roleName();
From 9e9d213f208f5f7c728a92c45b9b50870bcd6777 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Mon, 11 Jan 2021 16:32:59 +0100
Subject: [PATCH 103/118] Added roles info to api key generation and api key
list
---
module/CLI/src/ApiKey/RoleResolver.php | 2 +-
module/CLI/src/Command/Api/ListKeysCommand.php | 18 +++++++++++++-----
module/CLI/test/ApiKey/RoleResolverTest.php | 5 +++--
.../Domain/Repository/DomainRepositoryTest.php | 6 +++---
.../Repository/ShortUrlRepositoryTest.php | 4 ++--
.../test-db/Repository/TagRepositoryTest.php | 2 +-
.../test-db/Repository/VisitRepositoryTest.php | 2 +-
module/Core/test/Domain/DomainServiceTest.php | 2 +-
.../Rest/src/ApiKey/Model/RoleDefinition.php | 8 ++++++--
module/Rest/src/ApiKey/Role.php | 14 ++++++++++++++
.../Rest/test-api/Fixtures/ApiKeyFixture.php | 2 +-
module/Rest/test/Service/ApiKeyServiceTest.php | 6 +++++-
12 files changed, 51 insertions(+), 20 deletions(-)
diff --git a/module/CLI/src/ApiKey/RoleResolver.php b/module/CLI/src/ApiKey/RoleResolver.php
index d007697a..67747983 100644
--- a/module/CLI/src/ApiKey/RoleResolver.php
+++ b/module/CLI/src/ApiKey/RoleResolver.php
@@ -28,7 +28,7 @@ class RoleResolver implements RoleResolverInterface
}
if ($domainAuthority !== null) {
$domain = $this->domainService->getOrCreate($domainAuthority);
- $roleDefinitions[] = RoleDefinition::forDomain($domain->getId());
+ $roleDefinitions[] = RoleDefinition::forDomain($domain);
}
return $roleDefinitions;
diff --git a/module/CLI/src/Command/Api/ListKeysCommand.php b/module/CLI/src/Command/Api/ListKeysCommand.php
index f54ad8dd..17054f95 100644
--- a/module/CLI/src/Command/Api/ListKeysCommand.php
+++ b/module/CLI/src/Command/Api/ListKeysCommand.php
@@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\Api;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
+use Shlinkio\Shlink\Rest\ApiKey\Role;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Command\Command;
@@ -14,7 +15,8 @@ use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use function array_filter;
-use function array_map;
+use function Functional\map;
+use function implode;
use function sprintf;
class ListKeysCommand extends Command
@@ -50,7 +52,7 @@ class ListKeysCommand extends Command
{
$enabledOnly = $input->getOption('enabledOnly');
- $rows = array_map(function (ApiKey $apiKey) use ($enabledOnly) {
+ $rows = map($this->apiKeyService->listKeys($enabledOnly), function (ApiKey $apiKey) use ($enabledOnly) {
$expiration = $apiKey->getExpirationDate();
$messagePattern = $this->determineMessagePattern($apiKey);
@@ -60,13 +62,21 @@ class ListKeysCommand extends Command
$rowData[] = sprintf($messagePattern, $this->getEnabledSymbol($apiKey));
}
$rowData[] = $expiration !== null ? $expiration->toAtomString() : '-';
+ $rowData[] = $apiKey->isAdmin() ? '-' : implode("\n", $apiKey->mapRoles(
+ fn (string $roleName, array $meta) =>
+ empty($meta)
+ ? Role::toFriendlyName($roleName)
+ : sprintf('%s: %s', Role::toFriendlyName($roleName), Role::domainAuthorityFromMeta($meta)),
+ ));
+
return $rowData;
- }, $this->apiKeyService->listKeys($enabledOnly));
+ });
ShlinkTable::fromOutput($output)->render(array_filter([
'Key',
! $enabledOnly ? 'Is enabled' : null,
'Expiration date',
+ 'Roles',
]), $rows);
return ExitCodes::EXIT_SUCCESS;
}
@@ -80,8 +90,6 @@ class ListKeysCommand extends Command
return $apiKey->isExpired() ? self::WARNING_STRING_PATTERN : self::SUCCESS_STRING_PATTERN;
}
- /**
- */
private function getEnabledSymbol(ApiKey $apiKey): string
{
return ! $apiKey->isEnabled() || $apiKey->isExpired() ? '---' : '+++';
diff --git a/module/CLI/test/ApiKey/RoleResolverTest.php b/module/CLI/test/ApiKey/RoleResolverTest.php
index 4e498761..a50c2b12 100644
--- a/module/CLI/test/ApiKey/RoleResolverTest.php
+++ b/module/CLI/test/ApiKey/RoleResolverTest.php
@@ -47,6 +47,7 @@ class RoleResolverTest extends TestCase
public function provideRoles(): iterable
{
+ $domain = (new Domain('example.com'))->setId('1');
$buildInput = function (array $definition): InputInterface {
$input = $this->prophesize(InputInterface::class);
@@ -64,7 +65,7 @@ class RoleResolverTest extends TestCase
];
yield 'domain role only' => [
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => 'example.com', RoleResolver::AUTHOR_ONLY_PARAM => false]),
- [RoleDefinition::forDomain('1')],
+ [RoleDefinition::forDomain($domain)],
1,
];
yield 'author role only' => [
@@ -74,7 +75,7 @@ class RoleResolverTest extends TestCase
];
yield 'both roles' => [
$buildInput([RoleResolver::DOMAIN_ONLY_PARAM => 'example.com', RoleResolver::AUTHOR_ONLY_PARAM => true]),
- [RoleDefinition::forAuthoredShortUrls(), RoleDefinition::forDomain('1')],
+ [RoleDefinition::forAuthoredShortUrls(), RoleDefinition::forDomain($domain)],
1,
];
}
diff --git a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php
index 74d5297e..b39f3a87 100644
--- a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php
+++ b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php
@@ -72,12 +72,12 @@ class DomainRepositoryTest extends DatabaseTestCase
$this->getEntityManager()->flush();
- $authorAndDomainApiKey->registerRole(RoleDefinition::forDomain($fooDomain->getId()));
+ $authorAndDomainApiKey->registerRole(RoleDefinition::forDomain($fooDomain));
- $fooDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($fooDomain->getId()));
+ $fooDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($fooDomain));
$this->getEntityManager()->persist($fooDomainApiKey);
- $barDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($barDomain->getId()));
+ $barDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($barDomain));
$this->getEntityManager()->persist($fooDomainApiKey);
$this->getEntityManager()->flush();
diff --git a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php
index a95308ff..c806a243 100644
--- a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php
+++ b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php
@@ -335,9 +335,9 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$this->getEntityManager()->persist($apiKey);
$otherApiKey = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls());
$this->getEntityManager()->persist($otherApiKey);
- $wrongDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($wrongDomain->getId()));
+ $wrongDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($wrongDomain));
$this->getEntityManager()->persist($wrongDomainApiKey);
- $rightDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($rightDomain->getId()));
+ $rightDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($rightDomain));
$this->getEntityManager()->persist($rightDomainApiKey);
$shortUrl = new ShortUrl('foo', ShortUrlMeta::fromRawData(
diff --git a/module/Core/test-db/Repository/TagRepositoryTest.php b/module/Core/test-db/Repository/TagRepositoryTest.php
index 8f9894cd..fce6e61d 100644
--- a/module/Core/test-db/Repository/TagRepositoryTest.php
+++ b/module/Core/test-db/Repository/TagRepositoryTest.php
@@ -114,7 +114,7 @@ class TagRepositoryTest extends DatabaseTestCase
$authorApiKey = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls());
$this->getEntityManager()->persist($authorApiKey);
- $domainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($domain->getId()));
+ $domainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($domain));
$this->getEntityManager()->persist($domainApiKey);
$names = ['foo', 'bar', 'baz', 'another'];
diff --git a/module/Core/test-db/Repository/VisitRepositoryTest.php b/module/Core/test-db/Repository/VisitRepositoryTest.php
index 516b1dd3..b1c6e4bb 100644
--- a/module/Core/test-db/Repository/VisitRepositoryTest.php
+++ b/module/Core/test-db/Repository/VisitRepositoryTest.php
@@ -221,7 +221,7 @@ class VisitRepositoryTest extends DatabaseTestCase
$this->getEntityManager()->persist($shortUrl3);
$this->createVisitsForShortUrl($shortUrl3, 7);
- $domainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($domain->getId()));
+ $domainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($domain));
$this->getEntityManager()->persist($domainApiKey);
$this->getEntityManager()->flush();
diff --git a/module/Core/test/Domain/DomainServiceTest.php b/module/Core/test/Domain/DomainServiceTest.php
index 6a1ccef8..46e39c5a 100644
--- a/module/Core/test/Domain/DomainServiceTest.php
+++ b/module/Core/test/Domain/DomainServiceTest.php
@@ -51,7 +51,7 @@ class DomainServiceTest extends TestCase
{
$default = new DomainItem('default.com', true);
$adminApiKey = new ApiKey();
- $domainSpecificApiKey = ApiKey::withRoles(RoleDefinition::forDomain('123'));
+ $domainSpecificApiKey = ApiKey::withRoles(RoleDefinition::forDomain((new Domain(''))->setId('123')));
yield 'empty list without API key' => [[], [$default], null];
yield 'one item without API key' => [
diff --git a/module/Rest/src/ApiKey/Model/RoleDefinition.php b/module/Rest/src/ApiKey/Model/RoleDefinition.php
index bb9165e8..569044dc 100644
--- a/module/Rest/src/ApiKey/Model/RoleDefinition.php
+++ b/module/Rest/src/ApiKey/Model/RoleDefinition.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\ApiKey\Model;
+use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Rest\ApiKey\Role;
final class RoleDefinition
@@ -22,9 +23,12 @@ final class RoleDefinition
return new self(Role::AUTHORED_SHORT_URLS, []);
}
- public static function forDomain(string $domainId): self
+ public static function forDomain(Domain $domain): self
{
- return new self(Role::DOMAIN_SPECIFIC, ['domain_id' => $domainId]);
+ return new self(
+ Role::DOMAIN_SPECIFIC,
+ ['domain_id' => $domain->getId(), 'authority' => $domain->getAuthority()],
+ );
}
public function roleName(): string
diff --git a/module/Rest/src/ApiKey/Role.php b/module/Rest/src/ApiKey/Role.php
index 87bad5fc..ff3211ba 100644
--- a/module/Rest/src/ApiKey/Role.php
+++ b/module/Rest/src/ApiKey/Role.php
@@ -16,6 +16,10 @@ class Role
{
public const AUTHORED_SHORT_URLS = 'AUTHORED_SHORT_URLS';
public const DOMAIN_SPECIFIC = 'DOMAIN_SPECIFIC';
+ private const ROLE_FRIENDLY_NAMES = [
+ self::AUTHORED_SHORT_URLS => 'Author only',
+ self::DOMAIN_SPECIFIC => 'Domain only',
+ ];
public static function toSpec(ApiKeyRole $role, bool $inlined): Specification
{
@@ -35,4 +39,14 @@ class Role
{
return $meta['domain_id'] ?? '-1';
}
+
+ public static function domainAuthorityFromMeta(array $meta): string
+ {
+ return $meta['authority'] ?? '';
+ }
+
+ public static function toFriendlyName(string $roleName): string
+ {
+ return self::ROLE_FRIENDLY_NAMES[$roleName] ?? '';
+ }
}
diff --git a/module/Rest/test-api/Fixtures/ApiKeyFixture.php b/module/Rest/test-api/Fixtures/ApiKeyFixture.php
index d0a1f802..c6383968 100644
--- a/module/Rest/test-api/Fixtures/ApiKeyFixture.php
+++ b/module/Rest/test-api/Fixtures/ApiKeyFixture.php
@@ -33,7 +33,7 @@ class ApiKeyFixture extends AbstractFixture implements DependentFixtureInterface
/** @var Domain $exampleDomain */
$exampleDomain = $this->getReference('example_domain');
$domainApiKey = $this->buildApiKey('domain_api_key', true);
- $domainApiKey->registerRole(RoleDefinition::forDomain($exampleDomain->getId()));
+ $domainApiKey->registerRole(RoleDefinition::forDomain($exampleDomain));
$manager->persist($domainApiKey);
$manager->flush();
diff --git a/module/Rest/test/Service/ApiKeyServiceTest.php b/module/Rest/test/Service/ApiKeyServiceTest.php
index 3bbdbf64..6879d492 100644
--- a/module/Rest/test/Service/ApiKeyServiceTest.php
+++ b/module/Rest/test/Service/ApiKeyServiceTest.php
@@ -12,6 +12,7 @@ use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
+use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
@@ -51,7 +52,10 @@ class ApiKeyServiceTest extends TestCase
{
yield 'no expiration date' => [null, []];
yield 'expiration date' => [Chronos::parse('2030-01-01'), []];
- yield 'roles' => [null, [RoleDefinition::forDomain('123'), RoleDefinition::forAuthoredShortUrls()]];
+ yield 'roles' => [null, [
+ RoleDefinition::forDomain((new Domain(''))->setId('123')),
+ RoleDefinition::forAuthoredShortUrls(),
+ ]];
}
/**
From 75dab92225c7fe916a025bde57adafdc99d43b1e Mon Sep 17 00:00:00 2001
From: Alejandro Celaya
Date: Mon, 11 Jan 2021 17:01:01 +0100
Subject: [PATCH 104/118] Improved tests covering ListKeysCommand
---
.../test/Command/Api/ListKeysCommandTest.php | 103 +++++++++++++-----
module/Rest/test/ApiKey/RoleTest.php | 36 +++++-
2 files changed, 109 insertions(+), 30 deletions(-)
diff --git a/module/CLI/test/Command/Api/ListKeysCommandTest.php b/module/CLI/test/Command/Api/ListKeysCommandTest.php
index ccf3b0ee..2912d110 100644
--- a/module/CLI/test/Command/Api/ListKeysCommandTest.php
+++ b/module/CLI/test/Command/Api/ListKeysCommandTest.php
@@ -8,6 +8,8 @@ use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Api\ListKeysCommand;
+use Shlinkio\Shlink\Core\Entity\Domain;
+use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Application;
@@ -29,42 +31,87 @@ class ListKeysCommandTest extends TestCase
$this->commandTester = new CommandTester($command);
}
- /** @test */
- public function everythingIsListedIfEnabledOnlyIsNotProvided(): void
+ /**
+ * @test
+ * @dataProvider provideKeysAndOutputs
+ */
+ public function returnsExpectedOutput(array $keys, bool $enabledOnly, string $expected): void
{
- $this->apiKeyService->listKeys(false)->willReturn([
- new ApiKey(),
- new ApiKey(),
- new ApiKey(),
- ])->shouldBeCalledOnce();
+ $listKeys = $this->apiKeyService->listKeys($enabledOnly)->willReturn($keys);
- $this->commandTester->execute([]);
+ $this->commandTester->execute(['--enabledOnly' => $enabledOnly]);
$output = $this->commandTester->getDisplay();
- self::assertStringContainsString('Key', $output);
- self::assertStringContainsString('Is enabled', $output);
- self::assertStringContainsString(' +++ ', $output);
- self::assertStringNotContainsString(' --- ', $output);
- self::assertStringContainsString('Expiration date', $output);
+ self::assertEquals($expected, $output);
+ $listKeys->shouldHaveBeenCalledOnce();
}
- /** @test */
- public function onlyEnabledKeysAreListedIfEnabledOnlyIsProvided(): void
+ public function provideKeysAndOutputs(): iterable
{
- $this->apiKeyService->listKeys(true)->willReturn([
- (new ApiKey())->disable(),
- new ApiKey(),
- ])->shouldBeCalledOnce();
+ yield 'all keys' => [
+ [ApiKey::withKey('foo'), ApiKey::withKey('bar'), ApiKey::withKey('baz')],
+ false,
+ <<