From 89db9390df4492691b917b3c803e5d3f79f12462 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sun, 4 Mar 2018 15:21:54 -0600 Subject: [PATCH 1/8] Fix broken migration rollbacks --- database/migrations/2017_04_15_125021_UpgradeTaskSystem.php | 4 ++-- ...017_07_24_194433_DeleteTaskWhenParentServerIsDeleted.php | 6 +----- .../2018_01_11_213943_AddApiKeyPermissionColumns.php | 2 +- ..._134254_ensure_unique_allocation_id_on_servers_table.php | 3 +++ 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/database/migrations/2017_04_15_125021_UpgradeTaskSystem.php b/database/migrations/2017_04_15_125021_UpgradeTaskSystem.php index dc58df4d9..d069e1ba1 100644 --- a/database/migrations/2017_04_15_125021_UpgradeTaskSystem.php +++ b/database/migrations/2017_04_15_125021_UpgradeTaskSystem.php @@ -36,8 +36,8 @@ class UpgradeTaskSystem extends Migration public function down() { Schema::table('tasks', function (Blueprint $table) { - $table->dropForeign(['server_id']); - $table->dropForeign(['user_id']); +// $table->dropForeign(['server_id']); +// $table->dropForeign(['user_id']); $table->renameColumn('server_id', 'server'); $table->dropColumn('user_id'); diff --git a/database/migrations/2017_07_24_194433_DeleteTaskWhenParentServerIsDeleted.php b/database/migrations/2017_07_24_194433_DeleteTaskWhenParentServerIsDeleted.php index 88e2e0135..042e7564c 100644 --- a/database/migrations/2017_07_24_194433_DeleteTaskWhenParentServerIsDeleted.php +++ b/database/migrations/2017_07_24_194433_DeleteTaskWhenParentServerIsDeleted.php @@ -23,10 +23,6 @@ class DeleteTaskWhenParentServerIsDeleted extends Migration */ public function down() { - Schema::table('tasks', function (Blueprint $table) { - $table->dropForeign(['server_id']); - - $table->foreign('server_id')->references('id')->on('servers'); - }); + // } } diff --git a/database/migrations/2018_01_11_213943_AddApiKeyPermissionColumns.php b/database/migrations/2018_01_11_213943_AddApiKeyPermissionColumns.php index cd6b60e10..adc6d2648 100644 --- a/database/migrations/2018_01_11_213943_AddApiKeyPermissionColumns.php +++ b/database/migrations/2018_01_11_213943_AddApiKeyPermissionColumns.php @@ -41,7 +41,7 @@ class AddApiKeyPermissionColumns extends Migration $table->unsignedInteger('key_id'); $table->string('permission'); - $table->foreign('key_id')->references('id')->on('keys')->onDelete('cascade'); + $table->foreign('key_id')->references('id')->on('api_keys')->onDelete('cascade'); }); Schema::table('api_keys', function (Blueprint $table) { diff --git a/database/migrations/2018_02_17_134254_ensure_unique_allocation_id_on_servers_table.php b/database/migrations/2018_02_17_134254_ensure_unique_allocation_id_on_servers_table.php index fcf6b4fe3..bff7bbfb0 100644 --- a/database/migrations/2018_02_17_134254_ensure_unique_allocation_id_on_servers_table.php +++ b/database/migrations/2018_02_17_134254_ensure_unique_allocation_id_on_servers_table.php @@ -26,7 +26,10 @@ class EnsureUniqueAllocationIdOnServersTable extends Migration public function down() { Schema::table('servers', function (Blueprint $table) { + $table->dropForeign(['allocation_id']); $table->dropUnique(['allocation_id']); + + $table->foreign('allocation_id')->references('id')->on('allocations'); }); } } From e2aa01c9cc7389265694b95a24cecf7410fbe4ea Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sun, 4 Mar 2018 16:30:16 -0600 Subject: [PATCH 2/8] First go at integration tests --- .env.travis | 8 +- .travis.yml | 4 +- .../Api/Application/BaseTransformer.php | 7 +- .../Api/Application/LocationTransformer.php | 12 +- bootstrap/tests.php | 28 +++ composer.json | 1 + config/database.php | 22 ++ database/factories/ModelFactory.php | 18 +- phpunit.xml | 7 +- .../ApplicationApiIntegrationTestCase.php | 142 ++++++++++++ .../Location/LocationControllerTest.php | 207 ++++++++++++++++++ tests/Integration/IntegrationTestCase.php | 43 ++++ .../Http/IntegrationJsonRequestAssertions.php | 50 +++++ .../Traits/Integration/CreatesTestModels.php | 77 +++++++ .../Http/Middleware/AdminAuthenticateTest.php | 1 + .../Middleware/DaemonAuthenticateTest.php | 11 +- 16 files changed, 610 insertions(+), 28 deletions(-) create mode 100644 bootstrap/tests.php create mode 100644 tests/Integration/Api/Application/ApplicationApiIntegrationTestCase.php create mode 100644 tests/Integration/Api/Application/Location/LocationControllerTest.php create mode 100644 tests/Integration/IntegrationTestCase.php create mode 100644 tests/Traits/Http/IntegrationJsonRequestAssertions.php create mode 100644 tests/Traits/Integration/CreatesTestModels.php 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()); From 9905358bc3fdc217d42aa8bd101b79296bdb2039 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sun, 4 Mar 2018 16:56:05 -0600 Subject: [PATCH 3/8] Add integration tests for remote user endpoint --- database/factories/ModelFactory.php | 2 +- .../Users/ExternalUserControllerTest.php | 80 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 tests/Integration/Api/Application/Users/ExternalUserControllerTest.php diff --git a/database/factories/ModelFactory.php b/database/factories/ModelFactory.php index 28f55777e..785588ebd 100644 --- a/database/factories/ModelFactory.php +++ b/database/factories/ModelFactory.php @@ -47,7 +47,7 @@ $factory->define(Pterodactyl\Models\User::class, function (Faker $faker) { return [ 'id' => $faker->unique()->randomNumber(), - 'external_id' => null, + 'external_id' => $faker->unique()->isbn10, 'uuid' => $faker->uuid, 'username' => $faker->userName, 'email' => $faker->safeEmail, diff --git a/tests/Integration/Api/Application/Users/ExternalUserControllerTest.php b/tests/Integration/Api/Application/Users/ExternalUserControllerTest.php new file mode 100644 index 000000000..f4c7153b2 --- /dev/null +++ b/tests/Integration/Api/Application/Users/ExternalUserControllerTest.php @@ -0,0 +1,80 @@ +create(); + + $response = $this->json('GET', '/api/application/users/external/' . $user->external_id); + $response->assertStatus(200); + $response->assertJsonCount(2); + $response->assertJsonStructure([ + 'object', + 'attributes' => [ + 'id', 'external_id', 'uuid', 'username', 'email', 'first_name', 'last_name', + 'language', 'root_admin', '2fa', 'created_at', 'updated_at', + ], + ]); + + $response->assertJson([ + 'object' => 'user', + 'attributes' => [ + 'id' => $user->id, + 'external_id' => $user->external_id, + 'uuid' => $user->uuid, + 'username' => $user->username, + 'email' => $user->email, + 'first_name' => $user->name_first, + 'last_name' => $user->name_last, + 'language' => $user->language, + 'root_admin' => (bool) $user->root_admin, + '2fa' => (bool) $user->totp_enabled, + 'created_at' => $this->formatTimestamp($user->created_at), + 'updated_at' => $this->formatTimestamp($user->updated_at), + ], + ], true); + } + + /** + * Test that an invalid external ID returns a 404 error. + */ + public function testGetMissingLocation() + { + $response = $this->json('GET', '/api/application/users/external/nil'); + $this->assertNotFoundJson($response); + } + + /** + * Test that an authentication error occurs if a key does not have permission + * to access a resource. + */ + public function testErrorReturnedIfNoPermission() + { + $user = factory(User::class)->create(); + $this->createNewDefaultApiKey($this->getApiUser(), ['r_users' => 0]); + + $response = $this->json('GET', '/api/application/users/external/' . $user->external_id); + $this->assertAccessDeniedJson($response); + } + + /** + * Test that a users's existence is not exposed unless an API key has permission + * to access the resource. + */ + public function testResourceIsNotExposedWithoutPermissions() + { + $this->createNewDefaultApiKey($this->getApiUser(), ['r_users' => 0]); + + $response = $this->json('GET', '/api/application/users/external/nil'); + $this->assertAccessDeniedJson($response); + } +} From bbbab4bf81a4add03f0ce5997cd58b2b6791af0c Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sun, 4 Mar 2018 22:21:23 -0600 Subject: [PATCH 4/8] Handle error codes from custom rules better --- app/Exceptions/Handler.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 500f2d0ca..9de030e2d 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -16,6 +16,13 @@ use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; class Handler extends ExceptionHandler { + /** + * Laravel's validation parser formats custom rules using the class name + * resulting in some weird rule names. This string will be parsed out and + * replaced with 'p_' in the response code. + */ + private const PTERODACTYL_RULE_STRING = 'pterodactyl\_rules\_'; + /** * A list of the exception types that should not be reported. * @@ -156,7 +163,9 @@ class Handler extends ExceptionHandler $response = []; foreach ($errors as $key => $error) { $response[] = [ - 'code' => array_get($codes, str_replace('.', '_', $field) . '.' . $key), + 'code' => str_replace(self::PTERODACTYL_RULE_STRING, 'p_', array_get( + $codes, str_replace('.', '_', $field) . '.' . $key + )), 'detail' => $error, 'source' => ['field' => $field], ]; From e8ea218f2097109cb977dceefdc07bcec460c200 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sun, 4 Mar 2018 22:35:57 -0600 Subject: [PATCH 5/8] Add integration test for remaining application api user endpoints --- .../Api/Application/UserTransformer.php | 2 + database/factories/ModelFactory.php | 5 +- .../Location/LocationControllerTest.php | 23 +- .../Users/ExternalUserControllerTest.php | 13 +- .../Application/Users/UserControllerTest.php | 327 ++++++++++++++++++ .../Http/IntegrationJsonRequestAssertions.php | 5 +- 6 files changed, 355 insertions(+), 20 deletions(-) create mode 100644 tests/Integration/Api/Application/Users/UserControllerTest.php diff --git a/app/Transformers/Api/Application/UserTransformer.php b/app/Transformers/Api/Application/UserTransformer.php index 56749deaa..2e55bbb1b 100644 --- a/app/Transformers/Api/Application/UserTransformer.php +++ b/app/Transformers/Api/Application/UserTransformer.php @@ -53,6 +53,8 @@ class UserTransformer extends BaseTransformer * * @param \Pterodactyl\Models\User $user * @return \League\Fractal\Resource\Collection|\League\Fractal\Resource\NullResource + * + * @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException */ public function includeServers(User $user) { diff --git a/database/factories/ModelFactory.php b/database/factories/ModelFactory.php index 785588ebd..8866fa555 100644 --- a/database/factories/ModelFactory.php +++ b/database/factories/ModelFactory.php @@ -1,5 +1,6 @@ define(Pterodactyl\Models\User::class, function (Faker $faker) { 'language' => 'en', 'root_admin' => false, 'use_totp' => false, + 'created_at' => Chronos::now(), + 'updated_at' => Chronos::now(), ]; }); @@ -69,7 +72,7 @@ $factory->state(Pterodactyl\Models\User::class, 'admin', function () { $factory->define(Pterodactyl\Models\Location::class, function (Faker $faker) { return [ 'id' => $faker->unique()->randomNumber(), - 'short' => $faker->domainWord, + 'short' => $faker->unique()->domainWord, 'long' => $faker->catchPhrase, ]; }); diff --git a/tests/Integration/Api/Application/Location/LocationControllerTest.php b/tests/Integration/Api/Application/Location/LocationControllerTest.php index db315211b..b43f745d7 100644 --- a/tests/Integration/Api/Application/Location/LocationControllerTest.php +++ b/tests/Integration/Api/Application/Location/LocationControllerTest.php @@ -3,6 +3,7 @@ namespace Pterodactyl\Tests\Integration\Api\Application\Location; use Pterodactyl\Models\Node; +use Illuminate\Http\Response; use Pterodactyl\Models\Location; use Pterodactyl\Transformers\Api\Application\NodeTransformer; use Pterodactyl\Transformers\Api\Application\ServerTransformer; @@ -17,8 +18,8 @@ class LocationControllerTest extends ApplicationApiIntegrationTestCase { $locations = factory(Location::class)->times(2)->create(); - $response = $this->json('GET', '/api/application/locations'); - $response->assertStatus(200); + $response = $this->getJson('/api/application/locations'); + $response->assertStatus(Response::HTTP_OK); $response->assertJsonCount(2, 'data'); $response->assertJsonStructure([ 'object', @@ -71,8 +72,8 @@ class LocationControllerTest extends ApplicationApiIntegrationTestCase { $location = factory(Location::class)->create(); - $response = $this->json('GET', '/api/application/locations/' . $location->id); - $response->assertStatus(200); + $response = $this->getJson('/api/application/locations/' . $location->id); + $response->assertStatus(Response::HTTP_OK); $response->assertJsonCount(2); $response->assertJsonStructure(['object', 'attributes' => ['id', 'short', 'long', 'created_at', 'updated_at']]); $response->assertJson([ @@ -95,8 +96,8 @@ class LocationControllerTest extends ApplicationApiIntegrationTestCase $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 = $this->getJson('/api/application/locations/' . $location->id . '?include=servers,nodes'); + $response->assertStatus(Response::HTTP_OK); $response->assertJsonCount(2)->assertJsonCount(2, 'attributes.relationships'); $response->assertJsonStructure([ 'attributes' => [ @@ -145,8 +146,8 @@ class LocationControllerTest extends ApplicationApiIntegrationTestCase $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 = $this->getJson('/api/application/locations/' . $location->id . '?include=nodes'); + $response->assertStatus(Response::HTTP_OK); $response->assertJsonCount(2)->assertJsonCount(1, 'attributes.relationships'); $response->assertJsonStructure([ 'attributes' => [ @@ -176,7 +177,7 @@ class LocationControllerTest extends ApplicationApiIntegrationTestCase */ public function testGetMissingLocation() { - $response = $this->json('GET', '/api/application/locations/nil'); + $response = $this->getJson('/api/application/locations/nil'); $this->assertNotFoundJson($response); } @@ -189,7 +190,7 @@ class LocationControllerTest extends ApplicationApiIntegrationTestCase $location = factory(Location::class)->create(); $this->createNewDefaultApiKey($this->getApiUser(), ['r_locations' => 0]); - $response = $this->json('GET', '/api/application/locations/' . $location->id); + $response = $this->getJson('/api/application/locations/' . $location->id); $this->assertAccessDeniedJson($response); } @@ -201,7 +202,7 @@ class LocationControllerTest extends ApplicationApiIntegrationTestCase { $this->createNewDefaultApiKey($this->getApiUser(), ['r_locations' => 0]); - $response = $this->json('GET', '/api/application/locations/nil'); + $response = $this->getJson('/api/application/locations/nil'); $this->assertAccessDeniedJson($response); } } diff --git a/tests/Integration/Api/Application/Users/ExternalUserControllerTest.php b/tests/Integration/Api/Application/Users/ExternalUserControllerTest.php index f4c7153b2..f28e15a93 100644 --- a/tests/Integration/Api/Application/Users/ExternalUserControllerTest.php +++ b/tests/Integration/Api/Application/Users/ExternalUserControllerTest.php @@ -3,6 +3,7 @@ namespace Pterodactyl\Tests\Integration\Api\Application\Users; use Pterodactyl\Models\User; +use Illuminate\Http\Response; use Pterodactyl\Tests\Integration\Api\Application\ApplicationApiIntegrationTestCase; class ExternalUserControllerTest extends ApplicationApiIntegrationTestCase @@ -14,8 +15,8 @@ class ExternalUserControllerTest extends ApplicationApiIntegrationTestCase { $user = factory(User::class)->create(); - $response = $this->json('GET', '/api/application/users/external/' . $user->external_id); - $response->assertStatus(200); + $response = $this->getJson('/api/application/users/external/' . $user->external_id); + $response->assertStatus(Response::HTTP_OK); $response->assertJsonCount(2); $response->assertJsonStructure([ 'object', @@ -47,9 +48,9 @@ class ExternalUserControllerTest extends ApplicationApiIntegrationTestCase /** * Test that an invalid external ID returns a 404 error. */ - public function testGetMissingLocation() + public function testGetMissingUser() { - $response = $this->json('GET', '/api/application/users/external/nil'); + $response = $this->getJson('/api/application/users/external/nil'); $this->assertNotFoundJson($response); } @@ -62,7 +63,7 @@ class ExternalUserControllerTest extends ApplicationApiIntegrationTestCase $user = factory(User::class)->create(); $this->createNewDefaultApiKey($this->getApiUser(), ['r_users' => 0]); - $response = $this->json('GET', '/api/application/users/external/' . $user->external_id); + $response = $this->getJson('/api/application/users/external/' . $user->external_id); $this->assertAccessDeniedJson($response); } @@ -74,7 +75,7 @@ class ExternalUserControllerTest extends ApplicationApiIntegrationTestCase { $this->createNewDefaultApiKey($this->getApiUser(), ['r_users' => 0]); - $response = $this->json('GET', '/api/application/users/external/nil'); + $response = $this->getJson('/api/application/users/external/nil'); $this->assertAccessDeniedJson($response); } } diff --git a/tests/Integration/Api/Application/Users/UserControllerTest.php b/tests/Integration/Api/Application/Users/UserControllerTest.php new file mode 100644 index 000000000..704457e4e --- /dev/null +++ b/tests/Integration/Api/Application/Users/UserControllerTest.php @@ -0,0 +1,327 @@ +create(); + + $response = $this->getJson('/api/application/users'); + $response->assertStatus(Response::HTTP_OK); + $response->assertJsonCount(2, 'data'); + $response->assertJsonStructure([ + 'object', + 'data' => [ + ['object', 'attributes' => ['id', 'external_id', 'uuid', 'username', 'email', 'first_name', 'last_name', 'language', 'root_admin', '2fa', 'created_at', 'updated_at']], + ['object', 'attributes' => ['id', 'external_id', 'uuid', 'username', 'email', 'first_name', 'last_name', 'language', 'root_admin', '2fa', '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' => 'user', + 'attributes' => [ + 'id' => $this->getApiUser()->id, + 'external_id' => $this->getApiUser()->external_id, + 'uuid' => $this->getApiUser()->uuid, + 'username' => $this->getApiUser()->username, + 'email' => $this->getApiUser()->email, + 'first_name' => $this->getApiUser()->name_first, + 'last_name' => $this->getApiUser()->name_last, + 'language' => $this->getApiUser()->language, + 'root_admin' => (bool) $this->getApiUser()->root_admin, + '2fa' => (bool) $this->getApiUser()->totp_enabled, + 'created_at' => $this->formatTimestamp($this->getApiUser()->created_at), + 'updated_at' => $this->formatTimestamp($this->getApiUser()->updated_at), + ], + ]) + ->assertJsonFragment([ + 'object' => 'user', + 'attributes' => [ + 'id' => $user->id, + 'external_id' => $user->external_id, + 'uuid' => $user->uuid, + 'username' => $user->username, + 'email' => $user->email, + 'first_name' => $user->name_first, + 'last_name' => $user->name_last, + 'language' => $user->language, + 'root_admin' => (bool) $user->root_admin, + '2fa' => (bool) $user->totp_enabled, + 'created_at' => $this->formatTimestamp($user->created_at), + 'updated_at' => $this->formatTimestamp($user->updated_at), + ], + ]); + } + + /** + * Test getting a single user. + */ + public function testGetSingleUser() + { + $user = factory(User::class)->create(); + + $response = $this->getJson('/api/application/users/' . $user->id); + $response->assertStatus(Response::HTTP_OK); + $response->assertJsonCount(2); + $response->assertJsonStructure([ + 'object', + 'attributes' => ['id', 'external_id', 'uuid', 'username', 'email', 'first_name', 'last_name', 'language', 'root_admin', '2fa', 'created_at', 'updated_at'], + ]); + + $response->assertJson([ + 'object' => 'user', + 'attributes' => [ + 'id' => $user->id, + 'external_id' => $user->external_id, + 'uuid' => $user->uuid, + 'username' => $user->username, + 'email' => $user->email, + 'first_name' => $user->name_first, + 'last_name' => $user->name_last, + 'language' => $user->language, + 'root_admin' => (bool) $user->root_admin, + '2fa' => (bool) $user->totp_enabled, + 'created_at' => $this->formatTimestamp($user->created_at), + 'updated_at' => $this->formatTimestamp($user->updated_at), + ], + ]); + } + + /** + * Test that the correct relationships can be loaded. + */ + public function testRelationshipsCanBeLoaded() + { + $user = factory(User::class)->create(); + $server = $this->createServerModel(['user_id' => $user->id]); + + $response = $this->getJson('/api/application/users/' . $user->id . '?include=servers'); + $response->assertStatus(Response::HTTP_OK); + $response->assertJsonCount(2); + $response->assertJsonStructure([ + 'object', + 'attributes' => [ + 'id', 'external_id', 'uuid', 'username', 'email', 'first_name', 'last_name', 'language', 'root_admin', '2fa', 'created_at', 'updated_at', + 'relationships' => ['servers' => ['object', 'data' => [['object', 'attributes' => []]]]], + ], + ]); + + $response->assertJsonFragment([ + 'object' => 'list', + 'data' => [ + [ + 'object' => 'server', + 'attributes' => $this->getTransformer(ServerTransformer::class)->transform($server), + ], + ], + ]); + } + + /** + * Test that attempting to load a relationship that the key does not have permission + * for returns a null object. + */ + public function testKeyWithoutPermissionCannotLoadRelationship() + { + $this->createNewDefaultApiKey($this->getApiUser(), ['r_servers' => 0]); + + $user = factory(User::class)->create(); + $this->createServerModel(['user_id' => $user->id]); + + $response = $this->getJson('/api/application/users/' . $user->id . '?include=servers'); + $response->assertStatus(Response::HTTP_OK); + $response->assertJsonCount(2)->assertJsonCount(1, 'attributes.relationships'); + $response->assertJsonStructure([ + 'attributes' => [ + 'relationships' => [ + 'servers' => ['object', 'attributes'], + ], + ], + ]); + + // Just assert that we see the expected relationship IDs in the response. + $response->assertJson([ + 'attributes' => [ + 'relationships' => [ + 'servers' => [ + 'object' => 'null_resource', + 'attributes' => null, + ], + ], + ], + ]); + } + + /** + * Test that an invalid external ID returns a 404 error. + */ + public function testGetMissingUser() + { + $response = $this->getJson('/api/application/users/nil'); + $this->assertNotFoundJson($response); + } + + /** + * Test that an authentication error occurs if a key does not have permission + * to access a resource. + */ + public function testErrorReturnedIfNoPermission() + { + $user = factory(User::class)->create(); + $this->createNewDefaultApiKey($this->getApiUser(), ['r_users' => 0]); + + $response = $this->getJson('/api/application/users/' . $user->id); + $this->assertAccessDeniedJson($response); + } + + /** + * Test that a users's existence is not exposed unless an API key has permission + * to access the resource. + */ + public function testResourceIsNotExposedWithoutPermissions() + { + $this->createNewDefaultApiKey($this->getApiUser(), ['r_users' => 0]); + + $response = $this->getJson('/api/application/users/nil'); + $this->assertAccessDeniedJson($response); + } + + /** + * Test that a user can be created. + */ + public function testCreateUser() + { + $response = $this->postJson('/api/application/users', [ + 'username' => 'testuser', + 'email' => 'test@example.com', + 'first_name' => 'Test', + 'last_name' => 'User', + ]); + + $response->assertStatus(Response::HTTP_CREATED); + $response->assertJsonCount(3); + $response->assertJsonStructure([ + 'object', + 'attributes' => ['id', 'external_id', 'uuid', 'username', 'email', 'first_name', 'last_name', 'language', 'root_admin', '2fa', 'created_at', 'updated_at'], + 'meta' => ['resource'], + ]); + + $this->assertDatabaseHas('users', ['username' => 'testuser', 'email' => 'test@example.com']); + + $user = User::where('username', 'testuser')->first(); + $response->assertJson([ + 'object' => 'user', + 'attributes' => $this->getTransformer(UserTransformer::class)->transform($user), + 'meta' => [ + 'resource' => route('api.application.users.view', $user->id), + ], + ], true); + } + + /** + * Test that a user can be updated. + */ + public function testUpdateUser() + { + $user = factory(User::class)->create(); + + $response = $this->patchJson('/api/application/users/' . $user->id, [ + 'username' => 'new.test.name', + 'email' => 'new@emailtest.com', + 'first_name' => $user->name_first, + 'last_name' => $user->name_last, + ]); + $response->assertStatus(Response::HTTP_OK); + $response->assertJsonCount(2); + $response->assertJsonStructure([ + 'object', + 'attributes' => ['id', 'external_id', 'uuid', 'username', 'email', 'first_name', 'last_name', 'language', 'root_admin', '2fa', 'created_at', 'updated_at'], + ]); + + $this->assertDatabaseHas('users', ['username' => 'new.test.name', 'email' => 'new@emailtest.com']); + $user = $user->fresh(); + + $response->assertJson([ + 'object' => 'user', + 'attributes' => $this->getTransformer(UserTransformer::class)->transform($user), + ]); + } + + /** + * Test that a user can be deleted from the database. + */ + public function testDeleteUser() + { + $user = factory(User::class)->create(); + $this->assertDatabaseHas('users', ['id' => $user->id]); + + $response = $this->delete('/api/application/users/' . $user->id); + $response->assertStatus(Response::HTTP_NO_CONTENT); + + $this->assertDatabaseMissing('users', ['id' => $user->id]); + } + + /** + * Test that an API key without write permissions cannot create, update, or + * delete a user model. + * + * @param string $method + * @param string $url + * + * @dataProvider userWriteEndpointsDataProvider + */ + public function testApiKeyWithoutWritePermissions(string $method, string $url) + { + $this->createNewDefaultApiKey($this->getApiUser(), ['r_users' => AdminAcl::READ]); + + if (str_contains($url, '{id}')) { + $user = factory(User::class)->create(); + $url = str_replace('{id}', $user->id, $url); + } + + $response = $this->$method($url); + $this->assertAccessDeniedJson($response); + } + + /** + * Endpoints that should return a 403 error when the key does not have write + * permissions for user management. + * + * @return array + */ + public function userWriteEndpointsDataProvider(): array + { + return [ + ['postJson', '/api/application/users'], + ['patchJson', '/api/application/users/{id}'], + ['delete', '/api/application/users/{id}'], + ]; + } +} diff --git a/tests/Traits/Http/IntegrationJsonRequestAssertions.php b/tests/Traits/Http/IntegrationJsonRequestAssertions.php index 4bcae3076..aca9233f0 100644 --- a/tests/Traits/Http/IntegrationJsonRequestAssertions.php +++ b/tests/Traits/Http/IntegrationJsonRequestAssertions.php @@ -2,6 +2,7 @@ namespace Tests\Traits; +use Illuminate\Http\Response; use Illuminate\Foundation\Testing\TestResponse; trait IntegrationJsonRequestAssertions @@ -13,7 +14,7 @@ trait IntegrationJsonRequestAssertions */ public function assertNotFoundJson(TestResponse $response) { - $response->assertStatus(404); + $response->assertStatus(Response::HTTP_NOT_FOUND); $response->assertJsonStructure(['errors' => [['code', 'status', 'detail']]]); $response->assertJsonCount(1, 'errors'); $response->assertJson([ @@ -34,7 +35,7 @@ trait IntegrationJsonRequestAssertions */ public function assertAccessDeniedJson(TestResponse $response) { - $response->assertStatus(403); + $response->assertStatus(Response::HTTP_FORBIDDEN); $response->assertJsonStructure(['errors' => [['code', 'status', 'detail']]]); $response->assertJsonCount(1, 'errors'); $response->assertJson([ From ac9f83a8fe33eeb1c2d74b14c7da07d264e650e4 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sun, 4 Mar 2018 22:42:33 -0600 Subject: [PATCH 6/8] Fix test to run with new bootstrapping --- .../Helpers/TemporaryPasswordService.php | 18 +++---------- phpunit.xml | 4 +-- .../Helpers/TemporaryPasswordServiceTest.php | 26 +++---------------- 3 files changed, 10 insertions(+), 38 deletions(-) diff --git a/app/Services/Helpers/TemporaryPasswordService.php b/app/Services/Helpers/TemporaryPasswordService.php index 90a65b8c8..c9ccfa5df 100644 --- a/app/Services/Helpers/TemporaryPasswordService.php +++ b/app/Services/Helpers/TemporaryPasswordService.php @@ -9,19 +9,14 @@ namespace Pterodactyl\Services\Helpers; +use Ramsey\Uuid\Uuid; use Illuminate\Contracts\Hashing\Hasher; use Illuminate\Database\ConnectionInterface; -use Illuminate\Contracts\Config\Repository as ConfigRepository; class TemporaryPasswordService { const HMAC_ALGO = 'sha256'; - /** - * @var \Illuminate\Contracts\Config\Repository - */ - protected $config; - /** * @var \Illuminate\Database\ConnectionInterface */ @@ -35,16 +30,11 @@ class TemporaryPasswordService /** * TemporaryPasswordService constructor. * - * @param \Illuminate\Contracts\Config\Repository $config * @param \Illuminate\Database\ConnectionInterface $connection * @param \Illuminate\Contracts\Hashing\Hasher $hasher */ - public function __construct( - ConfigRepository $config, - ConnectionInterface $connection, - Hasher $hasher - ) { - $this->config = $config; + public function __construct(ConnectionInterface $connection, Hasher $hasher) + { $this->connection = $connection; $this->hasher = $hasher; } @@ -57,7 +47,7 @@ class TemporaryPasswordService */ public function handle($email) { - $token = hash_hmac(self::HMAC_ALGO, str_random(40), $this->config->get('app.key')); + $token = hash_hmac(self::HMAC_ALGO, Uuid::uuid4()->toString(), config('app.key')); $this->connection->table('password_resets')->insert([ 'email' => $email, diff --git a/phpunit.xml b/phpunit.xml index 26b662c91..0ceb3db26 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -10,8 +10,8 @@ processIsolation="false" stopOnFailure="false"> - - ./tests/Feature + + ./tests/Integration ./tests/Unit diff --git a/tests/Unit/Services/Helpers/TemporaryPasswordServiceTest.php b/tests/Unit/Services/Helpers/TemporaryPasswordServiceTest.php index f0bcf253e..2d8b6dbe5 100644 --- a/tests/Unit/Services/Helpers/TemporaryPasswordServiceTest.php +++ b/tests/Unit/Services/Helpers/TemporaryPasswordServiceTest.php @@ -1,30 +1,17 @@ . - * - * This software is licensed under the terms of the MIT license. - * https://opensource.org/licenses/MIT - */ namespace Tests\Unit\Services\Helpers; use Mockery as m; use Tests\TestCase; -use phpmock\phpunit\PHPMock; +use Tests\Traits\MocksUuids; use Illuminate\Contracts\Hashing\Hasher; -use Illuminate\Contracts\Config\Repository; use Illuminate\Database\ConnectionInterface; use Pterodactyl\Services\Helpers\TemporaryPasswordService; class TemporaryPasswordServiceTest extends TestCase { - use PHPMock; - - /** - * @var \Illuminate\Contracts\Config\Repository|\Mockery\Mock - */ - protected $config; + use MocksUuids; /** * @var \Illuminate\Database\ConnectionInterface|\Mockery\Mock @@ -48,11 +35,10 @@ class TemporaryPasswordServiceTest extends TestCase { parent::setUp(); - $this->config = m::mock(Repository::class); $this->connection = m::mock(ConnectionInterface::class); $this->hasher = m::mock(Hasher::class); - $this->service = new TemporaryPasswordService($this->config, $this->connection, $this->hasher); + $this->service = new TemporaryPasswordService($this->connection, $this->hasher); } /** @@ -60,11 +46,7 @@ class TemporaryPasswordServiceTest extends TestCase */ public function testTemporaryPasswordIsStored() { - $this->getFunctionMock('\\Pterodactyl\\Services\\Helpers', 'str_random') - ->expects($this->once())->with(40)->willReturn('random_string'); - - $this->config->shouldReceive('get')->with('app.key')->once()->andReturn('123456'); - $token = hash_hmac(TemporaryPasswordService::HMAC_ALGO, 'random_string', '123456'); + $token = hash_hmac(TemporaryPasswordService::HMAC_ALGO, $this->getKnownUuid(), config('app.key')); $this->hasher->shouldReceive('make')->with($token)->once()->andReturn('hashed_token'); $this->connection->shouldReceive('table')->with('password_resets')->once()->andReturnSelf(); From 6eaab29a929e761a3e188a6c4d788f9070692816 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Wed, 21 Mar 2018 22:28:01 -0500 Subject: [PATCH 7/8] Fix path to autoload file --- bootstrap/tests.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bootstrap/tests.php b/bootstrap/tests.php index 4e3a7e28a..6ddb47f70 100644 --- a/bootstrap/tests.php +++ b/bootstrap/tests.php @@ -3,7 +3,7 @@ use Illuminate\Contracts\Console\Kernel; use Symfony\Component\Console\Output\ConsoleOutput; -require __DIR__ . '/autoload.php'; +require __DIR__ . '/../vendor/autoload.php'; $app = require __DIR__ . '/app.php'; From 565c5ddc523cccac67424b741667de3df3ec91a5 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sun, 25 Mar 2018 17:41:36 -0500 Subject: [PATCH 8/8] Add integration tests for nests --- .../Api/Application/EggTransformer.php | 6 +- .../Api/Application/NestTransformer.php | 22 +++ .../Application/Nests/EggControllerTest.php | 149 ++++++++++++++++++ .../Application/Nests/NestControllerTest.php | 143 +++++++++++++++++ tests/Integration/IntegrationTestCase.php | 3 + 5 files changed, 320 insertions(+), 3 deletions(-) create mode 100644 tests/Integration/Api/Application/Nests/EggControllerTest.php create mode 100644 tests/Integration/Api/Application/Nests/NestControllerTest.php diff --git a/app/Transformers/Api/Application/EggTransformer.php b/app/Transformers/Api/Application/EggTransformer.php index b73500bb8..baaf1a154 100644 --- a/app/Transformers/Api/Application/EggTransformer.php +++ b/app/Transformers/Api/Application/EggTransformer.php @@ -46,10 +46,10 @@ class EggTransformer extends BaseTransformer 'description' => $model->description, 'docker_image' => $model->docker_image, 'config' => [ - 'files' => json_decode($model->config_files), - 'startup' => json_decode($model->config_startup), + 'files' => json_decode($model->config_files, true), + 'startup' => json_decode($model->config_startup, true), 'stop' => $model->config_stop, - 'logs' => json_decode($model->config_logs), + 'logs' => json_decode($model->config_logs, true), 'extends' => $model->config_from, ], 'startup' => $model->startup, diff --git a/app/Transformers/Api/Application/NestTransformer.php b/app/Transformers/Api/Application/NestTransformer.php index 9517af61d..80154a682 100644 --- a/app/Transformers/Api/Application/NestTransformer.php +++ b/app/Transformers/Api/Application/NestTransformer.php @@ -4,6 +4,7 @@ namespace Pterodactyl\Transformers\Api\Application; use Pterodactyl\Models\Egg; use Pterodactyl\Models\Nest; +use Pterodactyl\Models\Server; use Pterodactyl\Services\Acl\Api\AdminAcl; class NestTransformer extends BaseTransformer @@ -49,6 +50,8 @@ class NestTransformer extends BaseTransformer * * @param \Pterodactyl\Models\Nest $model * @return \League\Fractal\Resource\Collection|\League\Fractal\Resource\NullResource + * + * @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException */ public function includeEggs(Nest $model) { @@ -60,4 +63,23 @@ class NestTransformer extends BaseTransformer return $this->collection($model->getRelation('eggs'), $this->makeTransformer(EggTransformer::class), Egg::RESOURCE_NAME); } + + /** + * Include the servers relationship on the given Nest model. + * + * @param \Pterodactyl\Models\Nest $model + * @return \League\Fractal\Resource\Collection|\League\Fractal\Resource\NullResource + * + * @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException + */ + public function includeServers(Nest $model) + { + if (! $this->authorize(AdminAcl::RESOURCE_SERVERS)) { + return $this->null(); + } + + $model->loadMissing('servers'); + + return $this->collection($model->getRelation('servers'), $this->makeTransformer(ServerTransformer::class), Server::RESOURCE_NAME); + } } diff --git a/tests/Integration/Api/Application/Nests/EggControllerTest.php b/tests/Integration/Api/Application/Nests/EggControllerTest.php new file mode 100644 index 000000000..4ec2fc5f4 --- /dev/null +++ b/tests/Integration/Api/Application/Nests/EggControllerTest.php @@ -0,0 +1,149 @@ +repository = $this->app->make(EggRepositoryInterface::class); + } + + /** + * Test that all of the eggs belonging to a given nest can be returned. + */ + public function testListAllEggsInNest() + { + $eggs = $this->repository->findWhere([['nest_id', '=', 1]]); + + $response = $this->getJson('/api/application/nests/' . $eggs->first()->nest_id . '/eggs'); + $response->assertStatus(Response::HTTP_OK); + $response->assertJsonCount(count($eggs), 'data'); + $response->assertJsonStructure([ + 'object', + 'data' => [ + [ + 'object', + 'attributes' => [ + 'id', 'uuid', 'nest', 'author', 'description', 'docker_image', 'startup', 'created_at', 'updated_at', + 'script' => ['privileged', 'install', 'entry', 'container', 'extends'], + 'config' => [ + 'files' => [], + 'startup' => ['done', 'userInteraction' => []], + 'stop', + 'logs' => ['custom', 'location'], + 'extends', + ], + ], + ], + ], + ]); + + foreach (array_get($response->json(), 'data') as $datum) { + $egg = $eggs->where('id', '=', $datum['attributes']['id'])->first(); + + $expected = json_encode(Arr::sortRecursive($datum['attributes'])); + $actual = json_encode(Arr::sortRecursive($this->getTransformer(EggTransformer::class)->transform($egg))); + + $this->assertSame($expected, $actual, + 'Unable to find JSON fragment: ' . PHP_EOL . PHP_EOL . "[{$expected}]" . PHP_EOL . PHP_EOL . 'within' . PHP_EOL . PHP_EOL . "[{$actual}]." + ); + } + } + + /** + * Test that a single egg can be returned. + */ + public function testReturnSingleEgg() + { + $egg = $this->repository->find(1); + + $response = $this->getJson('/api/application/nests/' . $egg->nest_id . '/eggs/' . $egg->id); + $response->assertStatus(Response::HTTP_OK); + $response->assertJsonStructure([ + 'object', + 'attributes' => [ + 'id', 'uuid', 'nest', 'author', 'description', 'docker_image', 'startup', 'script' => [], 'config' => [], 'created_at', 'updated_at', + ], + ]); + + $response->assertJson([ + 'object' => 'egg', + 'attributes' => $this->getTransformer(EggTransformer::class)->transform($egg), + ], true); + } + + /** + * Test that a single egg and all of the defined relationships can be returned. + */ + public function testReturnSingleEggWithRelationships() + { + $egg = $this->repository->find(1); + + $response = $this->getJson('/api/application/nests/' . $egg->nest_id . '/eggs/' . $egg->id . '?include=servers,variables,nest'); + $response->assertStatus(Response::HTTP_OK); + $response->assertJsonStructure([ + 'object', + 'attributes' => [ + 'relationships' => [ + 'nest' => ['object', 'attributes'], + 'servers' => ['object', 'data' => []], + 'variables' => ['object', 'data' => []], + ], + ], + ]); + } + + /** + * Test that a missing egg returns a 404 error. + */ + public function testGetMissingEgg() + { + $egg = $this->repository->find(1); + + $response = $this->getJson('/api/application/nests/' . $egg->nest_id . '/eggs/nil'); + $this->assertNotFoundJson($response); + } + + /** + * Test that an authentication error occurs if a key does not have permission + * to access a resource. + */ + public function testErrorReturnedIfNoPermission() + { + $egg = $this->repository->find(1); + $this->createNewDefaultApiKey($this->getApiUser(), ['r_eggs' => 0]); + + $response = $this->getJson('/api/application/nests/' . $egg->nest_id . '/eggs'); + $this->assertAccessDeniedJson($response); + } + + /** + * Test that a nests's existence is not exposed unless an API key has permission + * to access the resource. + */ + public function testResourceIsNotExposedWithoutPermissions() + { + $egg = $this->repository->find(1); + $this->createNewDefaultApiKey($this->getApiUser(), ['r_eggs' => 0]); + + $response = $this->getJson('/api/application/nests/' . $egg->nest_id . '/eggs/nil'); + $this->assertAccessDeniedJson($response); + } +} diff --git a/tests/Integration/Api/Application/Nests/NestControllerTest.php b/tests/Integration/Api/Application/Nests/NestControllerTest.php new file mode 100644 index 000000000..f0f7b280a --- /dev/null +++ b/tests/Integration/Api/Application/Nests/NestControllerTest.php @@ -0,0 +1,143 @@ +repository = $this->app->make(NestRepositoryInterface::class); + } + + /** + * Test that the expected nests are returned in the request. + */ + public function testNestResponse() + { + /** @var \Pterodactyl\Models\Nest[] $nests */ + $nests = $this->repository->all(); + + $response = $this->getJson('/api/application/nests'); + $response->assertStatus(Response::HTTP_OK); + $response->assertJsonCount(count($nests), 'data'); + $response->assertJsonStructure([ + 'object', + 'data' => [['object', 'attributes' => ['id', 'uuid', 'author', 'name', 'description', 'created_at', 'updated_at']]], + 'meta' => ['pagination' => ['total', 'count', 'per_page', 'current_page', 'total_pages']], + ]); + + $response->assertJson([ + 'object' => 'list', + 'data' => [], + 'meta' => [ + 'pagination' => [ + 'total' => 4, + 'count' => 4, + 'per_page' => 50, + 'current_page' => 1, + 'total_pages' => 1, + ], + ], + ]); + + foreach ($nests as $nest) { + $response->assertJsonFragment([ + 'object' => 'nest', + 'attributes' => $this->getTransformer(NestTransformer::class)->transform($nest), + ]); + } + } + + /** + * Test that getting a single nest returns the expected result. + */ + public function testSingleNestResponse() + { + $nest = $this->repository->find(1); + + $response = $this->getJson('/api/application/nests/' . $nest->id); + $response->assertStatus(Response::HTTP_OK); + $response->assertJsonStructure([ + 'object', + 'attributes' => ['id', 'uuid', 'author', 'name', 'description', 'created_at', 'updated_at'], + ]); + + $response->assertJson([ + 'object' => 'nest', + 'attributes' => $this->getTransformer(NestTransformer::class)->transform($nest), + ]); + } + + /** + * Test that including eggs in the response works as expected. + */ + public function testSingleNestWithEggsIncluded() + { + $nest = $this->repository->find(1); + $nest->loadMissing('eggs'); + + $response = $this->getJson('/api/application/nests/' . $nest->id . '?include=servers,eggs'); + $response->assertStatus(Response::HTTP_OK); + $response->assertJsonStructure([ + 'object', + 'attributes' => [ + 'relationships' => [ + 'eggs' => ['object', 'data' => []], + 'servers' => ['object', 'data' => []], + ], + ], + ]); + + $response->assertJsonCount(count($nest->getRelation('eggs')), 'attributes.relationships.eggs.data'); + } + + /** + * Test that a missing nest returns a 404 error. + */ + public function testGetMissingNest() + { + $response = $this->getJson('/api/application/nests/nil'); + $this->assertNotFoundJson($response); + } + + /** + * Test that an authentication error occurs if a key does not have permission + * to access a resource. + */ + public function testErrorReturnedIfNoPermission() + { + $nest = $this->repository->find(1); + $this->createNewDefaultApiKey($this->getApiUser(), ['r_nests' => 0]); + + $response = $this->getJson('/api/application/nests/' . $nest->id); + $this->assertAccessDeniedJson($response); + } + + /** + * Test that a nest's existence is not exposed unless an API key has permission + * to access the resource. + */ + public function testResourceIsNotExposedWithoutPermissions() + { + $nest = $this->repository->find(1); + $this->createNewDefaultApiKey($this->getApiUser(), ['r_nests' => 0]); + + $response = $this->getJson('/api/application/nests/' . $nest->id); + $this->assertAccessDeniedJson($response); + } +} diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php index 3c2a1ad4d..bfaa2e3f9 100644 --- a/tests/Integration/IntegrationTestCase.php +++ b/tests/Integration/IntegrationTestCase.php @@ -23,6 +23,9 @@ abstract class IntegrationTestCase extends TestCase Model::unsetEventDispatcher(); } + /** + * @return array + */ protected function connectionsToTransact() { return ['testing'];