Applied API role specs to short URL creation

This commit is contained in:
Alejandro Celaya 2021-01-04 20:15:42 +01:00
parent 19834f6715
commit 4b67d41362
15 changed files with 314 additions and 7 deletions

View file

@ -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;
}
}

View file

@ -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;
}

View 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;
}
}

View file

@ -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';

View file

@ -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;
}

View file

@ -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();
}
}

View 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());
}
}

View file

@ -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;

View file

@ -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],
],
];

View file

@ -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]),

View file

@ -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),
]));
}
}

View file

@ -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';
}
}

View file

@ -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();
}
}

View file

@ -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()));
}
}

View file

@ -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());
}
}