diff --git a/.env.travis b/.env.travis index f1d4f5698..e0040b948 100644 --- a/.env.travis +++ b/.env.travis @@ -5,10 +5,10 @@ APP_THEME=pterodactyl APP_TIMEZONE=UTC APP_URL=http://localhost/ -DB_HOST=127.0.0.1 -DB_DATABASE=travis -DB_USERNAME=root -DB_PASSWORD="" +TESTING_DB_HOST=127.0.0.1 +TESTING_DB_DATABASE=travis +TESTING_DB_USERNAME=root +TESTING_DB_PASSWORD="" CACHE_DRIVER=array SESSION_DRIVER=array diff --git a/.travis.yml b/.travis.yml index 0a19b3748..4ed53fd7f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,9 +14,9 @@ before_script: - echo 'opcache.enable_cli=1' >> ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/travis.ini - cp .env.travis .env - travis_retry composer install --no-interaction --prefer-dist --no-suggest - - php artisan migrate --seed script: - - vendor/bin/phpunit --coverage-clover coverage.xml + - vendor/bin/phpunit --bootstrap vendor/autoload.php --coverage-clover coverage.xml tests/Unit + - vendor/bin/phpunit tests/Integration notifications: email: false webhooks: diff --git a/app/Transformers/Api/Application/BaseTransformer.php b/app/Transformers/Api/Application/BaseTransformer.php index 5766088c9..be5e367f6 100644 --- a/app/Transformers/Api/Application/BaseTransformer.php +++ b/app/Transformers/Api/Application/BaseTransformer.php @@ -5,10 +5,15 @@ namespace Pterodactyl\Transformers\Api\Application; use Cake\Chronos\Chronos; use Pterodactyl\Models\ApiKey; use Illuminate\Container\Container; +use Illuminate\Database\Eloquent\Model; use League\Fractal\TransformerAbstract; use Pterodactyl\Services\Acl\Api\AdminAcl; +use Pterodactyl\Transformers\Api\Client\BaseClientTransformer; use Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException; +/** + * @method array transform(Model $model) + */ abstract class BaseTransformer extends TransformerAbstract { const RESPONSE_TIMEZONE = 'UTC'; @@ -88,7 +93,7 @@ abstract class BaseTransformer extends TransformerAbstract $transformer = Container::getInstance()->makeWith($abstract, $parameters); $transformer->setKey($this->getKey()); - if (! $transformer instanceof self) { + if (! $transformer instanceof self || $transformer instanceof BaseClientTransformer) { throw new InvalidTransformerLevelException('Calls to ' . __METHOD__ . ' must return a transformer that is an instance of ' . __CLASS__); } diff --git a/app/Transformers/Api/Application/LocationTransformer.php b/app/Transformers/Api/Application/LocationTransformer.php index d003053d6..d54e77d20 100644 --- a/app/Transformers/Api/Application/LocationTransformer.php +++ b/app/Transformers/Api/Application/LocationTransformer.php @@ -32,7 +32,13 @@ class LocationTransformer extends BaseTransformer */ public function transform(Location $location): array { - return $location->toArray(); + return [ + 'id' => $location->id, + 'short' => $location->short, + 'long' => $location->long, + $location->getUpdatedAtColumn() => $this->formatTimestamp($location->updated_at), + $location->getCreatedAtColumn() => $this->formatTimestamp($location->created_at), + ]; } /** @@ -40,6 +46,8 @@ class LocationTransformer extends BaseTransformer * * @param \Pterodactyl\Models\Location $location * @return \League\Fractal\Resource\Collection|\League\Fractal\Resource\NullResource + * + * @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException */ public function includeServers(Location $location) { @@ -57,6 +65,8 @@ class LocationTransformer extends BaseTransformer * * @param \Pterodactyl\Models\Location $location * @return \League\Fractal\Resource\Collection|\League\Fractal\Resource\NullResource + * + * @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException */ public function includeNodes(Location $location) { diff --git a/bootstrap/tests.php b/bootstrap/tests.php new file mode 100644 index 000000000..4e3a7e28a --- /dev/null +++ b/bootstrap/tests.php @@ -0,0 +1,28 @@ +make(Kernel::class); + +/** + * Bootstrap the kernel and prepare application for testing. + */ +$kernel->bootstrap(); + +$output = new ConsoleOutput; + +/** + * Perform database migrations and reseeding before continuing with + * running the tests. + */ +$output->writeln(PHP_EOL . 'Refreshing database for Integration tests...'); +$kernel->call('migrate:fresh', ['--database' => 'testing']); + +$output->writeln('Seeding database for Integration tests...' . PHP_EOL); +$kernel->call('db:seed', ['--database' => 'testing']); diff --git a/composer.json b/composer.json index 25af3e4ad..5c734647f 100644 --- a/composer.json +++ b/composer.json @@ -65,6 +65,7 @@ }, "autoload-dev": { "psr-4": { + "Pterodactyl\\Tests\\Integration\\": "tests/Integration", "Tests\\": "tests/" } }, diff --git a/config/database.php b/config/database.php index acbc8627b..cbeefb845 100644 --- a/config/database.php +++ b/config/database.php @@ -43,6 +43,28 @@ return [ 'prefix' => env('DB_PREFIX', ''), 'strict' => env('DB_STRICT_MODE', false), ], + + /* + | ------------------------------------------------------------------------- + | Test Database Connection + | ------------------------------------------------------------------------- + | + | This connection is used by the integration and HTTP tests for Pterodactyl + | development. Normal users of the Panel do not need to adjust any settings + | in here. + */ + 'testing' => [ + 'driver' => 'mysql', + 'host' => env('TESTING_DB_HOST', '127.0.0.1'), + 'port' => env('TESTING_DB_PORT', '3306'), + 'database' => env('TESTING_DB_DATABASE', 'panel_test'), + 'username' => env('TESTING_DB_USERNAME', 'pterodactyl_test'), + 'password' => env('TESTING_DB_PASSWORD', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'strict' => false, + ], ], /* diff --git a/database/factories/ModelFactory.php b/database/factories/ModelFactory.php index bef8ee396..28f55777e 100644 --- a/database/factories/ModelFactory.php +++ b/database/factories/ModelFactory.php @@ -1,6 +1,7 @@ define(Pterodactyl\Models\Server::class, function (Faker $faker) { 'egg_id' => $faker->randomNumber(), 'pack_id' => null, 'installed' => 1, + 'database_limit' => null, + 'allocation_limit' => null, 'created_at' => \Carbon\Carbon::now(), 'updated_at' => \Carbon\Carbon::now(), ]; @@ -74,7 +77,6 @@ $factory->define(Pterodactyl\Models\Location::class, function (Faker $faker) { $factory->define(Pterodactyl\Models\Node::class, function (Faker $faker) { return [ 'id' => $faker->unique()->randomNumber(), - 'uuid' => $faker->unique()->uuid, 'public' => true, 'name' => $faker->firstName, 'fqdn' => $faker->ipv4, @@ -224,21 +226,17 @@ $factory->define(Pterodactyl\Models\DaemonKey::class, function (Faker $faker) { }); $factory->define(Pterodactyl\Models\ApiKey::class, function (Faker $faker) { + static $token; + return [ 'id' => $faker->unique()->randomNumber(), 'user_id' => $faker->randomNumber(), + 'key_type' => ApiKey::TYPE_APPLICATION, 'identifier' => str_random(Pterodactyl\Models\ApiKey::IDENTIFIER_LENGTH), - 'token' => 'encrypted_string', + 'token' => $token ?: $token = encrypt(str_random(Pterodactyl\Models\ApiKey::KEY_LENGTH)), + 'allowed_ips' => null, 'memo' => 'Test Function Key', 'created_at' => \Carbon\Carbon::now()->toDateTimeString(), 'updated_at' => \Carbon\Carbon::now()->toDateTimeString(), ]; }); - -$factory->define(Pterodactyl\Models\APIPermission::class, function (Faker $faker) { - return [ - 'id' => $faker->unique()->randomNumber(), - 'key_id' => $faker->randomNumber(), - 'permission' => mb_strtolower($faker->word), - ]; -}); diff --git a/phpunit.xml b/phpunit.xml index 82cc1fd74..26b662c91 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,7 +1,7 @@ - - - - + diff --git a/tests/Integration/Api/Application/ApplicationApiIntegrationTestCase.php b/tests/Integration/Api/Application/ApplicationApiIntegrationTestCase.php new file mode 100644 index 000000000..1a5d0a086 --- /dev/null +++ b/tests/Integration/Api/Application/ApplicationApiIntegrationTestCase.php @@ -0,0 +1,142 @@ +user = $this->createApiUser(); + $this->key = $this->createApiKey($this->user); + + $this->withHeader('Accept', 'application/vnd.pterodactyl.v1+json'); + $this->withHeader('Authorization', 'Bearer ' . $this->getApiKey()->identifier . decrypt($this->getApiKey()->token)); + + $this->withMiddleware('api..key:' . ApiKey::TYPE_APPLICATION); + } + + /** + * @return \Pterodactyl\Models\User + */ + public function getApiUser(): User + { + return $this->user; + } + + /** + * @return \Pterodactyl\Models\ApiKey + */ + public function getApiKey(): ApiKey + { + return $this->key; + } + + /** + * Creates a new default API key and refreshes the headers using it. + * + * @param \Pterodactyl\Models\User $user + * @param array $permissions + * @return \Pterodactyl\Models\ApiKey + */ + protected function createNewDefaultApiKey(User $user, array $permissions = []): ApiKey + { + $this->key = $this->createApiKey($user, $permissions); + $this->refreshHeaders($this->key); + + return $this->key; + } + + /** + * Refresh the authorization header for a request to use a different API key. + * + * @param \Pterodactyl\Models\ApiKey $key + */ + protected function refreshHeaders(ApiKey $key) + { + $this->withHeader('Authorization', 'Bearer ' . $key->identifier . decrypt($key->token)); + } + + /** + * Create an administrative user. + * + * @return \Pterodactyl\Models\User + */ + protected function createApiUser(): User + { + return factory(User::class)->create([ + 'root_admin' => true, + ]); + } + + /** + * Create a new application API key for a given user model. + * + * @param \Pterodactyl\Models\User $user + * @param array $permissions + * @return \Pterodactyl\Models\ApiKey + */ + protected function createApiKey(User $user, array $permissions = []): ApiKey + { + return factory(ApiKey::class)->create(array_merge([ + 'user_id' => $user->id, + 'key_type' => ApiKey::TYPE_APPLICATION, + 'r_servers' => AdminAcl::READ | AdminAcl::WRITE, + 'r_nodes' => AdminAcl::READ | AdminAcl::WRITE, + 'r_allocations' => AdminAcl::READ | AdminAcl::WRITE, + 'r_users' => AdminAcl::READ | AdminAcl::WRITE, + 'r_locations' => AdminAcl::READ | AdminAcl::WRITE, + 'r_nests' => AdminAcl::READ | AdminAcl::WRITE, + 'r_eggs' => AdminAcl::READ | AdminAcl::WRITE, + 'r_database_hosts' => AdminAcl::READ | AdminAcl::WRITE, + 'r_server_databases' => AdminAcl::READ | AdminAcl::WRITE, + 'r_packs' => AdminAcl::READ | AdminAcl::WRITE, + ], $permissions)); + } + + /** + * Return a transformer that can be used for testing purposes. + * + * @param string $abstract + * @return \Pterodactyl\Transformers\Api\Application\BaseTransformer + */ + protected function getTransformer(string $abstract): BaseTransformer + { + /** @var \Pterodactyl\Transformers\Api\Application\BaseTransformer $transformer */ + $transformer = $this->app->make($abstract); + $transformer->setKey($this->getApiKey()); + + Assert::assertInstanceOf(BaseTransformer::class, $transformer); + Assert::assertNotInstanceOf(BaseClientTransformer::class, $transformer); + + return $transformer; + } +} diff --git a/tests/Integration/Api/Application/Location/LocationControllerTest.php b/tests/Integration/Api/Application/Location/LocationControllerTest.php new file mode 100644 index 000000000..db315211b --- /dev/null +++ b/tests/Integration/Api/Application/Location/LocationControllerTest.php @@ -0,0 +1,207 @@ +times(2)->create(); + + $response = $this->json('GET', '/api/application/locations'); + $response->assertStatus(200); + $response->assertJsonCount(2, 'data'); + $response->assertJsonStructure([ + 'object', + 'data' => [ + ['object', 'attributes' => ['id', 'short', 'long', 'created_at', 'updated_at']], + ['object', 'attributes' => ['id', 'short', 'long', 'created_at', 'updated_at']], + ], + 'meta' => ['pagination' => ['total', 'count', 'per_page', 'current_page', 'total_pages']], + ]); + + $response + ->assertJson([ + 'object' => 'list', + 'data' => [[], []], + 'meta' => [ + 'pagination' => [ + 'total' => 2, + 'count' => 2, + 'per_page' => 50, + 'current_page' => 1, + 'total_pages' => 1, + ], + ], + ]) + ->assertJsonFragment([ + 'object' => 'location', + 'attributes' => [ + 'id' => $locations[0]->id, + 'short' => $locations[0]->short, + 'long' => $locations[0]->long, + 'created_at' => $this->formatTimestamp($locations[0]->created_at), + 'updated_at' => $this->formatTimestamp($locations[0]->updated_at), + ], + ])->assertJsonFragment([ + 'object' => 'location', + 'attributes' => [ + 'id' => $locations[1]->id, + 'short' => $locations[1]->short, + 'long' => $locations[1]->long, + 'created_at' => $this->formatTimestamp($locations[1]->created_at), + 'updated_at' => $this->formatTimestamp($locations[1]->updated_at), + ], + ]); + } + + /** + * Test getting a single location on the API. + */ + public function testGetSingleLocation() + { + $location = factory(Location::class)->create(); + + $response = $this->json('GET', '/api/application/locations/' . $location->id); + $response->assertStatus(200); + $response->assertJsonCount(2); + $response->assertJsonStructure(['object', 'attributes' => ['id', 'short', 'long', 'created_at', 'updated_at']]); + $response->assertJson([ + 'object' => 'location', + 'attributes' => [ + 'id' => $location->id, + 'short' => $location->short, + 'long' => $location->long, + 'created_at' => $this->formatTimestamp($location->created_at), + 'updated_at' => $this->formatTimestamp($location->updated_at), + ], + ], true); + } + + /** + * Test that all of the defined relationships for a location can be loaded successfully. + */ + public function testRelationshipsCanBeLoaded() + { + $location = factory(Location::class)->create(); + $server = $this->createServerModel(['user_id' => $this->getApiUser()->id, 'location_id' => $location->id]); + + $response = $this->json('GET', '/api/application/locations/' . $location->id . '?include=servers,nodes'); + $response->assertStatus(200); + $response->assertJsonCount(2)->assertJsonCount(2, 'attributes.relationships'); + $response->assertJsonStructure([ + 'attributes' => [ + 'relationships' => [ + 'nodes' => ['object', 'data' => [['attributes' => ['id']]]], + 'servers' => ['object', 'data' => [['attributes' => ['id']]]], + ], + ], + ]); + + // Just assert that we see the expected relationship IDs in the response. + $response->assertJson([ + 'attributes' => [ + 'relationships' => [ + 'nodes' => [ + 'object' => 'list', + 'data' => [ + [ + 'object' => 'node', + 'attributes' => $this->getTransformer(NodeTransformer::class)->transform($server->getRelation('node')), + ], + ], + ], + 'servers' => [ + 'object' => 'list', + 'data' => [ + [ + 'object' => 'server', + 'attributes' => $this->getTransformer(ServerTransformer::class)->transform($server), + ], + ], + ], + ], + ], + ]); + } + + /** + * Test that a relationship that an API key does not have permission to access + * cannot be loaded onto the model. + */ + public function testKeyWithoutPermissionCannotLoadRelationship() + { + $this->createNewDefaultApiKey($this->getApiUser(), ['r_nodes' => 0]); + + $location = factory(Location::class)->create(); + factory(Node::class)->create(['location_id' => $location->id]); + + $response = $this->json('GET', '/api/application/locations/' . $location->id . '?include=nodes'); + $response->assertStatus(200); + $response->assertJsonCount(2)->assertJsonCount(1, 'attributes.relationships'); + $response->assertJsonStructure([ + 'attributes' => [ + 'relationships' => [ + 'nodes' => ['object', 'attributes'], + ], + ], + ]); + + // Just assert that we see the expected relationship IDs in the response. + $response->assertJson([ + 'attributes' => [ + 'relationships' => [ + 'nodes' => [ + 'object' => 'null_resource', + 'attributes' => null, + ], + ], + ], + ]); + } + + /** + * Test that a missing location returns a 404 error. + * + * GET /api/application/locations/:id + */ + public function testGetMissingLocation() + { + $response = $this->json('GET', '/api/application/locations/nil'); + $this->assertNotFoundJson($response); + } + + /** + * Test that an authentication error occurs if a key does not have permission + * to access a resource. + */ + public function testErrorReturnedIfNoPermission() + { + $location = factory(Location::class)->create(); + $this->createNewDefaultApiKey($this->getApiUser(), ['r_locations' => 0]); + + $response = $this->json('GET', '/api/application/locations/' . $location->id); + $this->assertAccessDeniedJson($response); + } + + /** + * Test that a location's existence is not exposed unless an API key has permission + * to access the resource. + */ + public function testResourceIsNotExposedWithoutPermissions() + { + $this->createNewDefaultApiKey($this->getApiUser(), ['r_locations' => 0]); + + $response = $this->json('GET', '/api/application/locations/nil'); + $this->assertAccessDeniedJson($response); + } +} diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php new file mode 100644 index 000000000..3c2a1ad4d --- /dev/null +++ b/tests/Integration/IntegrationTestCase.php @@ -0,0 +1,43 @@ +setTimezone(BaseTransformer::RESPONSE_TIMEZONE) + ->toIso8601String(); + } +} diff --git a/tests/Traits/Http/IntegrationJsonRequestAssertions.php b/tests/Traits/Http/IntegrationJsonRequestAssertions.php new file mode 100644 index 000000000..4bcae3076 --- /dev/null +++ b/tests/Traits/Http/IntegrationJsonRequestAssertions.php @@ -0,0 +1,50 @@ +assertStatus(404); + $response->assertJsonStructure(['errors' => [['code', 'status', 'detail']]]); + $response->assertJsonCount(1, 'errors'); + $response->assertJson([ + 'errors' => [ + [ + 'code' => 'NotFoundHttpException', + 'status' => '404', + 'detail' => 'The requested resource does not exist on this server.', + ], + ], + ], true); + } + + /** + * Make assertions about a 403 error returned by the API. + * + * @param \Illuminate\Foundation\Testing\TestResponse $response + */ + public function assertAccessDeniedJson(TestResponse $response) + { + $response->assertStatus(403); + $response->assertJsonStructure(['errors' => [['code', 'status', 'detail']]]); + $response->assertJsonCount(1, 'errors'); + $response->assertJson([ + 'errors' => [ + [ + 'code' => 'AccessDeniedHttpException', + 'status' => '403', + 'detail' => 'This action is unauthorized.', + ], + ], + ], true); + } +} diff --git a/tests/Traits/Integration/CreatesTestModels.php b/tests/Traits/Integration/CreatesTestModels.php new file mode 100644 index 000000000..daa14eeb6 --- /dev/null +++ b/tests/Traits/Integration/CreatesTestModels.php @@ -0,0 +1,77 @@ +app->make(EloquentFactory::class); + + if (isset($attributes['user_id'])) { + $attributes['owner_id'] = $attributes['user_id']; + } + + if (! isset($attributes['owner_id'])) { + $user = $factory->of(User::class)->create(); + $attributes['owner_id'] = $user->id; + } + + if (! isset($attributes['node_id'])) { + if (! isset($attributes['location_id'])) { + $location = $factory->of(Location::class)->create(); + $attributes['location_id'] = $location->id; + } + + $node = $factory->of(Node::class)->create(['location_id' => $attributes['location_id']]); + $attributes['node_id'] = $node->id; + } + + if (! isset($attributes['allocation_id'])) { + $allocation = $factory->of(Allocation::class)->create(['node_id' => $attributes['node_id']]); + $attributes['allocation_id'] = $allocation->id; + } + + if (! isset($attributes['nest_id'])) { + $nest = Nest::with('eggs')->first(); + $attributes['nest_id'] = $nest->id; + + if (! isset($attributes['egg_id'])) { + $attributes['egg_id'] = $nest->getRelation('eggs')->first()->id; + } + } + + if (! isset($attributes['egg_id'])) { + $egg = Egg::where('nest_id', $attributes['nest_id'])->first(); + $attributes['egg_id'] = $egg->id; + } + + unset($attributes['user_id'], $attributes['location_id']); + + $server = $factory->of(Server::class)->create($attributes); + + return Server::with([ + 'location', 'user', 'node', 'allocation', 'nest', 'egg', + ])->findOrFail($server->id); + } +} diff --git a/tests/Unit/Http/Middleware/AdminAuthenticateTest.php b/tests/Unit/Http/Middleware/AdminAuthenticateTest.php index eee9a6969..c8bba572e 100644 --- a/tests/Unit/Http/Middleware/AdminAuthenticateTest.php +++ b/tests/Unit/Http/Middleware/AdminAuthenticateTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\Http\Middleware; +use Illuminate\Http\Request; use Pterodactyl\Models\User; use Pterodactyl\Http\Middleware\AdminAuthenticate; diff --git a/tests/Unit/Http/Middleware/DaemonAuthenticateTest.php b/tests/Unit/Http/Middleware/DaemonAuthenticateTest.php index 861e2724c..1b3deb570 100644 --- a/tests/Unit/Http/Middleware/DaemonAuthenticateTest.php +++ b/tests/Unit/Http/Middleware/DaemonAuthenticateTest.php @@ -29,12 +29,12 @@ class DaemonAuthenticateTest extends MiddlewareTestCase */ public function testValidDaemonConnection() { + $this->setRequestRouteName('random.name'); $node = factory(Node::class)->make(); - $this->request->shouldReceive('route->getName')->withNoArgs()->once()->andReturn('random.name'); - $this->request->shouldReceive('header')->with('X-Access-Node')->twice()->andReturn($node->uuid); + $this->request->shouldReceive('header')->with('X-Access-Node')->twice()->andReturn($node->daemonSecret); - $this->repository->shouldReceive('findFirstWhere')->with(['daemonSecret' => $node->uuid])->once()->andReturn($node); + $this->repository->shouldReceive('findFirstWhere')->with(['daemonSecret' => $node->daemonSecret])->once()->andReturn($node); $this->getMiddleware()->handle($this->request, $this->getClosureAssertions()); $this->assertRequestHasAttribute('node'); @@ -46,7 +46,7 @@ class DaemonAuthenticateTest extends MiddlewareTestCase */ public function testIgnoredRouteShouldContinue() { - $this->request->shouldReceive('route->getName')->withNoArgs()->once()->andReturn('daemon.configuration'); + $this->setRequestRouteName('daemon.configuration'); $this->getMiddleware()->handle($this->request, $this->getClosureAssertions()); $this->assertRequestMissingAttribute('node'); @@ -59,7 +59,8 @@ class DaemonAuthenticateTest extends MiddlewareTestCase */ public function testExceptionThrownIfMissingHeader() { - $this->request->shouldReceive('route->getName')->withNoArgs()->once()->andReturn('random.name'); + $this->setRequestRouteName('random.name'); + $this->request->shouldReceive('header')->with('X-Access-Node')->once()->andReturn(false); $this->getMiddleware()->handle($this->request, $this->getClosureAssertions());