diff --git a/CHANGELOG.md b/CHANGELOG.md index d570c89d..400083cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this The status code can be `200 OK` in case of success or `503 Service Unavailable` in case of error, while the `status` property will be one of `pass` or `fail`, as defined in the [Health check RFC](https://inadarei.github.io/rfc-healthcheck/). +* [#336](https://github.com/shlinkio/shlink/issues/336) Added an API test suite which performs API calls to an actual instance of the web service. + #### Changed * [#320](https://github.com/shlinkio/shlink/issues/320) Replaced query builder by plain DQL for all queries which do not need to be dynamically generated. diff --git a/bin/test/run-api-tests.sh b/bin/test/run-api-tests.sh new file mode 100755 index 00000000..5086ce41 --- /dev/null +++ b/bin/test/run-api-tests.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env sh +set -e + +export APP_ENV=test + +# Try to stop server just in case it hanged in last execution +vendor/bin/zend-expressive-swoole stop + +echo 'Starting server...' +vendor/bin/zend-expressive-swoole start -d +sleep 2 + +vendor/bin/phpunit --order-by=random -c phpunit-api.xml +vendor/bin/zend-expressive-swoole stop diff --git a/build.sh b/build.sh index cab2f4d3..99c82087 100755 --- a/build.sh +++ b/build.sh @@ -17,6 +17,7 @@ echo 'Copying project files...' rm -rf "${builtcontent}" mkdir -p "${builtcontent}" rsync -av * "${builtcontent}" \ + --exclude=bin/test \ --exclude=data/infra \ --exclude=data/travis \ --exclude=data/migrations_template.txt \ @@ -28,11 +29,11 @@ rsync -av * "${builtcontent}" \ --exclude=docs \ --exclude=indocker \ --exclude=docker* \ - --exclude=func_tests_bootstrap.php \ --exclude=php* \ --exclude=infection.json \ --exclude=phpstan.neon \ --exclude=config/autoload/*local* \ + --exclude=config/test \ --exclude=**/test* \ --exclude=build* cd "${builtcontent}" diff --git a/composer.json b/composer.json index d78860a1..0c8af151 100644 --- a/composer.json +++ b/composer.json @@ -51,6 +51,7 @@ }, "require-dev": { "devster/ubench": "^2.0", + "doctrine/data-fixtures": "^1.3", "filp/whoops": "^2.0", "infection/infection": "^0.11.0", "phpstan/phpstan": "^0.10.0", @@ -78,6 +79,7 @@ "psr-4": { "ShlinkioTest\\Shlink\\CLI\\": "module/CLI/test", "ShlinkioTest\\Shlink\\Rest\\": "module/Rest/test", + "ShlinkioApiTest\\Shlink\\Rest\\": "module/Rest/test-api", "ShlinkioTest\\Shlink\\Core\\": [ "module/Core/test", "module/Core/test-db" @@ -107,11 +109,13 @@ ], "test:ci": [ "@test:unit:ci", - "@test:db" + "@test:db", + "@test:api" ], "test:unit": "phpdbg -qrr vendor/bin/phpunit --order-by=random --coverage-php build/coverage-unit.cov", "test:unit:ci": "phpdbg -qrr vendor/bin/phpunit --order-by=random --coverage-php build/coverage-unit.cov --coverage-clover=build/clover.xml --coverage-xml=build/coverage-xml --log-junit=build/phpunit.junit.xml", - "test:db": "phpdbg -qrr vendor/bin/phpunit --order-by=random -c phpunit-db.xml --coverage-php build/coverage-db.cov", + "test:db": "APP_ENV=test phpdbg -qrr vendor/bin/phpunit --order-by=random -c phpunit-db.xml --coverage-php build/coverage-db.cov", + "test:api": "bin/test/run-api-tests.sh", "test:pretty": [ "@test", @@ -138,6 +142,7 @@ "test:unit": "Runs unit test suites", "test:unit:ci": "Runs unit test suites, generating all needed reports and logs for CI envs", "test:db": "Runs database test suites (covering entity repositories)", + "test:api": "Runs API test suites", "test:pretty": "Runs all test suites and generates an HTML code coverage report", "test:unit:pretty": "Runs unit test suites and generates an HTML code coverage report", "infect": "Checks unit tests quality applying mutation testing", diff --git a/config/cli-config.php b/config/cli-config.php index 774b3412..3c80ee16 100644 --- a/config/cli-config.php +++ b/config/cli-config.php @@ -6,18 +6,8 @@ use Doctrine\ORM\Tools\Console\ConsoleRunner; use Interop\Container\ContainerInterface; use Zend\ServiceManager\ServiceManager; -// If the "--test" flag was provided, we are on a test environment -$isTest = false; -foreach ($_SERVER['argv'] as $i => $arg) { - if ($arg === '--test') { - unset($_SERVER['argv'][$i]); - $isTest = true; - break; - } -} - /** @var ContainerInterface|ServiceManager $container */ -$container = $isTest ? include __DIR__ . '/test-container.php' : include __DIR__ . '/container.php'; +$container = include __DIR__ . '/container.php'; $em = $container->get(EntityManager::class); return ConsoleRunner::createHelperSet($em); diff --git a/config/config.php b/config/config.php index 1c87f66e..9b28cf82 100644 --- a/config/config.php +++ b/config/config.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink; use Acelaya\ExpressiveErrorHandler; use Zend\ConfigAggregator; use Zend\Expressive; +use function Shlinkio\Shlink\Common\env; return (new ConfigAggregator\ConfigAggregator([ Expressive\ConfigProvider::class, @@ -21,4 +22,7 @@ return (new ConfigAggregator\ConfigAggregator([ Rest\ConfigProvider::class, new ConfigAggregator\PhpFileProvider('config/autoload/{{,*.}global,{,*.}local}.php'), new ConfigAggregator\ZendConfigProvider('config/params/{generated_config.php,*.config.{php,json}}'), + env('APP_ENV') === 'test' + ? new ConfigAggregator\PhpFileProvider('config/test/*.global.php') + : new ConfigAggregator\ArrayProvider([]), ], 'data/cache/app_config.php'))->getMergedConfig(); diff --git a/config/test-container.php b/config/test-container.php deleted file mode 100644 index 8a3f4606..00000000 --- a/config/test-container.php +++ /dev/null @@ -1,14 +0,0 @@ -setAllowOverride(true); -$config = $container->get('config'); -$config['entity_manager']['connection'] = [ - 'driver' => 'pdo_sqlite', - 'path' => realpath(sys_get_temp_dir()) . '/shlink-tests.db', -]; -$container->setService('config', $config); - -return $container; diff --git a/config/test/bootstrap_api_tests.php b/config/test/bootstrap_api_tests.php new file mode 100644 index 00000000..c39042fa --- /dev/null +++ b/config/test/bootstrap_api_tests.php @@ -0,0 +1,26 @@ +get(TestHelper::class); +$config = $container->get('config'); + +$testHelper->createTestDb(); + +$em = $container->get(EntityManager::class); +$testHelper->seedFixtures($em, $config['data_fixtures'] ?? []); + +ApiTest\ApiTestCase::setApiClient($container->get('shlink_test_api_client')); diff --git a/config/test/bootstrap_db_tests.php b/config/test/bootstrap_db_tests.php new file mode 100644 index 00000000..58bc2174 --- /dev/null +++ b/config/test/bootstrap_db_tests.php @@ -0,0 +1,19 @@ +get(TestHelper::class)->createTestDb(); +DbTest\DatabaseTestCase::setEntityManager($container->get('em')); diff --git a/config/test/test_config.global.php b/config/test/test_config.global.php new file mode 100644 index 00000000..3de2584e --- /dev/null +++ b/config/test/test_config.global.php @@ -0,0 +1,57 @@ + true, + ConfigAggregator::ENABLE_CACHE => false, + + 'url_shortener' => [ + 'domain' => [ + 'schema' => 'http', + 'hostname' => 'doma.in', + ], + ], + + 'zend-expressive-swoole' => [ + 'swoole-http-server' => [ + 'port' => 9999, + 'host' => '127.0.0.1', + 'process-name' => 'shlink_test', + 'options' => [ + 'pid_file' => sys_get_temp_dir() . '/shlink-test-swoole.pid', + ], + ], + ], + + 'dependencies' => [ + 'factories' => [ + Common\TestHelper::class => InvokableFactory::class, + 'shlink_test_api_client' => function () { + return new Client(['base_uri' => 'http://localhost:9999/']); + }, + ], + ], + + 'entity_manager' => [ + 'connection' => [ + 'driver' => 'pdo_sqlite', + 'path' => realpath(sys_get_temp_dir()) . '/shlink-tests.db', + ], + ], + + 'data_fixtures' => [ + 'paths' => [ + __DIR__ . '/../../module/Rest/test-api/Fixtures', + ], + ], + +]; diff --git a/db_tests_bootstrap.php b/db_tests_bootstrap.php deleted file mode 100644 index 4bf09ba9..00000000 --- a/db_tests_bootstrap.php +++ /dev/null @@ -1,26 +0,0 @@ -inheritEnvironmentVariables() - ->mustRun(); - -DatabaseTestCase::$em = $container->get('em'); diff --git a/module/Common/test-db/ApiTest/ApiTestCase.php b/module/Common/test-db/ApiTest/ApiTestCase.php new file mode 100644 index 00000000..29959837 --- /dev/null +++ b/module/Common/test-db/ApiTest/ApiTestCase.php @@ -0,0 +1,51 @@ +request($method, sprintf('%s%s', self::PATH_PREFX, $uri), $options); + } + + /** + * @throws \GuzzleHttp\Exception\GuzzleException + */ + protected function callApiWithKey(string $method, string $uri, array $options = []): ResponseInterface + { + $headers = $options['headers'] ?? []; + $headers[ApiKeyHeaderPlugin::HEADER_NAME] = 'valid_api_key'; + $options['headers'] = $headers; + + return $this->callApi($method, $uri, $options); + } + + protected function getJsonResponsePayload(ResponseInterface $resp): array + { + return json_decode((string) $resp->getBody()); + } +} diff --git a/module/Common/test-db/DbUnit/DatabaseTestCase.php b/module/Common/test-db/DbTest/DatabaseTestCase.php similarity index 75% rename from module/Common/test-db/DbUnit/DatabaseTestCase.php rename to module/Common/test-db/DbTest/DatabaseTestCase.php index b3cb28d6..dc102c8c 100644 --- a/module/Common/test-db/DbUnit/DatabaseTestCase.php +++ b/module/Common/test-db/DbTest/DatabaseTestCase.php @@ -1,7 +1,7 @@ getEntityManager()->createQueryBuilder(); $qb->delete($entityClass, 'x'); $qb->getQuery()->execute(); } - // Clear entity manager $this->getEntityManager()->clear(); } } diff --git a/module/Common/test-db/TestHelper.php b/module/Common/test-db/TestHelper.php new file mode 100644 index 00000000..fc24657d --- /dev/null +++ b/module/Common/test-db/TestHelper.php @@ -0,0 +1,44 @@ +inheritEnvironmentVariables() + ->mustRun(); + } + + public function seedFixtures(EntityManagerInterface $em, array $config): void + { + $paths = $config['paths'] ?? []; + if (empty($paths)) { + return; + } + + $loader = new Loader(); + foreach ($paths as $path) { + $loader->loadFromDirectory($path); + } + + $executor = new ORMExecutor($em); + $executor->execute($loader->getFixtures(), true); + } +} diff --git a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php index c7f809b9..ae42e813 100644 --- a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php @@ -11,7 +11,7 @@ use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; -use ShlinkioTest\Shlink\Common\DbUnit\DatabaseTestCase; +use ShlinkioTest\Shlink\Common\DbTest\DatabaseTestCase; use function count; class ShortUrlRepositoryTest extends DatabaseTestCase diff --git a/module/Core/test-db/Repository/TagRepositoryTest.php b/module/Core/test-db/Repository/TagRepositoryTest.php index 9292753c..fb43bfe1 100644 --- a/module/Core/test-db/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Repository/TagRepositoryTest.php @@ -5,7 +5,7 @@ namespace ShlinkioTest\Shlink\Core\Repository; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Repository\TagRepository; -use ShlinkioTest\Shlink\Common\DbUnit\DatabaseTestCase; +use ShlinkioTest\Shlink\Common\DbTest\DatabaseTestCase; class TagRepositoryTest extends DatabaseTestCase { diff --git a/module/Core/test-db/Repository/VisitRepositoryTest.php b/module/Core/test-db/Repository/VisitRepositoryTest.php index 1841f5a0..ed059a37 100644 --- a/module/Core/test-db/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Repository/VisitRepositoryTest.php @@ -10,7 +10,7 @@ use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\VisitLocation; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Repository\VisitRepository; -use ShlinkioTest\Shlink\Common\DbUnit\DatabaseTestCase; +use ShlinkioTest\Shlink\Common\DbTest\DatabaseTestCase; use function sprintf; class VisitRepositoryTest extends DatabaseTestCase diff --git a/module/Rest/test-api/Action/ListShortUrlsTest.php b/module/Rest/test-api/Action/ListShortUrlsTest.php new file mode 100644 index 00000000..3e63da8b --- /dev/null +++ b/module/Rest/test-api/Action/ListShortUrlsTest.php @@ -0,0 +1,64 @@ +callApiWithKey(self::METHOD_GET, '/short-urls'); + $respPayload = $this->getJsonResponsePayload($resp); + + $this->assertEquals(self::STATUS_OK, $resp->getStatusCode()); + $this->assertEquals([ + 'shortUrls' => [ + 'data' => [ + [ + 'shortCode' => 'abc123', + 'shortUrl' => 'http://doma.in/abc123', + 'longUrl' => 'https://shlink.io', + 'dateCreated' => '2019-01-01T00:00:00+00:00', + 'visitsCount' => 3, + 'tags' => ['foo'], + 'originalUrl' => 'https://shlink.io', + ], + [ + 'shortCode' => 'def456', + 'shortUrl' => 'http://doma.in/def456', + 'longUrl' => + 'https://blog.alejandrocelaya.com/2017/12/09' + . '/acmailer-7-0-the-most-important-release-in-a-long-time/', + 'dateCreated' => '2019-01-01T00:00:00+00:00', + 'visitsCount' => 2, + 'tags' => ['foo', 'bar'], + 'originalUrl' => + 'https://blog.alejandrocelaya.com/2017/12/09' + . '/acmailer-7-0-the-most-important-release-in-a-long-time/', + ], + [ + 'shortCode' => 'custom', + 'shortUrl' => 'http://doma.in/custom', + 'longUrl' => 'https://shlink.io', + 'dateCreated' => '2019-01-01T00:00:00+00:00', + 'visitsCount' => 0, + 'tags' => [], + 'originalUrl' => 'https://shlink.io', + ], + ], + 'pagination' => [ + 'currentPage' => 1, + 'pagesCount' => 1, + 'itemsPerPage' => 10, + 'itemsInCurrentPage' => 3, + 'totalItems' => 3, + ], + ], + ], $respPayload); + } +} diff --git a/module/Rest/test-api/Fixtures/ApiKeyFixture.php b/module/Rest/test-api/Fixtures/ApiKeyFixture.php new file mode 100644 index 00000000..8990faa2 --- /dev/null +++ b/module/Rest/test-api/Fixtures/ApiKeyFixture.php @@ -0,0 +1,36 @@ +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())); + $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); + + if (! $enabled) { + $apiKey->disable(); + } + + return $apiKey; + } +} diff --git a/module/Rest/test-api/Fixtures/ShortUrlsFixture.php b/module/Rest/test-api/Fixtures/ShortUrlsFixture.php new file mode 100644 index 00000000..62c16c74 --- /dev/null +++ b/module/Rest/test-api/Fixtures/ShortUrlsFixture.php @@ -0,0 +1,52 @@ +setShortUrlDate(new ShortUrl('https://shlink.io'))->setShortCode('abc123'); + $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::createFromParams(Chronos::now()->addDays(3)) + ))->setShortCode('def456'); + $manager->persist($defShortUrl); + + $customShortUrl = $this->setShortUrlDate(new ShortUrl( + 'https://shlink.io', + ShortUrlMeta::createFromParams(null, null, 'custom', 2) + )); + $manager->persist($customShortUrl); + + $manager->flush(); + + $this->addReference('abc123_short_url', $abcShortUrl); + $this->addReference('def456_short_url', $defShortUrl); + } + + private function setShortUrlDate(ShortUrl $shortUrl): ShortUrl + { + $ref = new ReflectionObject($shortUrl); + $dateProp = $ref->getProperty('dateCreated'); + $dateProp->setAccessible(true); + $dateProp->setValue($shortUrl, Chronos::create(2019, 1, 1, 0, 0, 0)); + + return $shortUrl; + } +} diff --git a/module/Rest/test-api/Fixtures/TagsFixture.php b/module/Rest/test-api/Fixtures/TagsFixture.php new file mode 100644 index 00000000..c5e84648 --- /dev/null +++ b/module/Rest/test-api/Fixtures/TagsFixture.php @@ -0,0 +1,37 @@ +persist($fooTag); + $barTag = new Tag('bar'); + $manager->persist($barTag); + + /** @var ShortUrl $abcShortUrl */ + $abcShortUrl = $this->getReference('abc123_short_url'); + $abcShortUrl->setTags(new ArrayCollection([$fooTag])); + + /** @var ShortUrl $defShortUrl */ + $defShortUrl = $this->getReference('def456_short_url'); + $defShortUrl->setTags(new ArrayCollection([$fooTag, $barTag])); + + $manager->flush(); + } +} diff --git a/module/Rest/test-api/Fixtures/VisitsFixture.php b/module/Rest/test-api/Fixtures/VisitsFixture.php new file mode 100644 index 00000000..cd9cabfd --- /dev/null +++ b/module/Rest/test-api/Fixtures/VisitsFixture.php @@ -0,0 +1,35 @@ +getReference('abc123_short_url'); + $manager->persist(new Visit($abcShortUrl, new Visitor('shlink-tests-agent', '', '44.55.66.77'))); + $manager->persist(new Visit($abcShortUrl, new Visitor('shlink-tests-agent', 'https://google.com', '4.5.6.7'))); + $manager->persist(new Visit($abcShortUrl, new Visitor('shlink-tests-agent', '', '1.2.3.4'))); + + /** @var ShortUrl $defShortUrl */ + $defShortUrl = $this->getReference('def456_short_url'); + $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', ''))); + + $manager->flush(); + } +} diff --git a/module/Rest/test-api/Middleware/AuthenticationTest.php b/module/Rest/test-api/Middleware/AuthenticationTest.php new file mode 100644 index 00000000..c87b3c01 --- /dev/null +++ b/module/Rest/test-api/Middleware/AuthenticationTest.php @@ -0,0 +1,68 @@ +callApi(self::METHOD_GET, '/short-codes'); + } catch (ClientException $e) { + ['error' => $error, 'message' => $message] = $this->getJsonResponsePayload($e->getResponse()); + + $this->assertEquals(self::STATUS_UNAUTHORIZED, $e->getCode()); + $this->assertEquals(RestUtils::INVALID_AUTHORIZATION_ERROR, $error); + $this->assertEquals( + sprintf( + 'Expected one of the following authentication headers, but none were provided, ["%s"]', + implode('", "', RequestToHttpAuthPlugin::SUPPORTED_AUTH_HEADERS) + ), + $message + ); + } + } + + /** + * @test + * @dataProvider provideInvalidApiKeys + */ + public function apiKeyErrorIsReturnedWhenProvidedApiKeyIsInvalid(string $apiKey) + { + try { + $this->callApi(self::METHOD_GET, '/short-codes', [ + 'headers' => [ + ApiKeyHeaderPlugin::HEADER_NAME => $apiKey, + ], + ]); + } catch (ClientException $e) { + ['error' => $error, 'message' => $message] = json_decode((string) $e->getResponse()->getBody()); + + $this->assertEquals(self::STATUS_UNAUTHORIZED, $e->getCode()); + $this->assertEquals(RestUtils::INVALID_API_KEY_ERROR, $error); + $this->assertEquals('Provided API key does not exist or is invalid.', $message); + } + } + + public function provideInvalidApiKeys(): array + { + return [ + 'key which does not exist' => ['invalid'], + 'key which is expired' => ['expired_api_key'], + 'key which is disabled' => ['disabled_api_key'], + ]; + } +} diff --git a/phpunit-api.xml b/phpunit-api.xml new file mode 100644 index 00000000..69132097 --- /dev/null +++ b/phpunit-api.xml @@ -0,0 +1,19 @@ + + + + + ./module/*/test-api + + + + + + ./module/*/src + + + diff --git a/phpunit-db.xml b/phpunit-db.xml index f514afd9..eab4be28 100644 --- a/phpunit-db.xml +++ b/phpunit-db.xml @@ -1,9 +1,10 @@ + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/6.5/phpunit.xsd" + bootstrap="./config/test/bootstrap_db_tests.php" + colors="true" +> ./module/*/test-db