Merge pull request #559 from acelaya-forks/feature/msi-80

Feature/msi 80
This commit is contained in:
Alejandro Celaya 2019-12-01 12:42:57 +01:00 committed by GitHub
commit 03825469ca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 262 additions and 80 deletions

View File

@ -20,6 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
#### Changed
* [#492](https://github.com/shlinkio/shlink/issues/492) Updated to monolog 2, together with other dependencies, like Symfony 5 and infection-php.
* [#527](https://github.com/shlinkio/shlink/issues/527) Increased minimum required mutation score for unit tests to 80%.
#### Deprecated

View File

@ -130,9 +130,9 @@
"test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite",
"test:api": "bin/test/run-api-tests.sh",
"test:unit:pretty": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage",
"infect": "infection --threads=4 --min-msi=75 --log-verbosity=default --only-covered",
"infect:ci": "infection --threads=4 --min-msi=75 --log-verbosity=default --only-covered --coverage=build",
"infect:show": "infection --threads=4 --min-msi=75 --log-verbosity=default --only-covered --show-mutations",
"infect": "infection --threads=4 --min-msi=80 --log-verbosity=default --only-covered",
"infect:ci": "infection --threads=4 --min-msi=80 --log-verbosity=default --only-covered --coverage=build",
"infect:show": "infection --threads=4 --min-msi=80 --log-verbosity=default --only-covered --show-mutations",
"infect:test": [
"@test:unit:ci",
"@infect:ci"

View File

@ -27,6 +27,14 @@ class DeleteShortUrlExceptionTest extends TestCase
$this->assertEquals($threshold, $e->getVisitsThreshold());
$this->assertEquals($expectedMessage, $e->getMessage());
$this->assertEquals($expectedMessage, $e->getDetail());
$this->assertEquals([
'shortCode' => $shortCode,
'threshold' => $threshold,
], $e->getAdditionalData());
$this->assertEquals('Cannot delete short URL', $e->getTitle());
$this->assertEquals('INVALID_SHORTCODE_DELETION', $e->getType());
$this->assertEquals(422, $e->getStatus());
}
public function provideThresholds(): array

View File

@ -10,6 +10,8 @@ use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Throwable;
use function sprintf;
class InvalidUrlExceptionTest extends TestCase
{
/**
@ -18,10 +20,17 @@ class InvalidUrlExceptionTest extends TestCase
*/
public function properlyCreatesExceptionFromUrl(?Throwable $prev): void
{
$e = InvalidUrlException::fromUrl('http://the_url.com', $prev);
$url = 'http://the_url.com';
$expectedMessage = sprintf('Provided URL %s is invalid. Try with a different one.', $url);
$e = InvalidUrlException::fromUrl($url, $prev);
$this->assertEquals('Provided URL http://the_url.com is invalid. Try with a different one.', $e->getMessage());
$this->assertEquals($expectedMessage, $e->getMessage());
$this->assertEquals($expectedMessage, $e->getDetail());
$this->assertEquals('Invalid URL', $e->getTitle());
$this->assertEquals('INVALID_URL', $e->getType());
$this->assertEquals(['url' => $url], $e->getAdditionalData());
$this->assertEquals(StatusCodeInterface::STATUS_BAD_REQUEST, $e->getCode());
$this->assertEquals(StatusCodeInterface::STATUS_BAD_REQUEST, $e->getStatus());
$this->assertEquals($prev, $e->getPrevious());
}

View File

@ -15,8 +15,19 @@ class NonUniqueSlugExceptionTest extends TestCase
*/
public function properlyCreatesExceptionFromSlug(string $expectedMessage, string $slug, ?string $domain): void
{
$expectedAdditional = ['customSlug' => $slug];
if ($domain !== null) {
$expectedAdditional['domain'] = $domain;
}
$e = NonUniqueSlugException::fromSlug($slug, $domain);
$this->assertEquals($expectedMessage, $e->getMessage());
$this->assertEquals($expectedMessage, $e->getDetail());
$this->assertEquals('Invalid custom slug', $e->getTitle());
$this->assertEquals('INVALID_SLUG', $e->getType());
$this->assertEquals(400, $e->getStatus());
$this->assertEquals($expectedAdditional, $e->getAdditionalData());
}
public function provideMessages(): iterable

View File

@ -18,8 +18,19 @@ class ShortUrlNotFoundExceptionTest extends TestCase
string $shortCode,
?string $domain
): void {
$expectedAdditional = ['shortCode' => $shortCode];
if ($domain !== null) {
$expectedAdditional['domain'] = $domain;
}
$e = ShortUrlNotFoundException::fromNotFoundShortCode($shortCode, $domain);
$this->assertEquals($expectedMessage, $e->getMessage());
$this->assertEquals($expectedMessage, $e->getDetail());
$this->assertEquals('Short URL not found', $e->getTitle());
$this->assertEquals('INVALID_SHORTCODE', $e->getType());
$this->assertEquals(404, $e->getStatus());
$this->assertEquals($expectedAdditional, $e->getAdditionalData());
}
public function provideMessages(): iterable

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest\Exception;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use function sprintf;
class TagNotFoundExceptionTest extends TestCase
{
/** @test */
public function properlyCreatesExceptionFromNotFoundTag(): void
{
$tag = 'foo';
$expectedMessage = sprintf('Tag with name "%s" could not be found', $tag);
$e = TagNotFoundException::fromTag($tag);
$this->assertEquals($expectedMessage, $e->getMessage());
$this->assertEquals($expectedMessage, $e->getDetail());
$this->assertEquals('Tag not found', $e->getTitle());
$this->assertEquals('TAG_NOT_FOUND', $e->getType());
$this->assertEquals(['tag' => $tag], $e->getAdditionalData());
$this->assertEquals(404, $e->getStatus());
}
}

View File

@ -4,19 +4,26 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Rest;
use Zend\Config\Factory;
use Zend\Stdlib\Glob;
use function Shlinkio\Shlink\Common\loadConfigFromGlob;
use function sprintf;
class ConfigProvider
{
private const ROUTES_PREFIX = '/rest/v{version:1|2}';
/** @var callable */
private $loadConfig;
public function __construct(?callable $loadConfig = null)
{
$this->loadConfig = $loadConfig ?? function (string $glob) {
return loadConfigFromGlob($glob);
};
}
public function __invoke()
{
/** @var array $config */
$config = Factory::fromFiles(Glob::glob(__DIR__ . '/../config/{,*.}config.php', Glob::GLOB_BRACE));
$config = ($this->loadConfig)(__DIR__ . '/../config/{,*.}config.php');
return $this->applyRoutesPrefix($config);
}

View File

@ -21,7 +21,7 @@ class MissingAuthenticationException extends RuntimeException implements Problem
public static function fromExpectedTypes(array $expectedTypes): self
{
$e = new self(sprintf(
'Expected one of the following authentication headers, but none were provided, ["%s"]',
'Expected one of the following authentication headers, ["%s"], but none were provided',
implode('", "', $expectedTypes)
));

View File

@ -17,7 +17,7 @@ class AuthenticationTest extends ApiTestCase
public function authorizationErrorIsReturnedIfNoApiKeyIsSent(): void
{
$expectedDetail = sprintf(
'Expected one of the following authentication headers, but none were provided, ["%s"]',
'Expected one of the following authentication headers, ["%s"], but none were provided',
implode('", "', RequestToHttpAuthPlugin::SUPPORTED_AUTH_HEADERS)
);

View File

@ -4,14 +4,17 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest\Action\ShortUrl;
use Cake\Chronos\Chronos;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
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 Zend\Diactoros\ServerRequest;
use Zend\Diactoros\ServerRequestFactory;
use Zend\Diactoros\Uri;
use function strpos;
@ -41,20 +44,41 @@ class CreateShortUrlActionTest extends TestCase
$this->action->handle(new ServerRequest());
}
/** @test */
public function properShortcodeConversionReturnsData(): void
/**
* @test
* @dataProvider provideRequestBodies
*/
public function properShortcodeConversionReturnsData(array $body, ShortUrlMeta $expectedMeta): void
{
$shortUrl = new ShortUrl('');
$this->urlShortener->urlToShortCode(Argument::type(Uri::class), Argument::type('array'), Argument::cetera())
->willReturn($shortUrl)
->shouldBeCalledOnce();
$shorten = $this->urlShortener->urlToShortCode(
Argument::type(Uri::class),
Argument::type('array'),
$expectedMeta
)->willReturn($shortUrl);
$request = (new ServerRequest())->withParsedBody([
'longUrl' => 'http://www.domain.com/foo/bar',
]);
$request = ServerRequestFactory::fromGlobals()->withParsedBody($body);
$response = $this->action->handle($request);
$this->assertEquals(200, $response->getStatusCode());
$this->assertTrue(strpos($response->getBody()->getContents(), $shortUrl->toString(self::DOMAIN_CONFIG)) > 0);
$shorten->shouldHaveBeenCalledOnce();
}
public function provideRequestBodies(): iterable
{
$fullMeta = [
'longUrl' => 'http://www.domain.com/foo/bar',
'validSince' => Chronos::now()->toAtomString(),
'validUntil' => Chronos::now()->toAtomString(),
'customSlug' => 'foo-bar-baz',
'maxVisits' => 50,
'findIfExists' => true,
'domain' => 'my-domain.com',
];
yield [['longUrl' => 'http://www.domain.com/foo/bar'], ShortUrlMeta::createEmpty()];
yield [$fullMeta, ShortUrlMeta::createFromRawData($fullMeta)];
}
/**

View File

@ -37,7 +37,7 @@ class RequestToAuthPluginTest extends TestCase
$this->expectException(MissingAuthenticationException::class);
$this->expectExceptionMessage(sprintf(
'Expected one of the following authentication headers, but none were provided, ["%s"]',
'Expected one of the following authentication headers, ["%s"], but none were provided',
implode('", "', RequestToHttpAuthPlugin::SUPPORTED_AUTH_HEADERS)
));

View File

@ -20,9 +20,31 @@ class ConfigProviderTest extends TestCase
/** @test */
public function properConfigIsReturned(): void
{
$config = $this->configProvider->__invoke();
$config = ($this->configProvider)();
$this->assertArrayHasKey('routes', $config);
$this->assertArrayHasKey('dependencies', $config);
}
/** @test */
public function routesAreProperlyPrefixed(): void
{
$configProvider = new ConfigProvider(function () {
return [
'routes' => [
['path' => '/foo'],
['path' => '/bar'],
['path' => '/baz/foo'],
],
];
});
$config = $configProvider();
$this->assertEquals([
['path' => '/rest/v{version:1|2}/foo'],
['path' => '/rest/v{version:1|2}/bar'],
['path' => '/rest/v{version:1|2}/baz/foo'],
], $config['routes']);
}
}

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest\Exception;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Rest\Exception\MissingAuthenticationException;
use function implode;
use function sprintf;
class MissingAuthenticationExceptionTest extends TestCase
{
/**
* @test
* @dataProvider provideExpectedTypes
*/
public function exceptionIsProperlyCreatedFromExpectedTypes(array $expectedTypes): void
{
$expectedMessage = sprintf(
'Expected one of the following authentication headers, ["%s"], but none were provided',
implode('", "', $expectedTypes)
);
$e = MissingAuthenticationException::fromExpectedTypes($expectedTypes);
$this->assertEquals($expectedMessage, $e->getMessage());
$this->assertEquals($expectedMessage, $e->getDetail());
$this->assertEquals('Invalid authorization', $e->getTitle());
$this->assertEquals('INVALID_AUTHORIZATION', $e->getType());
$this->assertEquals(401, $e->getStatus());
$this->assertEquals(['expectedTypes' => $expectedTypes], $e->getAdditionalData());
}
public function provideExpectedTypes(): iterable
{
yield [['foo', 'bar']];
yield [['something']];
yield [[]];
yield [['foo', 'bar', 'baz']];
}
}

View File

@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\Rest\Middleware;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ProphecyInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Rest\Middleware\BodyParserMiddleware;
@ -31,7 +32,10 @@ class BodyParserMiddlewareTest extends TestCase
*/
public function requestsFromOtherMethodsJustFallbackToNextMiddleware(string $method): void
{
$request = (new ServerRequest())->withMethod($method);
$request = $this->prophesize(ServerRequestInterface::class);
$request->getMethod()->willReturn($method);
$request->getParsedBody()->willReturn([]);
$this->assertHandlingRequestJustFallsBackToNext($request);
}
@ -45,18 +49,25 @@ class BodyParserMiddlewareTest extends TestCase
/** @test */
public function requestsWithNonEmptyBodyJustFallbackToNextMiddleware(): void
{
$request = (new ServerRequest())->withParsedBody(['foo' => 'bar'])->withMethod('POST');
$request = $this->prophesize(ServerRequestInterface::class);
$request->getMethod()->willReturn('POST');
$request->getParsedBody()->willReturn(['foo' => 'bar']);
$this->assertHandlingRequestJustFallsBackToNext($request);
}
private function assertHandlingRequestJustFallsBackToNext(ServerRequestInterface $request): void
private function assertHandlingRequestJustFallsBackToNext(ProphecyInterface $requestMock): void
{
$getContentType = $requestMock->getHeaderLine('Content-type')->willReturn('');
$request = $requestMock->reveal();
$nextHandler = $this->prophesize(RequestHandlerInterface::class);
$handle = $nextHandler->handle($request)->willReturn(new Response());
$this->middleware->process($request, $nextHandler->reveal());
$handle->shouldHaveBeenCalledOnce();
$getContentType->shouldNotHaveBeenCalled();
}
/** @test */

View File

@ -8,12 +8,14 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Rest\Authentication;
use Shlinkio\Shlink\Rest\Middleware\CrossDomainMiddleware;
use Zend\Diactoros\Response;
use Zend\Diactoros\ServerRequest;
use Zend\Expressive\Router\Route;
use Zend\Expressive\Router\RouteResult;
use function implode;
use function Zend\Stratigility\middleware;
class CrossDomainMiddlewareTest extends TestCase
@ -39,6 +41,7 @@ class CrossDomainMiddlewareTest extends TestCase
$this->assertSame($originalResponse, $response);
$headers = $response->getHeaders();
$this->assertArrayNotHasKey('Access-Control-Allow-Origin', $headers);
$this->assertArrayNotHasKey('Access-Control-Expose-Headers', $headers);
$this->assertArrayNotHasKey('Access-Control-Allow-Methods', $headers);
@ -59,8 +62,12 @@ class CrossDomainMiddlewareTest extends TestCase
$this->assertNotSame($originalResponse, $response);
$headers = $response->getHeaders();
$this->assertArrayHasKey('Access-Control-Allow-Origin', $headers);
$this->assertArrayHasKey('Access-Control-Expose-Headers', $headers);
$this->assertEquals('local', $response->getHeaderLine('Access-Control-Allow-Origin'));
$this->assertEquals(implode(', ', [
Authentication\Plugin\ApiKeyHeaderPlugin::HEADER_NAME,
Authentication\Plugin\AuthorizationHeaderPlugin::HEADER_NAME,
]), $response->getHeaderLine('Access-Control-Expose-Headers'));
$this->assertArrayNotHasKey('Access-Control-Allow-Methods', $headers);
$this->assertArrayNotHasKey('Access-Control-Max-Age', $headers);
$this->assertArrayNotHasKey('Access-Control-Allow-Headers', $headers);
@ -70,18 +77,25 @@ class CrossDomainMiddlewareTest extends TestCase
public function optionsRequestIncludesMoreHeaders(): void
{
$originalResponse = new Response();
$request = (new ServerRequest())->withMethod('OPTIONS')->withHeader('Origin', 'local');
$request = (new ServerRequest())
->withMethod('OPTIONS')
->withHeader('Origin', 'local')
->withHeader('Access-Control-Request-Headers', 'foo, bar, baz');
$this->handler->handle(Argument::any())->willReturn($originalResponse)->shouldBeCalledOnce();
$response = $this->middleware->process($request, $this->handler->reveal());
$this->assertNotSame($originalResponse, $response);
$headers = $response->getHeaders();
$this->assertArrayHasKey('Access-Control-Allow-Origin', $headers);
$this->assertArrayHasKey('Access-Control-Expose-Headers', $headers);
$this->assertEquals('local', $response->getHeaderLine('Access-Control-Allow-Origin'));
$this->assertEquals(implode(', ', [
Authentication\Plugin\ApiKeyHeaderPlugin::HEADER_NAME,
Authentication\Plugin\AuthorizationHeaderPlugin::HEADER_NAME,
]), $response->getHeaderLine('Access-Control-Expose-Headers'));
$this->assertArrayHasKey('Access-Control-Allow-Methods', $headers);
$this->assertArrayHasKey('Access-Control-Max-Age', $headers);
$this->assertArrayHasKey('Access-Control-Allow-Headers', $headers);
$this->assertEquals('1000', $response->getHeaderLine('Access-Control-Max-Age'));
$this->assertEquals('foo, bar, baz', $response->getHeaderLine('Access-Control-Allow-Headers'));
}
/**

View File

@ -27,65 +27,49 @@ class ApiKeyServiceTest extends TestCase
$this->service = new ApiKeyService($this->em->reveal());
}
/** @test */
public function keyIsProperlyCreated()
/**
* @test
* @dataProvider provideCreationDate
*/
public function apiKeyIsProperlyCreated(?Chronos $date): void
{
$this->em->flush()->shouldBeCalledOnce();
$this->em->persist(Argument::type(ApiKey::class))->shouldBeCalledOnce();
$key = $this->service->create();
$this->assertNull($key->getExpirationDate());
}
/** @test */
public function keyIsProperlyCreatedWithExpirationDate()
{
$this->em->flush()->shouldBeCalledOnce();
$this->em->persist(Argument::type(ApiKey::class))->shouldBeCalledOnce();
$date = Chronos::parse('2030-01-01');
$key = $this->service->create($date);
$this->assertSame($date, $key->getExpirationDate());
$this->assertEquals($date, $key->getExpirationDate());
}
/** @test */
public function checkReturnsFalseWhenKeyIsInvalid()
public function provideCreationDate(): iterable
{
yield 'no expiration date' => [null];
yield 'expiration date' => [Chronos::parse('2030-01-01')];
}
/**
* @test
* @dataProvider provideInvalidApiKeys
*/
public function checkReturnsFalseForInvalidApiKeys(?ApiKey $invalidKey): void
{
$repo = $this->prophesize(EntityRepository::class);
$repo->findOneBy(['key' => '12345'])->willReturn(null)
$repo->findOneBy(['key' => '12345'])->willReturn($invalidKey)
->shouldBeCalledOnce();
$this->em->getRepository(ApiKey::class)->willReturn($repo->reveal());
$this->assertFalse($this->service->check('12345'));
}
/** @test */
public function checkReturnsFalseWhenKeyIsDisabled()
public function provideInvalidApiKeys(): iterable
{
$key = new ApiKey();
$key->disable();
$repo = $this->prophesize(EntityRepository::class);
$repo->findOneBy(['key' => '12345'])->willReturn($key)
->shouldBeCalledOnce();
$this->em->getRepository(ApiKey::class)->willReturn($repo->reveal());
$this->assertFalse($this->service->check('12345'));
yield 'non-existent api key' => [null];
yield 'disabled api key' => [(new ApiKey())->disable()];
yield 'expired api key' => [new ApiKey(Chronos::now()->subDay())];
}
/** @test */
public function checkReturnsFalseWhenKeyIsExpired()
{
$key = new ApiKey(Chronos::now()->subDay());
$repo = $this->prophesize(EntityRepository::class);
$repo->findOneBy(['key' => '12345'])->willReturn($key)
->shouldBeCalledOnce();
$this->em->getRepository(ApiKey::class)->willReturn($repo->reveal());
$this->assertFalse($this->service->check('12345'));
}
/** @test */
public function checkReturnsTrueWhenConditionsAreFavorable()
public function checkReturnsTrueWhenConditionsAreFavorable(): void
{
$repo = $this->prophesize(EntityRepository::class);
$repo->findOneBy(['key' => '12345'])->willReturn(new ApiKey())
@ -96,7 +80,7 @@ class ApiKeyServiceTest extends TestCase
}
/** @test */
public function disableThrowsExceptionWhenNoTokenIsFound()
public function disableThrowsExceptionWhenNoApiKeyIsFound(): void
{
$repo = $this->prophesize(EntityRepository::class);
$repo->findOneBy(['key' => '12345'])->willReturn(null)
@ -104,11 +88,12 @@ class ApiKeyServiceTest extends TestCase
$this->em->getRepository(ApiKey::class)->willReturn($repo->reveal());
$this->expectException(InvalidArgumentException::class);
$this->service->disable('12345');
}
/** @test */
public function disableReturnsDisabledKeyWhenFOund()
public function disableReturnsDisabledApiKeyWhenFound(): void
{
$key = new ApiKey();
$repo = $this->prophesize(EntityRepository::class);
@ -125,24 +110,32 @@ class ApiKeyServiceTest extends TestCase
}
/** @test */
public function listFindsAllApiKeys()
public function listFindsAllApiKeys(): void
{
$expectedApiKeys = [new ApiKey(), new ApiKey(), new ApiKey()];
$repo = $this->prophesize(EntityRepository::class);
$repo->findBy([])->willReturn([])
$repo->findBy([])->willReturn($expectedApiKeys)
->shouldBeCalledOnce();
$this->em->getRepository(ApiKey::class)->willReturn($repo->reveal());
$this->service->listKeys();
$result = $this->service->listKeys();
$this->assertEquals($expectedApiKeys, $result);
}
/** @test */
public function listEnabledFindsOnlyEnabledApiKeys()
public function listEnabledFindsOnlyEnabledApiKeys(): void
{
$expectedApiKeys = [new ApiKey(), new ApiKey(), new ApiKey()];
$repo = $this->prophesize(EntityRepository::class);
$repo->findBy(['enabled' => true])->willReturn([])
$repo->findBy(['enabled' => true])->willReturn($expectedApiKeys)
->shouldBeCalledOnce();
$this->em->getRepository(ApiKey::class)->willReturn($repo->reveal());
$this->service->listKeys(true);
$result = $this->service->listKeys(true);
$this->assertEquals($expectedApiKeys, $result);
}
}