mirror of
https://github.com/shlinkio/shlink.git
synced 2025-03-14 04:00:57 +03:00
Applied API role specs to short URL creation
This commit is contained in:
parent
19834f6715
commit
4b67d41362
15 changed files with 314 additions and 7 deletions
|
@ -8,6 +8,7 @@ use Doctrine\ORM\EntityManagerInterface;
|
|||
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
|
||||
use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
use Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Role;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
|
@ -43,4 +44,15 @@ class DomainService implements DomainServiceInterface
|
|||
...$mappedDomains,
|
||||
];
|
||||
}
|
||||
|
||||
public function getDomain(string $domainId): Domain
|
||||
{
|
||||
/** @var Domain|null $domain */
|
||||
$domain = $this->em->find(Domain::class, $domainId);
|
||||
if ($domain === null) {
|
||||
throw DomainNotFoundException::fromId($domainId);
|
||||
}
|
||||
|
||||
return $domain;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||
namespace Shlinkio\Shlink\Core\Domain;
|
||||
|
||||
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
interface DomainServiceInterface
|
||||
|
@ -13,4 +14,6 @@ interface DomainServiceInterface
|
|||
* @return DomainItem[]
|
||||
*/
|
||||
public function listDomains(?ApiKey $apiKey = null): array;
|
||||
|
||||
public function getDomain(string $domainId): Domain;
|
||||
}
|
||||
|
|
32
module/Core/src/Exception/DomainNotFoundException.php
Normal file
32
module/Core/src/Exception/DomainNotFoundException.php
Normal file
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Exception;
|
||||
|
||||
use Fig\Http\Message\StatusCodeInterface;
|
||||
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
|
||||
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class DomainNotFoundException extends DomainException implements ProblemDetailsExceptionInterface
|
||||
{
|
||||
use CommonProblemDetailsExceptionTrait;
|
||||
|
||||
private const TITLE = 'Domain not found';
|
||||
private const TYPE = 'DOMAIN_NOT_FOUND';
|
||||
|
||||
public static function fromId(string $id): self
|
||||
{
|
||||
$e = new self(sprintf('Domain with id "%s" could not be found', $id));
|
||||
|
||||
$e->detail = $e->getMessage();
|
||||
$e->title = self::TITLE;
|
||||
$e->type = self::TYPE;
|
||||
$e->status = StatusCodeInterface::STATUS_NOT_FOUND;
|
||||
$e->additional = ['id' => $id];
|
||||
|
||||
return $e;
|
||||
}
|
||||
}
|
|
@ -10,10 +10,10 @@ use Happyr\DoctrineSpecification\Spec;
|
|||
|
||||
class BelongsToDomain extends BaseSpecification
|
||||
{
|
||||
private int $domainId;
|
||||
private string $domainId;
|
||||
private string $dqlAlias;
|
||||
|
||||
public function __construct(int $domainId, ?string $dqlAlias = null)
|
||||
public function __construct(string $domainId, ?string $dqlAlias = null)
|
||||
{
|
||||
$this->domainId = $domainId;
|
||||
$this->dqlAlias = $dqlAlias ?? 's';
|
||||
|
|
|
@ -9,9 +9,9 @@ use Happyr\DoctrineSpecification\Specification\Specification;
|
|||
|
||||
class BelongsToDomainInlined implements Specification
|
||||
{
|
||||
private int $domainId;
|
||||
private string $domainId;
|
||||
|
||||
public function __construct(int $domainId)
|
||||
public function __construct(string $domainId)
|
||||
{
|
||||
$this->domainId = $domainId;
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ use Shlinkio\Shlink\Core\Domain\DomainService;
|
|||
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
|
||||
use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
use Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
|
||||
|
||||
class DomainServiceTest extends TestCase
|
||||
{
|
||||
|
@ -54,4 +55,27 @@ class DomainServiceTest extends TestCase
|
|||
[$default, new DomainItem('foo.com', false), new DomainItem('bar.com', false)],
|
||||
];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function getDomainThrowsExceptionWhenDomainIsNotFound(): void
|
||||
{
|
||||
$find = $this->em->find(Domain::class, '123')->willReturn(null);
|
||||
|
||||
$this->expectException(DomainNotFoundException::class);
|
||||
$find->shouldBeCalledOnce();
|
||||
|
||||
$this->domainService->getDomain('123');
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function getDomainReturnsEntityWhenFound(): void
|
||||
{
|
||||
$domain = new Domain('');
|
||||
$find = $this->em->find(Domain::class, '123')->willReturn($domain);
|
||||
|
||||
$result = $this->domainService->getDomain('123');
|
||||
|
||||
self::assertSame($domain, $result);
|
||||
$find->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
||||
|
|
28
module/Core/test/Exception/DomainNotFoundExceptionTest.php
Normal file
28
module/Core/test/Exception/DomainNotFoundExceptionTest.php
Normal file
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Exception;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class DomainNotFoundExceptionTest extends TestCase
|
||||
{
|
||||
/** @test */
|
||||
public function properlyCreatesExceptionFromNotFoundTag(): void
|
||||
{
|
||||
$id = '123';
|
||||
$expectedMessage = sprintf('Domain with id "%s" could not be found', $id);
|
||||
$e = DomainNotFoundException::fromId($id);
|
||||
|
||||
self::assertEquals($expectedMessage, $e->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());
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -45,6 +45,7 @@ return [
|
|||
Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class => InvokableFactory::class,
|
||||
Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ConfigAbstractFactory::class,
|
||||
Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => ConfigAbstractFactory::class,
|
||||
Middleware\ShortUrl\OverrideDomainMiddleware::class => ConfigAbstractFactory::class,
|
||||
],
|
||||
],
|
||||
|
||||
|
@ -81,6 +82,7 @@ return [
|
|||
Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => [
|
||||
'config.url_shortener.default_short_codes_length',
|
||||
],
|
||||
Middleware\ShortUrl\OverrideDomainMiddleware::class => [DomainService::class],
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
@ -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]),
|
||||
|
|
|
@ -51,6 +51,8 @@ class SingleStepCreateShortUrlAction extends AbstractCreateShortUrlAction
|
|||
|
||||
return new CreateShortUrlData($longUrl, [], ShortUrlMeta::fromRawData([
|
||||
ShortUrlMetaInputFilter::API_KEY => $apiKeyResult->apiKey(),
|
||||
// This will usually be null, unless this API key enforces one specific domain
|
||||
ShortUrlMetaInputFilter::DOMAIN => $request->getAttribute(ShortUrlMetaInputFilter::DOMAIN),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,10 +24,15 @@ class Role
|
|||
}
|
||||
|
||||
if ($role->name() === self::DOMAIN_SPECIFIC) {
|
||||
$domainId = $role->meta()['domain_id'] ?? -1;
|
||||
$domainId = self::domainIdFromMeta($role->meta());
|
||||
return $inlined ? new BelongsToDomainInlined($domainId) : new BelongsToDomain($domainId);
|
||||
}
|
||||
|
||||
return Spec::andX();
|
||||
}
|
||||
|
||||
public static function domainIdFromMeta(array $meta): string
|
||||
{
|
||||
return $meta['domain_id'] ?? '-1';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -83,4 +83,11 @@ class ApiKey extends AbstractEntity
|
|||
{
|
||||
return $this->roles->exists(fn ($key, ApiKeyRole $role) => $role->name() === $roleName);
|
||||
}
|
||||
|
||||
public function getRoleMeta(string $roleName): array
|
||||
{
|
||||
/** @var ApiKeyRole|false $role */
|
||||
$role = $this->roles->filter(fn (ApiKeyRole $role) => $role->name() === $roleName)->first();
|
||||
return ! $role ? [] : $role->meta();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Rest\Middleware\ShortUrl;
|
||||
|
||||
use Fig\Http\Message\RequestMethodInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Role;
|
||||
use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
|
||||
|
||||
class OverrideDomainMiddleware implements MiddlewareInterface
|
||||
{
|
||||
private DomainServiceInterface $domainService;
|
||||
|
||||
public function __construct(DomainServiceInterface $domainService)
|
||||
{
|
||||
$this->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()));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,141 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Rest\Middleware\ShortUrl;
|
||||
|
||||
use Laminas\Diactoros\Response;
|
||||
use Laminas\Diactoros\ServerRequestFactory;
|
||||
use PHPUnit\Framework\Assert;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Role;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
use Shlinkio\Shlink\Rest\Middleware\ShortUrl\OverrideDomainMiddleware;
|
||||
|
||||
class OverrideDomainMiddlewareTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private OverrideDomainMiddleware $middleware;
|
||||
private ObjectProphecy $domainService;
|
||||
private ObjectProphecy $apiKey;
|
||||
private ObjectProphecy $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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());
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue