diff --git a/app/Services/Schedules/ScheduleCreationService.php b/app/Services/Schedules/ScheduleCreationService.php index 3a1920aac..837469d95 100644 --- a/app/Services/Schedules/ScheduleCreationService.php +++ b/app/Services/Schedules/ScheduleCreationService.php @@ -77,7 +77,7 @@ class ScheduleCreationService */ public function handle($server, array $data, array $tasks = []) { - Assert::true(($server instanceof Server || is_numeric($server)), + Assert::true(($server instanceof Server || is_digit($server)), 'First argument passed to handle must be numeric or instance of \Pterodactyl\Models\Server, received %s.' ); diff --git a/app/Services/Schedules/Tasks/TaskCreationService.php b/app/Services/Schedules/Tasks/TaskCreationService.php index 556528782..36eaee1ae 100644 --- a/app/Services/Schedules/Tasks/TaskCreationService.php +++ b/app/Services/Schedules/Tasks/TaskCreationService.php @@ -59,17 +59,16 @@ class TaskCreationService */ public function handle($schedule, array $data, $returnModel = true) { - Assert::true(($schedule instanceof Schedule || is_numeric($schedule)), + Assert::true(($schedule instanceof Schedule || is_digit($schedule)), 'First argument passed to handle must be numeric or instance of \Pterodactyl\Models\Schedule, received %s.' ); $schedule = ($schedule instanceof Schedule) ? $schedule->id : $schedule; - if ($data['time_interval'] === 'm' && $data['time_value'] > 15) { + $delay = $data['time_interval'] === 'm' ? $data['time_value'] * 60 : $data['time_value']; + if ($delay > 900) { throw new TaskIntervalTooLongException(trans('exceptions.tasks.chain_interval_too_long')); } - $delay = $data['time_interval'] === 'm' ? $data['time_value'] * 60 : $data['time_value']; - $repository = ($returnModel) ? $this->repository : $this->repository->withoutFresh(); $task = $repository->create([ 'schedule_id' => $schedule, diff --git a/app/helpers.php b/app/helpers.php index 0b51c7f06..21ee6eac4 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -45,3 +45,17 @@ if (! function_exists('human_readable')) { return app('file')->humanReadableSize($path, $precision); } } + +if (! function_exists('is_digit')) { + /** + * Deal with normal (and irritating) PHP behavior to determine if + * a value is a non-float positive integer. + * + * @param mixed $value + * @return bool + */ + function is_digit($value) + { + return is_bool($value) ? false : ctype_digit(strval($value)); + } +} diff --git a/database/factories/ModelFactory.php b/database/factories/ModelFactory.php index 8726b2f4f..4732a3513 100644 --- a/database/factories/ModelFactory.php +++ b/database/factories/ModelFactory.php @@ -177,3 +177,23 @@ $factory->define(Pterodactyl\Models\DatabaseHost::class, function (Faker\Generat 'node_id' => $faker->randomNumber(), ]; }); + +$factory->define(Pterodactyl\Models\Schedule::class, function (Faker\Generator $faker) { + return [ + 'id' => $faker->unique()->randomNumber(), + 'server_id' => $faker->randomNumber(), + 'name' => $faker->firstName(), + ]; +}); + +$factory->define(Pterodactyl\Models\Task::class, function (Faker\Generator $faker) { + return [ + 'id' => $faker->unique()->randomNumber(), + 'schedule_id' => $faker->randomNumber(), + 'sequence_id' => $faker->randomNumber(1), + 'action' => 'command', + 'payload' => 'test command', + 'time_offset' => 120, + 'is_queued' => false, + ]; +}); diff --git a/tests/Unit/Services/Schedules/ScheduleCreationServiceTest.php b/tests/Unit/Services/Schedules/ScheduleCreationServiceTest.php new file mode 100644 index 000000000..98f0b105c --- /dev/null +++ b/tests/Unit/Services/Schedules/ScheduleCreationServiceTest.php @@ -0,0 +1,163 @@ +. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +namespace Tests\Unit\Services\Schedules; + +use Mockery as m; +use Tests\TestCase; +use Pterodactyl\Models\Node; +use Pterodactyl\Models\Server; +use Pterodactyl\Models\Schedule; +use Illuminate\Database\ConnectionInterface; +use Pterodactyl\Services\Schedules\ScheduleCreationService; +use Pterodactyl\Services\Schedules\Tasks\TaskCreationService; +use Pterodactyl\Contracts\Repository\ScheduleRepositoryInterface; + +class ScheduleCreationServiceTest extends TestCase +{ + /** + * @var \Illuminate\Database\ConnectionInterface + */ + protected $connection; + + /** + * @var \Pterodactyl\Contracts\Repository\ScheduleRepositoryInterface + */ + protected $repository; + + /** + * @var \Pterodactyl\Services\Schedules\ScheduleCreationService + */ + protected $service; + + /** + * @var \Pterodactyl\Services\Schedules\Tasks\TaskCreationService + */ + protected $taskCreationService; + + /** + * Setup tests. + */ + public function setUp() + { + parent::setUp(); + + $this->connection = m::mock(ConnectionInterface::class); + $this->repository = m::mock(ScheduleRepositoryInterface::class); + $this->taskCreationService = m::mock(TaskCreationService::class); + + $this->service = new ScheduleCreationService($this->connection, $this->repository, $this->taskCreationService); + } + + /** + * Test that a schedule with no tasks can be created. + */ + public function testScheduleWithNoTasksIsCreated() + { + $schedule = factory(Schedule::class)->make(); + $server = factory(Server::class)->make(); + + $this->connection->shouldReceive('beginTransaction')->withNoArgs()->once()->andReturnNull(); + $this->repository->shouldReceive('create')->with(['server_id' => $server->id, 'test_data' => 'test_value'])->once()->andReturn($schedule); + $this->connection->shouldReceive('commit')->withNoArgs()->once()->andReturnNull(); + + $response = $this->service->handle($server, ['test_data' => 'test_value']); + $this->assertNotEmpty($response); + $this->assertInstanceOf(Schedule::class, $response); + $this->assertEquals($schedule, $response); + } + + /** + * Test that a schedule with at least one task can be created. + */ + public function testScheduleWithTasksIsCreated() + { + $schedule = factory(Schedule::class)->make(); + $server = factory(Server::class)->make(); + + $this->connection->shouldReceive('beginTransaction')->withNoArgs()->once()->andReturnNull(); + $this->repository->shouldReceive('create')->with(['server_id' => $server->id, 'test_data' => 'test_value'])->once()->andReturn($schedule); + $this->taskCreationService->shouldReceive('handle')->with($schedule, [ + 'time_interval' => 'm', + 'time_value' => 10, + 'sequence_id' => 1, + 'action' => 'test', + 'payload' => 'testpayload', + ], false)->once()->andReturnNull(); + + $this->connection->shouldReceive('commit')->withNoArgs()->once()->andReturnNull(); + + $response = $this->service->handle($server, ['test_data' => 'test_value'], [ + ['time_interval' => 'm', 'time_value' => 10, 'action' => 'test', 'payload' => 'testpayload'], + ]); + $this->assertNotEmpty($response); + $this->assertInstanceOf(Schedule::class, $response); + $this->assertEquals($schedule, $response); + } + + /** + * Test that an ID can be passed in place of the server model. + */ + public function testIdCanBePassedInPlaceOfServerModel() + { + $schedule = factory(Schedule::class)->make(); + + $this->connection->shouldReceive('beginTransaction')->withNoArgs()->once()->andReturnNull(); + $this->repository->shouldReceive('create')->with(['server_id' => 1234, 'test_data' => 'test_value'])->once()->andReturn($schedule); + $this->connection->shouldReceive('commit')->withNoArgs()->once()->andReturnNull(); + + $response = $this->service->handle(1234, ['test_data' => 'test_value']); + $this->assertNotEmpty($response); + $this->assertInstanceOf(Schedule::class, $response); + $this->assertEquals($schedule, $response); + } + + /** + * Test that an exception is raised if invalid data is passed. + * + * @dataProvider invalidServerArgumentProvider + * @expectedException \InvalidArgumentException + */ + public function testExceptionIsThrownIfServerIsInvalid($attribute) + { + $this->service->handle($attribute, []); + } + + /** + * Return an array of invalid server data to test aganist. + * + * @return array + */ + public function invalidServerArgumentProvider() + { + return [ + [123.456], + ['server'], + ['abc123'], + ['123_test'], + [new Node()], + [Server::class], + ]; + } +} diff --git a/tests/Unit/Services/Schedules/Tasks/TaskCreationServiceTest.php b/tests/Unit/Services/Schedules/Tasks/TaskCreationServiceTest.php new file mode 100644 index 000000000..68c7f915a --- /dev/null +++ b/tests/Unit/Services/Schedules/Tasks/TaskCreationServiceTest.php @@ -0,0 +1,223 @@ +. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +namespace Tests\Unit\Services\Schedules\Tasks; + +use Mockery as m; +use Tests\TestCase; +use Pterodactyl\Models\Task; +use Pterodactyl\Models\Server; +use Pterodactyl\Models\Schedule; +use Pterodactyl\Exceptions\DisplayException; +use Pterodactyl\Contracts\Repository\TaskRepositoryInterface; +use Pterodactyl\Services\Schedules\Tasks\TaskCreationService; +use Pterodactyl\Exceptions\Service\Schedule\Task\TaskIntervalTooLongException; + +class TaskCreationServiceTest extends TestCase +{ + /** + * @var \Pterodactyl\Contracts\Repository\TaskRepositoryInterface + */ + protected $repository; + + /** + * @var \Pterodactyl\Services\Schedules\Tasks\TaskCreationService + */ + protected $service; + + /** + * Setup tests. + */ + public function setUp() + { + parent::setUp(); + + $this->repository = m::mock(TaskRepositoryInterface::class); + + $this->service = new TaskCreationService($this->repository); + } + + /** + * Test that a task is created and a model is returned for the task. + * + * @dataProvider validIntervalProvider + */ + public function testTaskIsCreatedAndModelReturned($interval, $value, $final) + { + $schedule = factory(Schedule::class)->make(); + $task = factory(Task::class)->make(); + + $this->repository->shouldReceive('create')->with([ + 'schedule_id' => $schedule->id, + 'sequence_id' => 1, + 'action' => $task->action, + 'payload' => $task->payload, + 'time_offset' => $final, + ])->once()->andReturn($task); + + $response = $this->service->handle($schedule, [ + 'time_interval' => $interval, + 'time_value' => $value, + 'sequence_id' => 1, + 'action' => $task->action, + 'payload' => $task->payload, + ]); + + $this->assertNotEmpty($response); + $this->assertInstanceOf(Task::class, $response); + $this->assertEquals($task, $response); + } + + /** + * Test that no new model is returned when a task is created. + */ + public function testTaskIsCreatedAndModelIsNotReturned() + { + $schedule = factory(Schedule::class)->make(); + + $this->repository->shouldReceive('withoutFresh')->withNoArgs()->once()->andReturnSelf() + ->shouldReceive('create')->with([ + 'schedule_id' => $schedule->id, + 'sequence_id' => 1, + 'action' => 'test', + 'payload' => 'testpayload', + 'time_offset' => 300, + ])->once()->andReturn(true); + + $response = $this->service->handle($schedule, [ + 'time_interval' => 'm', + 'time_value' => 5, + 'sequence_id' => 1, + 'action' => 'test', + 'payload' => 'testpayload', + ], false); + + $this->assertNotEmpty($response); + $this->assertNotInstanceOf(Task::class, $response); + $this->assertTrue($response); + } + + /** + * Test that an ID can be passed in place of the schedule model itself. + */ + public function testIdCanBePassedInPlaceOfScheduleModel() + { + $this->repository->shouldReceive('withoutFresh')->withNoArgs()->once()->andReturnSelf() + ->shouldReceive('create')->with([ + 'schedule_id' => 1234, + 'sequence_id' => 1, + 'action' => 'test', + 'payload' => 'testpayload', + 'time_offset' => 300, + ])->once()->andReturn(true); + + $response = $this->service->handle(1234, [ + 'time_interval' => 'm', + 'time_value' => 5, + 'sequence_id' => 1, + 'action' => 'test', + 'payload' => 'testpayload', + ], false); + + $this->assertNotEmpty($response); + $this->assertNotInstanceOf(Task::class, $response); + $this->assertTrue($response); + } + + /** + * Test exception is thrown if the interval is greater than 15 minutes. + * + * @dataProvider invalidIntervalProvider + */ + public function testExceptionIsThrownIfIntervalIsMoreThan15Minutes($interval, $value) + { + try { + $this->service->handle(1234, [ + 'time_interval' => $interval, + 'time_value' => $value, + ]); + } catch (DisplayException $exception) { + $this->assertInstanceOf(TaskIntervalTooLongException::class, $exception); + $this->assertEquals(trans('exceptions.tasks.chain_interval_too_long'), $exception->getMessage()); + } + } + + /** + * Test that exceptions are thrown if the Scheudle module or ID is invalid. + * + * @dataProvider invalidScheduleArgumentProvider + * @expectedException \InvalidArgumentException + */ + public function testExceptionIsThrownIfInvalidArgumentIsPassed($argument) + { + $this->service->handle($argument, []); + } + + /** + * Provides valid time intervals to be used in tests. + * + * @return array + */ + public function validIntervalProvider() + { + return [ + ['s', 30, 30], + ['s', 60, 60], + ['s', 90, 90], + ['m', 1, 60], + ['m', 5, 300], + ]; + } + + /** + * Return invalid time formats. + * + * @return array + */ + public function invalidIntervalProvider() + { + return [ + ['m', 15.1], + ['m', 16], + ['s', 901], + ]; + } + + /** + * Return an array of invalid schedule data to test aganist. + * + * @return array + */ + public function invalidScheduleArgumentProvider() + { + return [ + [123.456], + ['string'], + ['abc123'], + ['123_test'], + [new Server()], + [Schedule::class], + ]; + } +}