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/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.
diff --git a/composer.json b/composer.json
index b2828ef9..88919e4a 100644
--- a/composer.json
+++ b/composer.json
@@ -19,12 +19,12 @@
"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",
"geoip2/geoip2": "^2.9",
"guzzlehttp/guzzle": "^7.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",
@@ -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",
@@ -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"
},
@@ -125,13 +125,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",
@@ -140,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/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' => '',
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/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/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/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": {
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/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..ddcfa1bd 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);
+ $domains = $this->domainService->listDomains();
- 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($domains, fn (DomainItem $domain) => [$domain->toString(), $domain->isDefault() ? 'Yes' : 'No']),
+ );
return ExitCodes::EXIT_SUCCESS;
}
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/Domain/ListDomainsCommandTest.php b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php
index 500fed7f..a0f79448 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->listDomains()->willReturn([
+ new DomainItem('foo.com', true),
+ new DomainItem('bar.com', false),
+ new DomainItem('baz.com', false),
]);
$this->commandTester->execute([]);
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/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..e80f36b7 100644
--- a/module/Core/src/Domain/DomainService.php
+++ b/module/Core/src/Domain/DomainService.php
@@ -5,25 +5,54 @@ 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 Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
+use Shlinkio\Shlink\Rest\ApiKey\Role;
+use Shlinkio\Shlink\Rest\Entity\ApiKey;
+
+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 listDomains(?ApiKey $apiKey = null): array
{
/** @var DomainRepositoryInterface $repo */
$repo = $this->em->getRepository(Domain::class);
- return $repo->findDomainsWithout($excludeDomain);
+ $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),
+ ...$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 3e56c69c..0a2ef914 100644
--- a/module/Core/src/Domain/DomainServiceInterface.php
+++ b/module/Core/src/Domain/DomainServiceInterface.php
@@ -4,12 +4,16 @@ 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
{
/**
- * @return Domain[]
+ * @return DomainItem[]
*/
- public function listDomainsWithout(?string $excludeDomain = null): array;
+ public function listDomains(?ApiKey $apiKey = null): array;
+
+ public function getDomain(string $domainId): Domain;
}
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/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/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/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/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/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/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/Paginator/Adapter/ShortUrlRepositoryAdapter.php b/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php
index 59d48a82..93fd88c7 100644
--- a/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php
+++ b/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php
@@ -4,19 +4,23 @@ 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;
+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 +32,7 @@ class ShortUrlRepositoryAdapter implements AdapterInterface
$this->params->tags(),
$this->params->orderBy(),
$this->params->dateRange(),
+ $this->resolveSpec(),
);
}
@@ -37,6 +42,12 @@ class ShortUrlRepositoryAdapter implements AdapterInterface
$this->params->searchTerm(),
$this->params->tags(),
$this->params->dateRange(),
+ $this->resolveSpec(),
);
}
+
+ private function resolveSpec(): ?Specification
+ {
+ return $this->apiKey !== null ? $this->apiKey->spec() : null;
+ }
}
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/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 fc6ace41..ddfaa189 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,18 +77,23 @@ 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();
}
private function createListQueryBuilder(
- ?string $searchTerm = null,
- array $tags = [],
- ?DateRange $dateRange = null
+ ?string $searchTerm,
+ array $tags,
+ ?DateRange $dateRange,
+ ?Specification $spec
): QueryBuilder {
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->from(ShortUrl::class, 's')
@@ -125,6 +132,8 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
->andWhere($qb->expr()->in('t.name', $tags));
}
+ $this->applySpecification($qb, $spec, 's');
+
return $qb;
}
@@ -160,23 +169,23 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
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();
}
- 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);
+ $qb = $this->createFindOneQueryBuilder($slug, $domain, $spec);
$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')
@@ -187,6 +196,8 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
$this->whereDomainIs($qb, $domain);
+ $this->applySpecification($qb, $spec, 's');
+
return $qb;
}
@@ -223,6 +234,11 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
->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();
diff --git a/module/Core/src/Repository/ShortUrlRepositoryInterface.php b/module/Core/src/Repository/ShortUrlRepositoryInterface.php
index 1d6f38a8..a0131f6f 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,16 +21,22 @@ 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;
- 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;
+ 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/TagRepository.php b/module/Core/src/Repository/TagRepository.php
index 05b2481c..dd15c292 100644
--- a/module/Core/src/Repository/TagRepository.php
+++ b/module/Core/src/Repository/TagRepository.php
@@ -4,13 +4,18 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Repository;
-use Doctrine\ORM\EntityRepository;
+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;
-class TagRepository extends EntityRepository implements TagRepositoryInterface
+class TagRepository extends EntitySpecificationRepository implements TagRepositoryInterface
{
public function deleteByName(array $names): int
{
@@ -28,21 +33,32 @@ 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->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');
+
+ $this->applySpecification($qb, $spec, 't');
+
+ $query = $qb->getQuery();
return map(
$query->getResult(),
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 37179e21..86898ed1 100644
--- a/module/Core/src/Repository/TagRepositoryInterface.php
+++ b/module/Core/src/Repository/TagRepositoryInterface.php
@@ -5,14 +5,19 @@ 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;
+use Shlinkio\Shlink\Rest\Entity\ApiKey;
-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;
+
+ 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 458b8ef2..a1df73a5 100644
--- a/module/Core/src/Repository/VisitRepository.php
+++ b/module/Core/src/Repository/VisitRepository.php
@@ -4,17 +4,21 @@ 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\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;
-class VisitRepository extends EntityRepository implements VisitRepositoryInterface
+class VisitRepository extends EntitySpecificationRepository implements VisitRepositoryInterface
{
/**
* @return iterable|Visit[]
@@ -84,15 +88,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 +110,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
@@ -124,32 +134,36 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa
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;
}
@@ -194,4 +208,11 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa
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 5a540171..526645df 100644
--- a/module/Core/src/Repository/VisitRepositoryInterface.php
+++ b/module/Core/src/Repository/VisitRepositoryInterface.php
@@ -5,10 +5,13 @@ 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;
+use Shlinkio\Shlink\Rest\Entity\ApiKey;
-interface VisitRepositoryInterface extends ObjectRepository
+interface VisitRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
{
public const DEFAULT_BLOCK_SIZE = 10000;
@@ -35,13 +38,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;
/**
@@ -51,8 +56,11 @@ interface VisitRepositoryInterface extends ObjectRepository
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;
+
+ public function countVisits(?ApiKey $apiKey = null): int;
}
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/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/src/Service/ShortUrlService.php b/module/Core/src/Service/ShortUrlService.php
index 9159ef63..06b39f08 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());
@@ -54,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();
@@ -68,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 3c09e7e9..5f6b9b30 100644
--- a/module/Core/src/Service/ShortUrlServiceInterface.php
+++ b/module/Core/src/Service/ShortUrlServiceInterface.php
@@ -11,23 +11,28 @@ 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
* @throws ShortUrlNotFoundException
*/
- public function setTagsByShortCode(ShortUrlIdentifier $identifier, array $tags = []): ShortUrl;
+ public function setTagsByShortCode(ShortUrlIdentifier $identifier, array $tags, ?ApiKey $apiKey = null): ShortUrl;
/**
* @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/src/Service/VisitsTracker.php b/module/Core/src/Service/VisitsTracker.php
index e777af76..fc35499f 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());
@@ -73,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 2c2759c2..ecffae23 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,11 +22,11 @@ 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
* @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/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/ShortUrl/Spec/BelongsToApiKey.php b/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php
new file mode 100644
index 00000000..9e094b90
--- /dev/null
+++ b/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php
@@ -0,0 +1,28 @@
+apiKey = $apiKey;
+ $this->dqlAlias = $dqlAlias ?? 's';
+ parent::__construct($this->dqlAlias);
+ }
+
+ protected function getSpec(): Filter
+ {
+ return Spec::eq('authorApiKey', $this->apiKey, $this->dqlAlias);
+ }
+}
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/BelongsToDomain.php b/module/Core/src/ShortUrl/Spec/BelongsToDomain.php
new file mode 100644
index 00000000..81b4388a
--- /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/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php b/module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php
new file mode 100644
index 00000000..a8ef527e
--- /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/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/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/src/Tag/TagService.php b/module/Core/src/Tag/TagService.php
index 4e0261a5..ae46a312 100644
--- a/module/Core/src/Tag/TagService.php
+++ b/module/Core/src/Tag/TagService.php
@@ -6,13 +6,18 @@ 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\ForbiddenTagOperationException;
use Shlinkio\Shlink\Core\Exception\TagConflictException;
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;
class TagService implements TagServiceInterface
{
@@ -28,28 +33,38 @@ 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;
}
/**
* @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);
}
/**
* @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);
@@ -73,24 +88,29 @@ class TagService implements TagServiceInterface
/**
* @throws TagNotFoundException
* @throws TagConflictException
+ * @throws ForbiddenTagOperationException
*/
- public function renameTag(string $oldName, string $newName): 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);
/** @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 3c8c6e69..34cf1871 100644
--- a/module/Core/src/Tag/TagServiceInterface.php
+++ b/module/Core/src/Tag/TagServiceInterface.php
@@ -6,26 +6,30 @@ 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;
+use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
+use Shlinkio\Shlink\Rest\Entity\ApiKey;
interface TagServiceInterface
{
/**
* @return Tag[]
*/
- public function listTags(): array;
+ public function listTags(?ApiKey $apiKey = null): array;
/**
* @return TagInfo[]
*/
- public function tagsInfo(): array;
+ public function tagsInfo(?ApiKey $apiKey = null): array;
/**
* @param string[] $tagNames
+ * @throws ForbiddenTagOperationException
*/
- public function deleteTags(array $tagNames): void;
+ public function deleteTags(array $tagNames, ?ApiKey $apiKey = null): void;
/**
* @deprecated
@@ -37,6 +41,7 @@ interface TagServiceInterface
/**
* @throws TagNotFoundException
* @throws TagConflictException
+ * @throws ForbiddenTagOperationException
*/
- public function renameTag(string $oldName, string $newName): Tag;
+ public function renameTag(TagRenaming $renaming, ?ApiKey $apiKey = null): Tag;
}
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/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-db/Domain/Repository/DomainRepositoryTest.php b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php
index 79f9caaf..74d5297e 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,35 +29,70 @@ 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);
$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'));
}
- private function createShortUrl(Domain $domain): ShortUrl
+ /** @test */
+ public function findDomainsReturnsJustThoseMatchingProvidedApiKey(): void
+ {
+ $authorApiKey = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls());
+ $this->getEntityManager()->persist($authorApiKey);
+ $authorAndDomainApiKey = ApiKey::withRoles(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 = ApiKey::withRoles(RoleDefinition::forDomain($fooDomain->getId()));
+ $this->getEntityManager()->persist($fooDomainApiKey);
+
+ $barDomainApiKey = ApiKey::withRoles(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/Core/test-db/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php
index 86eb2aa3..a95308ff 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 = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls());
+ $this->getEntityManager()->persist($apiKey);
+ $otherApiKey = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls());
+ $this->getEntityManager()->persist($otherApiKey);
+ $wrongDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($wrongDomain->getId()));
+ $this->getEntityManager()->persist($wrongDomainApiKey);
+ $rightDomainApiKey = ApiKey::withRoles(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,
+ ])),
);
}
diff --git a/module/Core/test-db/Repository/TagRepositoryTest.php b/module/Core/test-db/Repository/TagRepositoryTest.php
index 9f8b9893..8f9894cd 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,59 @@ 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 = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls());
+ $this->getEntityManager()->persist($authorApiKey);
+ $domainApiKey = ApiKey::withRoles(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::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));
+ }
}
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 906088ea..7c21014c 100644
--- a/module/Core/test/Domain/DomainServiceTest.php
+++ b/module/Core/test/Domain/DomainServiceTest.php
@@ -9,8 +9,12 @@ 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;
+use Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
+use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
+use Shlinkio\Shlink\Rest\Entity\ApiKey;
class DomainServiceTest extends TestCase
{
@@ -22,20 +26,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 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($excludedDomain)->willReturn($expectedResult);
+ $findDomains = $repo->findDomainsWithout('default.com', $apiKey)->willReturn($domains);
- $result = $this->domainService->listDomainsWithout($excludedDomain);
+ $result = $this->domainService->listDomains($apiKey);
self::assertEquals($expectedResult, $result);
$getRepo->shouldHaveBeenCalledOnce();
@@ -44,9 +48,67 @@ 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);
+ $adminApiKey = new ApiKey();
+ $domainSpecificApiKey = ApiKey::withRoles(RoleDefinition::forDomain('123'));
+
+ 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,
+ ];
+ }
+
+ /** @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/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/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/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/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/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php b/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php
index b3a47749..a0bc6405 100644
--- a/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php
+++ b/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php
@@ -11,18 +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([]));
}
/** @test */
@@ -31,10 +30,11 @@ class VisitsForTagPaginatorAdapterTest extends TestCase
$count = 3;
$limit = 1;
$offset = 5;
- $findVisits = $this->repo->findVisitsByTag('foo', new DateRange(), $limit, $offset)->willReturn([]);
+ $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);
@@ -44,12 +44,24 @@ class VisitsForTagPaginatorAdapterTest extends TestCase
public function repoIsCalledOnlyOnceForCount(): void
{
$count = 3;
- $countVisits = $this->repo->countVisitsByTag('foo', new DateRange())->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 508a0984..76ccc220 100644
--- a/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php
+++ b/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php
@@ -12,22 +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([]),
- );
}
/** @test */
@@ -36,10 +31,13 @@ class VisitsPaginatorAdapterTest extends TestCase
$count = 3;
$limit = 1;
$offset = 5;
- $findVisits = $this->repo->findVisitsByShortCode('', null, new DateRange(), $limit, $offset)->willReturn([]);
+ $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);
@@ -49,12 +47,24 @@ class VisitsPaginatorAdapterTest extends TestCase
public function repoIsCalledOnlyOnceForCount(): void
{
$count = 3;
- $countVisits = $this->repo->countVisitsByShortCode('', null, new DateRange())->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 3566b285..e7cc0041 100644
--- a/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php
+++ b/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php
@@ -18,12 +18,15 @@ 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 ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait;
use function Functional\map;
use function range;
class ShortUrlResolverTest extends TestCase
{
+ use ApiKeyHelpersTrait;
use ProphecyTrait;
private ShortUrlResolver $urlResolver;
@@ -35,37 +38,43 @@ class ShortUrlResolverTest extends TestCase
$this->urlResolver = new ShortUrlResolver($this->em->reveal());
}
- /** @test */
- public function shortCodeIsProperlyParsed(): void
+ /**
+ * @test
+ * @dataProvider provideAdminApiKeys
+ */
+ public function shortCodeIsProperlyParsed(?ApiKey $apiKey): void
{
$shortUrl = new ShortUrl('expected_url');
$shortCode = $shortUrl->getShortCode();
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
- $findOne = $repo->findOne($shortCode, 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 provideAdminApiKeys
+ */
+ public function exceptionIsThrownIfShortcodeIsNotFound(?ApiKey $apiKey): void
{
$shortCode = 'abc123';
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
- $findOne = $repo->findOne($shortCode, 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);
}
/** @test */
diff --git a/module/Core/test/Service/ShortUrlServiceTest.php b/module/Core/test/Service/ShortUrlServiceTest.php
index fc2de22b..99f26a53 100644
--- a/module/Core/test/Service/ShortUrlServiceTest.php
+++ b/module/Core/test/Service/ShortUrlServiceTest.php
@@ -20,11 +20,14 @@ 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 ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait;
use function count;
class ShortUrlServiceTest extends TestCase
{
+ use ApiKeyHelpersTrait;
use ProphecyTrait;
private ShortUrlService $service;
@@ -48,8 +51,11 @@ class ShortUrlServiceTest extends TestCase
);
}
- /** @test */
- public function listedUrlsAreReturnedFromEntityManager(): void
+ /**
+ * @test
+ * @dataProvider provideAdminApiKeys
+ */
+ public function listedUrlsAreReturnedFromEntityManager(?ApiKey $apiKey): void
{
$list = [
new ShortUrl(''),
@@ -63,25 +69,29 @@ 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 provideAdminApiKeys
+ */
+ public function providedTagsAreGetFromRepoAndSetToTheShortUrl(?ApiKey $apiKey): void
{
$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), $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);
}
/**
@@ -90,15 +100,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 +135,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/Core/test/Service/Tag/TagServiceTest.php b/module/Core/test/Service/Tag/TagServiceTest.php
index 16fd8683..5f518184 100644
--- a/module/Core/test/Service/Tag/TagServiceTest.php
+++ b/module/Core/test/Service/Tag/TagServiceTest.php
@@ -10,14 +10,20 @@ 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;
+use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait;
class TagServiceTest extends TestCase
{
+ use ApiKeyHelpersTrait;
use ProphecyTrait;
private TagService $service;
@@ -28,7 +34,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());
}
@@ -38,37 +44,55 @@ 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 */
- 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()->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();
}
- /** @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'], ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls()));
+ }
+
/** @test */
public function createTagsPersistsEntities(): void
{
@@ -84,15 +108,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('foo', 'bar');
+ $this->service->renameTag(TagRenaming::fromNames('foo', 'bar'), $apiKey);
}
/**
@@ -107,7 +134,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);
@@ -122,8 +149,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);
@@ -134,6 +164,21 @@ class TagServiceTest extends TestCase
$flush->shouldNotBeCalled();
$this->expectException(TagConflictException::class);
- $this->service->renameTag('foo', 'bar');
+ $this->service->renameTag(TagRenaming::fromNames('foo', 'bar'), $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'),
+ ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls()),
+ );
}
}
diff --git a/module/Core/test/Service/VisitsTrackerTest.php b/module/Core/test/Service/VisitsTrackerTest.php
index 1d9096e3..ef894aaf 100644
--- a/module/Core/test/Service/VisitsTrackerTest.php
+++ b/module/Core/test/Service/VisitsTrackerTest.php
@@ -25,12 +25,15 @@ 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 ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait;
use function Functional\map;
use function range;
class VisitsTrackerTest extends TestCase
{
+ use ApiKeyHelpersTrait;
use ProphecyTrait;
private VisitsTracker $visitsTracker;
@@ -42,7 +45,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 */
@@ -58,21 +61,27 @@ class VisitsTrackerTest extends TestCase
$this->eventDispatcher->dispatch(Argument::type(ShortUrlVisited::class))->shouldHaveBeenCalled();
}
- /** @test */
- public function infoReturnsVisitsForCertainShortCode(): void
+ /**
+ * @test
+ * @dataProvider provideAdminApiKeys
+ */
+ public function infoReturnsVisitsForCertainShortCode(?ApiKey $apiKey): void
{
$shortCode = '123ABC';
+ $spec = $apiKey === null ? null : $apiKey->spec();
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
- $count = $repo->shortCodeIsInUse($shortCode, 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)->willReturn($list);
- $repo2->countVisitsByShortCode($shortCode, null, Argument::type(DateRange::class))->willReturn(1);
+ $repo2->findVisitsByShortCode($shortCode, null, Argument::type(DateRange::class), 1, 0, $spec)->willReturn(
+ $list,
+ );
+ $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();
@@ -83,7 +92,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);
@@ -96,35 +105,40 @@ 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 */
- public function visitsForTagAreReturnedAsExpected(): void
+ /**
+ * @test
+ * @dataProvider provideAdminApiKeys
+ */
+ public function visitsForTagAreReturnedAsExpected(?ApiKey $apiKey): void
{
$tag = 'foo';
$repo = $this->prophesize(TagRepository::class);
- $count = $repo->count(['name' => $tag])->willReturn(1);
+ $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)->willReturn($list);
- $repo2->countVisitsByTag($tag, Argument::type(DateRange::class))->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()));
- $count->shouldHaveBeenCalledOnce();
+ $tagExists->shouldHaveBeenCalledOnce();
$getRepo->shouldHaveBeenCalledOnce();
}
}
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/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()];
+ }
+}
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/config/dependencies.config.php b/module/Rest/config/dependencies.config.php
index 8c1cdb8e..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,
],
],
@@ -74,13 +75,14 @@ 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'],
Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => [
'config.url_shortener.default_short_codes_length',
],
+ Middleware\ShortUrl\OverrideDomainMiddleware::class => [DomainService::class],
],
];
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..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
@@ -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,11 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
$builder->createField('enabled', Types::BOOLEAN)
->build();
+
+ $builder->createOneToMany('roles', ApiKeyRole::class)
+ ->mappedBy('apiKey')
+ ->setIndexBy('roleName')
+ ->cascadePersist()
+ ->orphanRemoval()
+ ->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/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/Domain/ListDomainsAction.php b/module/Rest/src/Action/Domain/ListDomainsAction.php
index 7362123a..35ce04f3 100644
--- a/module/Rest/src/Action/Domain/ListDomainsAction.php
+++ b/module/Rest/src/Action/Domain/ListDomainsAction.php
@@ -8,10 +8,8 @@ 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;
+use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
class ListDomainsAction extends AbstractRestAction
{
@@ -19,33 +17,21 @@ class ListDomainsAction extends AbstractRestAction
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);
+ $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
+ $domainItems = $this->domainService->listDomains($apiKey);
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/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/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/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/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/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/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php
index fe8c44aa..e9edee41 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,9 @@ class SingleStepCreateShortUrlAction extends AbstractCreateShortUrlAction
}
return new CreateShortUrlData($longUrl, [], ShortUrlMeta::fromRawData([
- ShortUrlMetaInputFilter::API_KEY => $apiKey,
+ 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/Action/Tag/DeleteTagsAction.php b/module/Rest/src/Action/Tag/DeleteTagsAction.php
index f38c443a..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
{
@@ -22,18 +23,13 @@ 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();
$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/ListTagsAction.php b/module/Rest/src/Action/Tag/ListTagsAction.php
index 0832f17c..48cf923b 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;
@@ -29,16 +30,17 @@ 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),
],
]);
}
- $tagsInfo = $this->tagService->tagsInfo();
+ $tagsInfo = $this->tagService->tagsInfo($apiKey);
$data = map($tagsInfo, fn (TagInfo $info) => (string) $info->tag());
return new JsonResponse([
diff --git a/module/Rest/src/Action/Tag/UpdateTagAction.php b/module/Rest/src/Action/Tag/UpdateTagAction.php
index fbf93f50..d83d8b9a 100644
--- a/module/Rest/src/Action/Tag/UpdateTagAction.php
+++ b/module/Rest/src/Action/Tag/UpdateTagAction.php
@@ -7,9 +7,10 @@ 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;
+use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
class UpdateTagAction extends AbstractRestAction
{
@@ -23,24 +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();
- if (! isset($body['oldName'], $body['newName'])) {
- throw ValidationException::fromArray([
- 'oldName' => 'oldName is required',
- 'newName' => 'newName is required',
- ]);
- }
+ $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
- $this->tagService->renameTag($body['oldName'], $body['newName']);
+ $this->tagService->renameTag(TagRenaming::fromArray($body), $apiKey);
return new EmptyResponse();
}
}
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/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/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/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/ApiKey/Role.php b/module/Rest/src/ApiKey/Role.php
new file mode 100644
index 00000000..87bad5fc
--- /dev/null
+++ b/module/Rest/src/ApiKey/Role.php
@@ -0,0 +1,38 @@
+name() === self::AUTHORED_SHORT_URLS) {
+ return $inlined ? new BelongsToApiKeyInlined($role->apiKey()) : new BelongsToApiKey($role->apiKey());
+ }
+
+ if ($role->name() === self::DOMAIN_SPECIFIC) {
+ $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/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php b/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php
new file mode 100644
index 00000000..64359d15
--- /dev/null
+++ b/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php
@@ -0,0 +1,31 @@
+apiKey = $apiKey;
+ $this->fieldToJoin = $fieldToJoin;
+ }
+
+ protected function getSpec(): Specification
+ {
+ return $this->apiKey === null || $this->apiKey->isAdmin() ? Spec::andX() : Spec::andX(
+ Spec::join($this->fieldToJoin, 's'),
+ $this->apiKey->spec(),
+ );
+ }
+}
diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php
index 1d372c9c..4538829c 100644
--- a/module/Rest/src/Entity/ApiKey.php
+++ b/module/Rest/src/Entity/ApiKey.php
@@ -5,20 +5,52 @@ declare(strict_types=1);
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
{
private string $key;
private ?Chronos $expirationDate = null;
private bool $enabled;
+ /** @var Collection|ApiKeyRole[] */
+ private Collection $roles;
+ /**
+ * @throws Exception
+ */
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) {
+ $apiKey->registerRole($roleDefinition);
+ }
+
+ 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
@@ -54,4 +86,52 @@ class ApiKey extends AbstractEntity
{
return $this->key;
}
+
+ public function toString(): string
+ {
+ return $this->key;
+ }
+
+ public function spec(bool $inlined = false): Specification
+ {
+ $specs = $this->roles->map(fn (ApiKeyRole $role) => Role::toSpec($role, $inlined))->getValues();
+ return Spec::andX(...$specs);
+ }
+
+ public function isAdmin(): bool
+ {
+ return $this->roles->isEmpty();
+ }
+
+ public function hasRole(string $roleName): bool
+ {
+ return $this->roles->containsKey($roleName);
+ }
+
+ public function getRoleMeta(string $roleName): array
+ {
+ /** @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
new file mode 100644
index 00000000..99dbb627
--- /dev/null
+++ b/module/Rest/src/Entity/ApiKeyRole.php
@@ -0,0 +1,41 @@
+roleName = $roleName;
+ $this->meta = $meta;
+ $this->apiKey = $apiKey;
+ }
+
+ public function name(): string
+ {
+ return $this->roleName;
+ }
+
+ public function meta(): array
+ {
+ return $this->meta;
+ }
+
+ public function updateMeta(array $newMeta): void
+ {
+ $this->meta = $newMeta;
+ }
+
+ public function apiKey(): ApiKey
+ {
+ return $this->apiKey;
+ }
+}
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/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/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-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];
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/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/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/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];
}
}
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,
+ ],
+ ]];
+ }
}
diff --git a/module/Rest/test-api/Action/ListShortUrlsTest.php b/module/Rest/test-api/Action/ListShortUrlsTest.php
index 2f1cf484..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,
@@ -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,43 @@ 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,
- ]];
+ self::SHORT_URL_CUSTOM_DOMAIN,
+ ], '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
diff --git a/module/Rest/test-api/Action/ListTagsActionTest.php b/module/Rest/test-api/Action/ListTagsActionTest.php
index 9191b4e0..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' => [
[
@@ -39,6 +39,25 @@ class ListTagsActionTest extends ApiTestCase
'shortUrlsCount' => 0,
'visitsCount' => 0,
],
+ [
+ 'tag' => 'foo',
+ 'shortUrlsCount' => 3,
+ 'visitsCount' => 5,
+ ],
+ ],
+ ]];
+
+ 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,
@@ -46,5 +65,19 @@ class ListTagsActionTest extends ApiTestCase
],
],
]];
+
+ 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,
+ ],
+ ],
+ ]];
}
}
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'];
+ }
+}
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/Action/TagVisitsActionTest.php b/module/Rest/test-api/Action/TagVisitsActionTest.php
index d0f9838b..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,21 +27,34 @@ 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];
}
- /** @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/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..954d2059 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);
@@ -60,6 +72,7 @@ class ShortUrlsFixture extends AbstractFixture
$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();
}
}
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();
}
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;
diff --git a/module/Rest/test/Action/Domain/ListDomainsActionTest.php b/module/Rest/test/Action/Domain/ListDomainsActionTest.php
index 6750d105..d6dcc4a3 100644
--- a/module/Rest/test/Action/Domain/ListDomainsActionTest.php
+++ b/module/Rest/test/Action/Domain/ListDomainsActionTest.php
@@ -10,8 +10,9 @@ 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;
+use Shlinkio\Shlink\Rest\Entity\ApiKey;
class ListDomainsActionTest extends TestCase
{
@@ -23,37 +24,26 @@ 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
{
- $listDomains = $this->domainService->listDomainsWithout('foo.com')->willReturn([
- new Domain('bar.com'),
- new Domain('baz.com'),
- ]);
+ $apiKey = new ApiKey();
+ $domains = [
+ new DomainItem('bar.com', true),
+ new DomainItem('baz.com', false),
+ ];
+ $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([
'domains' => [
- 'data' => [
- [
- 'domain' => 'foo.com',
- 'isDefault' => true,
- ],
- [
- 'domain' => 'bar.com',
- 'isDefault' => false,
- ],
- [
- 'domain' => 'baz.com',
- 'isDefault' => false,
- ],
- ],
+ 'data' => $domains,
],
], $payload);
$listDomains->shouldHaveBeenCalledOnce();
diff --git a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php
index 91e6014c..80ccfc17 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;
+
$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/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();
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,
]);
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());
+ }
}
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);
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);
}
diff --git a/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php
index 62005c8d..b42b95fb 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 {
+ Argument::that(function (string $argument): bool {
Assert::assertEquals('http://foobar.com', $argument);
- return $argument;
+ return true;
}),
[],
- ShortUrlMeta::fromRawData(['apiKey' => 'abc123']),
+ ShortUrlMeta::fromRawData(['apiKey' => $apiKey]),
)->willReturn(new ShortUrl(''));
$resp = $this->action->handle($request);
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/ListTagsActionTest.php b/module/Rest/test/Action/Tag/ListTagsActionTest.php
index 2f675536..9bdad15b 100644
--- a/module/Rest/test/Action/Tag/ListTagsActionTest.php
+++ b/module/Rest/test/Action/Tag/ListTagsActionTest.php
@@ -7,12 +7,15 @@ 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;
use Shlinkio\Shlink\Rest\Action\Tag\ListTagsAction;
+use Shlinkio\Shlink\Rest\Entity\ApiKey;
class ListTagsActionTest extends TestCase
{
@@ -34,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([
@@ -62,10 +65,11 @@ class ListTagsActionTest extends TestCase
new TagInfo(new Tag('foo'), 1, 1),
new TagInfo(new Tag('bar'), 3, 10),
];
- $tagsInfo = $this->tagService->tagsInfo()->willReturn($stats);
+ $tagsInfo = $this->tagService->tagsInfo(Argument::type(ApiKey::class))->willReturn($stats);
+ $req = $this->requestWithApiKey()->withQueryParams(['withStats' => 'true']);
/** @var JsonResponse $resp */
- $resp = $this->action->handle(ServerRequestFactory::fromGlobals()->withQueryParams(['withStats' => 'true']));
+ $resp = $this->action->handle($req);
$payload = $resp->getPayload();
self::assertEquals([
@@ -76,4 +80,9 @@ class ListTagsActionTest extends TestCase
], $payload);
$tagsInfo->shouldHaveBeenCalled();
}
+
+ private function requestWithApiKey(): ServerRequestInterface
+ {
+ return ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, new ApiKey());
+ }
}
diff --git a/module/Rest/test/Action/Tag/UpdateTagActionTest.php b/module/Rest/test/Action/Tag/UpdateTagActionTest.php
index b82c8c2e..681e68f6 100644
--- a/module/Rest/test/Action/Tag/UpdateTagActionTest.php
+++ b/module/Rest/test/Action/Tag/UpdateTagActionTest.php
@@ -4,14 +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
{
@@ -32,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);
@@ -49,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('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());
+ }
}
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]);
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());
+ }
}
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();
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'];
+ }
+}
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/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());
+ }
+}
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 */
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