diff --git a/app/Contracts/Repository/AllocationRepositoryInterface.php b/app/Contracts/Repository/AllocationRepositoryInterface.php index 22ca07656..acfea56ea 100644 --- a/app/Contracts/Repository/AllocationRepositoryInterface.php +++ b/app/Contracts/Repository/AllocationRepositoryInterface.php @@ -3,6 +3,7 @@ namespace Pterodactyl\Contracts\Repository; use Illuminate\Support\Collection; +use Illuminate\Contracts\Pagination\LengthAwarePaginator; interface AllocationRepositoryInterface extends RepositoryInterface { @@ -23,6 +24,15 @@ interface AllocationRepositoryInterface extends RepositoryInterface */ public function getAllocationsForNode(int $node): Collection; + /** + * Return all of the allocations for a node in a paginated format. + * + * @param int $node + * @param int $perPage + * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator + */ + public function getPaginatedAllocationsForNode(int $node, int $perPage = 100): LengthAwarePaginator; + /** * Return all of the unique IPs that exist for a given node. * diff --git a/app/Contracts/Repository/RepositoryInterface.php b/app/Contracts/Repository/RepositoryInterface.php index 028058af4..4a098c34f 100644 --- a/app/Contracts/Repository/RepositoryInterface.php +++ b/app/Contracts/Repository/RepositoryInterface.php @@ -3,6 +3,7 @@ namespace Pterodactyl\Contracts\Repository; use Illuminate\Support\Collection; +use Illuminate\Contracts\Pagination\LengthAwarePaginator; interface RepositoryInterface { @@ -175,6 +176,14 @@ interface RepositoryInterface */ public function all(): Collection; + /** + * Return a paginated result set using a search term if set on the repository. + * + * @param int $perPage + * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator + */ + public function paginated(int $perPage): LengthAwarePaginator; + /** * Insert a single or multiple records into the database at once skipping * validation and mass assignment checking. diff --git a/app/Exceptions/Service/Allocation/ServerUsingAllocationException.php b/app/Exceptions/Service/Allocation/ServerUsingAllocationException.php new file mode 100644 index 000000000..93018ec90 --- /dev/null +++ b/app/Exceptions/Service/Allocation/ServerUsingAllocationException.php @@ -0,0 +1,9 @@ +repository->all(50); + $locations = $this->repository->paginated(100); return $this->fractal->collection($locations) ->transformWith(new LocationTransformer($request)) diff --git a/app/Http/Controllers/API/Admin/Nodes/AllocationController.php b/app/Http/Controllers/API/Admin/Nodes/AllocationController.php new file mode 100644 index 000000000..b87f179a6 --- /dev/null +++ b/app/Http/Controllers/API/Admin/Nodes/AllocationController.php @@ -0,0 +1,78 @@ +deletionService = $deletionService; + $this->fractal = $fractal; + $this->repository = $repository; + } + + /** + * Return all of the allocations that exist for a given node. + * + * @param \Illuminate\Http\Request $request + * @param int $node + * @return array + */ + public function index(Request $request, int $node): array + { + $allocations = $this->repository->getPaginatedAllocationsForNode($node, 100); + + return $this->fractal->collection($allocations) + ->transformWith(new AllocationTransformer($request)) + ->withResourceName('allocation') + ->paginateWith(new IlluminatePaginatorAdapter($allocations)) + ->toArray(); + } + + /** + * Delete a specific allocation from the Panel. + * + * @param \Pterodactyl\Models\Allocation $allocation + * @return \Illuminate\Http\Response + * + * @throws \Pterodactyl\Exceptions\Service\Allocation\ServerUsingAllocationException + */ + public function delete(Request $request, int $node, Allocation $allocation): Response + { + $this->deletionService->handle($allocation); + + return response('', 204); + } +} diff --git a/app/Http/Controllers/API/Admin/Nodes/NodeController.php b/app/Http/Controllers/API/Admin/Nodes/NodeController.php index df78e7992..23e540846 100644 --- a/app/Http/Controllers/API/Admin/Nodes/NodeController.php +++ b/app/Http/Controllers/API/Admin/Nodes/NodeController.php @@ -74,7 +74,7 @@ class NodeController extends Controller */ public function index(Request $request): array { - $nodes = $this->repository->all(config('pterodactyl.paginate.api.nodes')); + $nodes = $this->repository->paginated(100); $fractal = $this->fractal->collection($nodes) ->transformWith(new NodeTransformer($request)) diff --git a/app/Http/Controllers/API/Admin/Users/UserController.php b/app/Http/Controllers/API/Admin/Users/UserController.php index a1b076411..d2cd65d51 100644 --- a/app/Http/Controllers/API/Admin/Users/UserController.php +++ b/app/Http/Controllers/API/Admin/Users/UserController.php @@ -76,7 +76,7 @@ class UserController extends Controller */ public function index(Request $request): array { - $users = $this->repository->all(config('pterodactyl.paginate.api.users')); + $users = $this->repository->paginated(100); return $this->fractal->collection($users) ->transformWith(new UserTransformer($request)) @@ -113,7 +113,6 @@ class UserController extends Controller * @param \Pterodactyl\Models\User $user * @return array * - * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException * @throws \Pterodactyl\Exceptions\Model\DataValidationException * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException */ diff --git a/app/Http/Controllers/Admin/NodesController.php b/app/Http/Controllers/Admin/NodesController.php index e5d50fb40..511eb4393 100644 --- a/app/Http/Controllers/Admin/NodesController.php +++ b/app/Http/Controllers/Admin/NodesController.php @@ -12,6 +12,8 @@ namespace Pterodactyl\Http\Controllers\Admin; use Javascript; use Illuminate\Http\Request; use Pterodactyl\Models\Node; +use Illuminate\Http\Response; +use Pterodactyl\Models\Allocation; use Prologue\Alerts\AlertsMessageBag; use Pterodactyl\Http\Controllers\Controller; use Pterodactyl\Services\Nodes\NodeUpdateService; @@ -23,6 +25,7 @@ use Pterodactyl\Services\Helpers\SoftwareVersionService; use Pterodactyl\Http\Requests\Admin\Node\NodeFormRequest; use Pterodactyl\Contracts\Repository\NodeRepositoryInterface; use Pterodactyl\Http\Requests\Admin\Node\AllocationFormRequest; +use Pterodactyl\Services\Allocations\AllocationDeletionService; use Pterodactyl\Contracts\Repository\LocationRepositoryInterface; use Pterodactyl\Contracts\Repository\AllocationRepositoryInterface; use Pterodactyl\Http\Requests\Admin\Node\AllocationAliasFormRequest; @@ -78,11 +81,16 @@ class NodesController extends Controller * @var \Pterodactyl\Services\Helpers\SoftwareVersionService */ protected $versionService; + /** + * @var \Pterodactyl\Services\Allocations\AllocationDeletionService + */ + private $allocationDeletionService; /** * NodesController constructor. * * @param \Prologue\Alerts\AlertsMessageBag $alert + * @param \Pterodactyl\Services\Allocations\AllocationDeletionService $allocationDeletionService * @param \Pterodactyl\Contracts\Repository\AllocationRepositoryInterface $allocationRepository * @param \Pterodactyl\Services\Allocations\AssignmentService $assignmentService * @param \Illuminate\Cache\Repository $cache @@ -95,6 +103,7 @@ class NodesController extends Controller */ public function __construct( AlertsMessageBag $alert, + AllocationDeletionService $allocationDeletionService, AllocationRepositoryInterface $allocationRepository, AssignmentService $assignmentService, CacheRepository $cache, @@ -106,6 +115,7 @@ class NodesController extends Controller SoftwareVersionService $versionService ) { $this->alert = $alert; + $this->allocationDeletionService = $allocationDeletionService; $this->allocationRepository = $allocationRepository; $this->assignmentService = $assignmentService; $this->cache = $cache; @@ -262,17 +272,14 @@ class NodesController extends Controller /** * Removes a single allocation from a node. * - * @param int $node - * @param int $allocation - * @return \Illuminate\Http\Response|\Illuminate\Http\JsonResponse + * @param \Pterodactyl\Models\Allocation $allocation + * @return \Illuminate\Http\Response + * + * @throws \Pterodactyl\Exceptions\Service\Allocation\ServerUsingAllocationException */ - public function allocationRemoveSingle($node, $allocation) + public function allocationRemoveSingle(Allocation $allocation): Response { - $this->allocationRepository->deleteWhere([ - ['id', '=', $allocation], - ['node_id', '=', $node], - ['server_id', '=', null], - ]); + $this->allocationDeletionService->handle($allocation); return response('', 204); } diff --git a/app/Models/Allocation.php b/app/Models/Allocation.php index 2fce57e84..dfa84c017 100644 --- a/app/Models/Allocation.php +++ b/app/Models/Allocation.php @@ -105,4 +105,14 @@ class Allocation extends Model implements CleansAttributes, ValidableContract { return $this->belongsTo(Server::class); } + + /** + * Return the Node model associated with this allocation. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function node() + { + return $this->belongsTo(Node::class); + } } diff --git a/app/Repositories/Eloquent/AllocationRepository.php b/app/Repositories/Eloquent/AllocationRepository.php index 1a89134ca..f4830678d 100644 --- a/app/Repositories/Eloquent/AllocationRepository.php +++ b/app/Repositories/Eloquent/AllocationRepository.php @@ -2,8 +2,10 @@ namespace Pterodactyl\Repositories\Eloquent; +use Pterodactyl\Models\Node; use Illuminate\Support\Collection; use Pterodactyl\Models\Allocation; +use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Pterodactyl\Contracts\Repository\AllocationRepositoryInterface; class AllocationRepository extends EloquentRepository implements AllocationRepositoryInterface @@ -41,6 +43,18 @@ class AllocationRepository extends EloquentRepository implements AllocationRepos return $this->getBuilder()->where('node_id', $node)->get($this->getColumns()); } + /** + * Return all of the allocations for a node in a paginated format. + * + * @param int $node + * @param int $perPage + * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator + */ + public function getPaginatedAllocationsForNode(int $node, int $perPage = 100): LengthAwarePaginator + { + return $this->getBuilder()->where('node_id', $node)->paginate($perPage, $this->getColumns()); + } + /** * Return all of the unique IPs that exist for a given node. * diff --git a/app/Repositories/Eloquent/EloquentRepository.php b/app/Repositories/Eloquent/EloquentRepository.php index e285ba318..74ec809fe 100644 --- a/app/Repositories/Eloquent/EloquentRepository.php +++ b/app/Repositories/Eloquent/EloquentRepository.php @@ -7,6 +7,7 @@ use Illuminate\Support\Collection; use Pterodactyl\Repositories\Repository; use Illuminate\Database\Query\Expression; use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Pterodactyl\Contracts\Repository\RepositoryInterface; use Pterodactyl\Exceptions\Model\DataValidationException; use Pterodactyl\Exceptions\Repository\RecordNotFoundException; @@ -234,6 +235,22 @@ abstract class EloquentRepository extends Repository implements RepositoryInterf return $instance->get($this->getColumns()); } + /** + * Return a paginated result set using a search term if set on the repository. + * + * @param int $perPage + * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator + */ + public function paginated(int $perPage): LengthAwarePaginator + { + $instance = $this->getBuilder(); + if (is_subclass_of(get_called_class(), SearchableInterface::class) && $this->hasSearchTerm()) { + $instance = $instance->search($this->getSearchTerm()); + } + + return $instance->paginate($perPage, $this->getColumns()); + } + /** * Insert a single or multiple records into the database at once skipping * validation and mass assignment checking. diff --git a/app/Services/Allocations/AllocationDeletionService.php b/app/Services/Allocations/AllocationDeletionService.php new file mode 100644 index 000000000..5e81a1d2f --- /dev/null +++ b/app/Services/Allocations/AllocationDeletionService.php @@ -0,0 +1,43 @@ +repository = $repository; + } + + /** + * Delete an allocation from the database only if it does not have a server + * that is actively attached to it. + * + * @param \Pterodactyl\Models\Allocation $allocation + * @return int + * + * @throws \Pterodactyl\Exceptions\Service\Allocation\ServerUsingAllocationException + */ + public function handle(Allocation $allocation) + { + if (! is_null($allocation->server_id)) { + throw new ServerUsingAllocationException(trans('exceptions.allocations.server_using')); + } + + return $this->repository->delete($allocation->id); + } +} diff --git a/app/Services/Nodes/NodeUpdateService.php b/app/Services/Nodes/NodeUpdateService.php index 124a5b825..ef9349a71 100644 --- a/app/Services/Nodes/NodeUpdateService.php +++ b/app/Services/Nodes/NodeUpdateService.php @@ -10,16 +10,24 @@ namespace Pterodactyl\Services\Nodes; use Pterodactyl\Models\Node; +use GuzzleHttp\Exception\ConnectException; use GuzzleHttp\Exception\RequestException; +use Illuminate\Database\ConnectionInterface; use Pterodactyl\Traits\Services\ReturnsUpdatedModels; use Pterodactyl\Contracts\Repository\NodeRepositoryInterface; use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException; +use Pterodactyl\Exceptions\Service\Node\ConfigurationNotPersistedException; use Pterodactyl\Contracts\Repository\Daemon\ConfigurationRepositoryInterface; class NodeUpdateService { use ReturnsUpdatedModels; + /** + * @var \Illuminate\Database\ConnectionInterface + */ + private $connection; + /** * @var \Pterodactyl\Contracts\Repository\Daemon\ConfigurationRepositoryInterface */ @@ -33,13 +41,16 @@ class NodeUpdateService /** * UpdateService constructor. * + * @param \Illuminate\Database\ConnectionInterface $connection * @param \Pterodactyl\Contracts\Repository\Daemon\ConfigurationRepositoryInterface $configurationRepository * @param \Pterodactyl\Contracts\Repository\NodeRepositoryInterface $repository */ public function __construct( + ConnectionInterface $connection, ConfigurationRepositoryInterface $configurationRepository, NodeRepositoryInterface $repository ) { + $this->connection = $connection; $this->configRepository = $configurationRepository; $this->repository = $repository; } @@ -62,6 +73,7 @@ class NodeUpdateService unset($data['reset_secret']); } + $this->connection->beginTransaction(); if ($this->getUpdatedModel()) { $response = $this->repository->update($node->id, $data); } else { @@ -70,7 +82,16 @@ class NodeUpdateService try { $this->configRepository->setNode($node)->update(); + $this->connection->commit(); } catch (RequestException $exception) { + // Failed to connect to the Daemon. Let's go ahead and save the configuration + // and let the user know they'll need to manually update. + if ($exception instanceof ConnectException) { + $this->connection->commit(); + + throw new ConfigurationNotPersistedException(trans('exceptions.node.daemon_off_config_updated')); + } + throw new DaemonConnectionException($exception); } diff --git a/app/Transformers/Api/Admin/AllocationTransformer.php b/app/Transformers/Api/Admin/AllocationTransformer.php index cd466cf42..6015a9677 100644 --- a/app/Transformers/Api/Admin/AllocationTransformer.php +++ b/app/Transformers/Api/Admin/AllocationTransformer.php @@ -7,6 +7,16 @@ use Pterodactyl\Transformers\Api\ApiTransformer; class AllocationTransformer extends ApiTransformer { + /** + * Relationships that can be loaded onto allocation transformations. + * + * @var array + */ + protected $availableIncludes = [ + 'node', + 'server', + ]; + /** * Return a generic transformed allocation array. * @@ -15,17 +25,50 @@ class AllocationTransformer extends ApiTransformer */ public function transform(Allocation $allocation) { - return $this->transformWithFilter($allocation); + return [ + 'id' => $allocation->id, + 'ip' => $allocation->ip, + 'alias' => $allocation->ip_alias, + 'port' => $allocation->port, + 'assigned' => ! is_null($allocation->server_id), + ]; } /** - * Determine which transformer filter to apply. + * Load the node relationship onto a given transformation. * * @param \Pterodactyl\Models\Allocation $allocation - * @return array + * @return bool|\League\Fractal\Resource\Item + * + * @throws \Pterodactyl\Exceptions\PterodactylException */ - protected function transformWithFilter(Allocation $allocation) + public function includeNode(Allocation $allocation) { - return $allocation->toArray(); + if (! $this->authorize('node-view')) { + return false; + } + + $allocation->loadMissing('node'); + + return $this->item($allocation->getRelation('node'), new NodeTransformer($this->getRequest()), 'node'); + } + + /** + * Load the server relationship onto a given transformation. + * + * @param \Pterodactyl\Models\Allocation $allocation + * @return bool|\League\Fractal\Resource\Item + * + * @throws \Pterodactyl\Exceptions\PterodactylException + */ + public function includeServer(Allocation $allocation) + { + if (! $this->authorize('server-view')) { + return false; + } + + $allocation->loadMissing('server'); + + return $this->item($allocation->getRelation('server'), new ServerTransformer($this->getRequest()), 'server'); } } diff --git a/app/Transformers/Api/Admin/UserTransformer.php b/app/Transformers/Api/Admin/UserTransformer.php index 91d30ea6b..292818b31 100644 --- a/app/Transformers/Api/Admin/UserTransformer.php +++ b/app/Transformers/Api/Admin/UserTransformer.php @@ -35,13 +35,11 @@ class UserTransformer extends ApiTransformer */ public function includeServers(User $user) { - if ($this->authorize('server-list')) { + if (! $this->authorize('server-list')) { return false; } - if (! $user->relationLoaded('servers')) { - $user->load('servers'); - } + $user->loadMissing('servers'); return $this->collection($user->getRelation('servers'), new ServerTransformer($this->getRequest()), 'server'); } diff --git a/resources/lang/en/exceptions.php b/resources/lang/en/exceptions.php index f32b9c71a..373451a7b 100644 --- a/resources/lang/en/exceptions.php +++ b/resources/lang/en/exceptions.php @@ -1,19 +1,13 @@ . - * - * This software is licensed under the terms of the MIT license. - * https://opensource.org/licenses/MIT - */ return [ 'daemon_connection_failed' => 'There was an exception while attempting to communicate with the daemon resulting in a HTTP/:code response code. This exception has been logged.', 'node' => [ 'servers_attached' => 'A node must have no servers linked to it in order to be deleted.', - 'daemon_off_config_updated' => 'The daemon configuration has been updated, however there was an error encountered while attempting to automatically update the configuration file on the Daemon. You will need to manually update the configuration file (core.json) for the daemon to apply these changes. The daemon responded with a HTTP/:code response code and the error has been logged.', + 'daemon_off_config_updated' => 'The daemon configuration has been updated, however there was an error encountered while attempting to automatically update the configuration file on the Daemon. You will need to manually update the configuration file (core.json) for the daemon to apply these changes.', ], 'allocations' => [ + 'server_using' => 'A server is currently assigned to this allocation. An allocation can only be deleted if no server is currently assigned.', 'too_many_ports' => 'Adding more than 1000 ports at a single time is not supported. Please use a smaller range.', 'invalid_mapping' => 'The mapping provided for :port was invalid and could not be processed.', 'cidr_out_of_range' => 'CIDR notation only allows masks between /25 and /32.', diff --git a/routes/api-admin.php b/routes/api-admin.php index b4160122e..cd253b1b4 100644 --- a/routes/api-admin.php +++ b/routes/api-admin.php @@ -34,6 +34,12 @@ Route::group(['prefix' => '/nodes'], function () { Route::patch('/{node}', 'Nodes\NodeController@update')->name('api.admin.node.update'); Route::delete('/{node}', 'Nodes\NodeController@delete')->name('api.admin.node.delete'); + + Route::group(['prefix' => '/{node}/allocations'], function () { + Route::get('/', 'Nodes\AllocationController@index')->name('api.admin.node.allocations.list'); + + Route::delete('/{allocation}', 'Nodes\AllocationController@delete')->name('api.admin.node.allocations.delete'); + }); }); /* diff --git a/tests/Traits/MocksRequestException.php b/tests/Traits/MocksRequestException.php index 81e0e5414..974fcf0e9 100644 --- a/tests/Traits/MocksRequestException.php +++ b/tests/Traits/MocksRequestException.php @@ -21,29 +21,23 @@ trait MocksRequestException /** * Configure the exception mock to work with the Panel's default exception * handler actions. + * + * @param string $abstract + * @param null $response */ - public function configureExceptionMock() + protected function configureExceptionMock(string $abstract = RequestException::class, $response = null) { - $this->getExceptionMock()->shouldReceive('getResponse')->andReturn($this->exceptionResponse); + $this->getExceptionMock($abstract)->shouldReceive('getResponse')->andReturn(value($response)); } /** * Return a mocked instance of the request exception. * + * @param string $abstract * @return \Mockery\MockInterface */ - private function getExceptionMock(): MockInterface + protected function getExceptionMock(string $abstract = RequestException::class): MockInterface { - return $this->exception ?? $this->exception = Mockery::mock(RequestException::class); - } - - /** - * Set the exception response. - * - * @param mixed $response - */ - protected function setExceptionResponse($response) - { - $this->exceptionResponse = $response; + return $this->exception ?? $this->exception = Mockery::mock($abstract); } } diff --git a/tests/Unit/Services/Allocations/AllocationDeletionServiceTest.php b/tests/Unit/Services/Allocations/AllocationDeletionServiceTest.php new file mode 100644 index 000000000..cded75b28 --- /dev/null +++ b/tests/Unit/Services/Allocations/AllocationDeletionServiceTest.php @@ -0,0 +1,59 @@ +repository = m::mock(AllocationRepositoryInterface::class); + } + + /** + * Test that an allocation is deleted. + */ + public function testAllocationIsDeleted() + { + $model = factory(Allocation::class)->make(); + + $this->repository->shouldReceive('delete')->with($model->id)->once()->andReturn(1); + + $response = $this->getService()->handle($model); + $this->assertEquals(1, $response); + } + + /** + * Test that an exception gets thrown if an allocation is currently assigned to a server. + * + * @expectedException \Pterodactyl\Exceptions\Service\Allocation\ServerUsingAllocationException + */ + public function testExceptionThrownIfAssignedToServer() + { + $model = factory(Allocation::class)->make(['server_id' => 123]); + + $this->getService()->handle($model); + } + + /** + * Return an instance of the service with mocked injections. + * + * @return \Pterodactyl\Services\Allocations\AllocationDeletionService + */ + private function getService(): AllocationDeletionService + { + return new AllocationDeletionService($this->repository); + } +} diff --git a/tests/Unit/Services/Nodes/NodeUpdateServiceTest.php b/tests/Unit/Services/Nodes/NodeUpdateServiceTest.php index df60ae3f1..6dff6e4ba 100644 --- a/tests/Unit/Services/Nodes/NodeUpdateServiceTest.php +++ b/tests/Unit/Services/Nodes/NodeUpdateServiceTest.php @@ -12,49 +12,34 @@ namespace Tests\Unit\Services\Nodes; use Exception; use Mockery as m; use Tests\TestCase; -use Illuminate\Log\Writer; use phpmock\phpunit\PHPMock; use Pterodactyl\Models\Node; use GuzzleHttp\Psr7\Response; -use GuzzleHttp\Exception\RequestException; -use Pterodactyl\Exceptions\DisplayException; +use Tests\Traits\MocksRequestException; +use GuzzleHttp\Exception\ConnectException; +use Illuminate\Database\ConnectionInterface; use Pterodactyl\Services\Nodes\NodeUpdateService; use Pterodactyl\Contracts\Repository\NodeRepositoryInterface; use Pterodactyl\Contracts\Repository\Daemon\ConfigurationRepositoryInterface; class NodeUpdateServiceTest extends TestCase { - use PHPMock; + use PHPMock, MocksRequestException; + + /** + * @var \Illuminate\Database\ConnectionInterface|\Mockery\Mock + */ + private $connection; /** * @var \Pterodactyl\Contracts\Repository\Daemon\ConfigurationRepositoryInterface|\Mockery\Mock */ - protected $configRepository; - - /** - * @var \GuzzleHttp\Exception\RequestException|\Mockery\Mock - */ - protected $exception; - - /** - * @var \Pterodactyl\Models\Node - */ - protected $node; + private $configRepository; /** * @var \Pterodactyl\Contracts\Repository\NodeRepositoryInterface|\Mockery\Mock */ - protected $repository; - - /** - * @var \Pterodactyl\Services\Nodes\NodeUpdateService - */ - protected $service; - - /** - * @var \Illuminate\Log\Writer|\Mockery\Mock - */ - protected $writer; + private $repository; /** * Setup tests. @@ -63,18 +48,9 @@ class NodeUpdateServiceTest extends TestCase { parent::setUp(); - $this->node = factory(Node::class)->make(); - + $this->connection = m::mock(ConnectionInterface::class); $this->configRepository = m::mock(ConfigurationRepositoryInterface::class); - $this->exception = m::mock(RequestException::class); $this->repository = m::mock(NodeRepositoryInterface::class); - $this->writer = m::mock(Writer::class); - - $this->service = new NodeUpdateService( - $this->configRepository, - $this->repository, - $this->writer - ); } /** @@ -82,21 +58,23 @@ class NodeUpdateServiceTest extends TestCase */ public function testNodeIsUpdatedAndDaemonSecretIsReset() { + $model = factory(Node::class)->make(); + $this->getFunctionMock('\\Pterodactyl\\Services\\Nodes', 'str_random') ->expects($this->once())->willReturn('random_string'); - $this->repository->shouldReceive('withoutFreshModel')->withNoArgs()->once()->andReturnSelf() - ->shouldReceive('update')->with($this->node->id, [ - 'name' => 'NewName', - 'daemonSecret' => 'random_string', - ])->andReturn(true); + $this->connection->shouldReceive('beginTransaction')->withNoArgs()->once()->andReturnNull(); + $this->repository->shouldReceive('withoutFreshModel->update')->with($model->id, [ + 'name' => 'NewName', + 'daemonSecret' => 'random_string', + ])->andReturn(true); - $this->configRepository->shouldReceive('setNode')->with($this->node)->once()->andReturnSelf() + $this->configRepository->shouldReceive('setNode')->with($model)->once()->andReturnSelf() ->shouldReceive('update')->withNoArgs()->once()->andReturn(new Response); + $this->connection->shouldReceive('commit')->withNoArgs()->once()->andReturnNull(); - $response = $this->service->handle($this->node, ['name' => 'NewName', 'reset_secret' => true]); - $this->assertInstanceOf(Node::class, $response); - $this->assertSame($this->node, $response); + $response = $this->getService()->returnUpdatedModel(false)->handle($model, ['name' => 'NewName', 'reset_secret' => true]); + $this->assertTrue($response); } /** @@ -104,59 +82,85 @@ class NodeUpdateServiceTest extends TestCase */ public function testNodeIsUpdatedAndDaemonSecretIsNotChanged() { - $this->repository->shouldReceive('withoutFreshModel')->withNoArgs()->once()->andReturnSelf() - ->shouldReceive('update')->with($this->node->id, [ - 'name' => 'NewName', - ])->andReturn(true); + $model = factory(Node::class)->make(); - $this->configRepository->shouldReceive('setNode')->with($this->node)->once()->andReturnSelf() + $this->connection->shouldReceive('beginTransaction')->withNoArgs()->once()->andReturnNull(); + $this->repository->shouldReceive('withoutFreshModel->update')->with($model->id, [ + 'name' => 'NewName', + ])->andReturn(true); + + $this->configRepository->shouldReceive('setNode')->with($model)->once()->andReturnSelf() ->shouldReceive('update')->withNoArgs()->once()->andReturn(new Response); + $this->connection->shouldReceive('commit')->withNoArgs()->once()->andReturnNull(); - $response = $this->service->handle($this->node, ['name' => 'NewName']); + $response = $this->getService()->returnUpdatedModel(false)->handle($model, ['name' => 'NewName']); + $this->assertTrue($response); + } + + public function testUpdatedModelIsReturned() + { + $model = factory(Node::class)->make(); + $updated = clone $model; + $updated->name = 'NewName'; + + $this->connection->shouldReceive('beginTransaction')->withNoArgs()->once()->andReturnNull(); + $this->repository->shouldReceive('update')->with($model->id, [ + 'name' => $updated->name, + ])->andReturn($updated); + + $this->configRepository->shouldReceive('setNode')->with($model)->once()->andReturnSelf() + ->shouldReceive('update')->withNoArgs()->once()->andReturn(new Response); + $this->connection->shouldReceive('commit')->withNoArgs()->once()->andReturnNull(); + + $response = $this->getService()->returnUpdatedModel()->handle($model, ['name' => $updated->name]); $this->assertInstanceOf(Node::class, $response); - $this->assertSame($this->node, $response); + $this->assertSame($updated, $response); } /** - * Test that an exception caused by the daemon is handled properly. + * Test that an exception caused by a connection error is handled. + * + * @expectedException \Pterodactyl\Exceptions\Service\Node\ConfigurationNotPersistedException */ - public function testExceptionCausedByDaemonIsHandled() + public function testExceptionRelatedToConnection() { - $this->repository->shouldReceive('withoutFreshModel')->withNoArgs()->once()->andReturnSelf() - ->shouldReceive('update')->with($this->node->id, [ - 'name' => 'NewName', - ])->andReturn(new Response); + $this->configureExceptionMock(ConnectException::class); + $model = factory(Node::class)->make(); - $this->configRepository->shouldReceive('setNode')->with($this->node)->once()->andThrow($this->exception); - $this->writer->shouldReceive('warning')->with($this->exception)->once()->andReturnNull(); - $this->exception->shouldReceive('getResponse')->withNoArgs()->once()->andReturnSelf() - ->shouldReceive('getStatusCode')->withNoArgs()->once()->andReturn(400); + $this->connection->shouldReceive('beginTransaction')->withNoArgs()->once()->andReturnNull(); + $this->repository->shouldReceive('withoutFreshModel->update')->andReturn(new Response); - try { - $this->service->handle($this->node, ['name' => 'NewName']); - } catch (Exception $exception) { - $this->assertInstanceOf(DisplayException::class, $exception); - $this->assertEquals( - trans('exceptions.node.daemon_off_config_updated', ['code' => 400]), - $exception->getMessage() - ); - } + $this->configRepository->shouldReceive('setNode->update')->once()->andThrow($this->getExceptionMock()); + $this->connection->shouldReceive('commit')->withNoArgs()->once()->andReturnNull(); + + $this->getService()->handle($model, ['name' => 'NewName']); } /** - * Test that an ID can be passed in place of a model. + * Test that an exception not caused by a daemon connection error is handled. + * + * @expectedException \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ - public function testFunctionCanAcceptANodeIdInPlaceOfModel() + public function testExceptionNotRelatedToConnection() { - $this->repository->shouldReceive('find')->with($this->node->id)->once()->andReturn($this->node); - $this->repository->shouldReceive('withoutFreshModel')->withNoArgs()->once()->andReturnSelf() - ->shouldReceive('update')->with($this->node->id, [ - 'name' => 'NewName', - ])->andReturn(true); + $this->configureExceptionMock(); + $model = factory(Node::class)->make(); - $this->configRepository->shouldReceive('setNode')->with($this->node)->once()->andReturnSelf() - ->shouldReceive('update')->withNoArgs()->once()->andReturn(new Response); + $this->connection->shouldReceive('beginTransaction')->withNoArgs()->once()->andReturnNull(); + $this->repository->shouldReceive('withoutFreshModel->update')->andReturn(new Response); - $this->assertTrue($this->service->handle($this->node->id, ['name' => 'NewName'])); + $this->configRepository->shouldReceive('setNode->update')->once()->andThrow($this->getExceptionMock()); + + $this->getService()->handle($model, ['name' => 'NewName']); + } + + /** + * Return an instance of the service with mocked injections. + * + * @return \Pterodactyl\Services\Nodes\NodeUpdateService + */ + private function getService(): NodeUpdateService + { + return new NodeUpdateService($this->connection, $this->configRepository, $this->repository); } }