diff --git a/app/Livewire/Project/Shared/ScheduledTask/Executions.php b/app/Livewire/Project/Shared/ScheduledTask/Executions.php index 76e0735ff4..f7e5ca1d1f 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Executions.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Executions.php @@ -6,6 +6,7 @@ use App\Models\ScheduledTask; use App\Traits\ListensToTeamChannel; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; +use Livewire\Attributes\Computed; use Livewire\Attributes\Locked; use Livewire\Component; @@ -13,9 +14,6 @@ class Executions extends Component { use ListensToTeamChannel; - #[Locked] - public ScheduledTask $task; - #[Locked] public int $taskId; @@ -48,11 +46,11 @@ class Executions extends Component { try { $this->taskId = $taskId; - $this->task = ScheduledTask::where('team_id', Auth::user()->currentTeam()->id)->findOrFail($taskId); - $this->executions = $this->task->executions()->take(20)->get(); - $this->serverTimezone = data_get($this->task, 'application.destination.server.settings.server_timezone'); + $task = ScheduledTask::where('team_id', Auth::user()->currentTeam()->id)->findOrFail($taskId); + $this->executions = $task->executions()->take(20)->get(); + $this->serverTimezone = data_get($task, 'application.destination.server.settings.server_timezone'); if (! $this->serverTimezone) { - $this->serverTimezone = data_get($this->task, 'service.destination.server.settings.server_timezone'); + $this->serverTimezone = data_get($task, 'service.destination.server.settings.server_timezone'); } if (! $this->serverTimezone) { $this->serverTimezone = 'UTC'; @@ -62,8 +60,22 @@ class Executions extends Component } } + /** + * Load the task on each request instead of storing it as a model property. A stored model fails + * with a 404 when the poll runs after the task is deleted and before the page navigates away. + */ + #[Computed] + public function task(): ?ScheduledTask + { + return ScheduledTask::find($this->taskId); + } + public function refreshExecutions(): void { + if (! $this->task) { + return; + } + $this->executions = $this->task->executions()->take(20)->get(); if ($this->selectedKey) { $this->selectedExecution = $this->task->executions()->find($this->selectedKey); @@ -84,7 +96,7 @@ class Executions extends Component return; } $this->selectedKey = $key; - $this->selectedExecution = $this->task->executions()->find($key); + $this->selectedExecution = $this->task?->executions()->find($key); $this->currentPage = 1; // Start polling if task is running @@ -157,7 +169,9 @@ class Executions extends Component */ public function canReadOutput(): bool { - return auth()->user()?->can('update', $this->task->application ?? $this->task->service) ?? false; + $resource = $this->task?->application ?? $this->task?->service; + + return $resource && auth()->user()?->can('update', $resource); } public function hasMoreLogs() diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php index 4419b2e701..35a0f12410 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Show.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php @@ -179,11 +179,10 @@ class Show extends Component $this->task->delete(); auditLog('ui.scheduled_task.deleted', $this->taskAuditContext()); - if ($this->type === 'application') { - return redirectRoute($this, 'project.application.scheduled-tasks.show', $this->parameters); - } else { - return redirectRoute($this, 'project.service.scheduled-tasks.show', $this->parameters); - } + redirectRoute($this, "project.{$this->type}.scheduled-tasks.show", $this->parameters); + + // Without true, the confirmation modal refreshes this deleted task while wire:navigate loads the list. + return true; } catch (\Exception $e) { return handleError($e); } diff --git a/resources/views/livewire/project/shared/scheduled-task/executions.blade.php b/resources/views/livewire/project/shared/scheduled-task/executions.blade.php index 8283e359d1..6f978dc40c 100644 --- a/resources/views/livewire/project/shared/scheduled-task/executions.blade.php +++ b/resources/views/livewire/project/shared/scheduled-task/executions.blade.php @@ -25,8 +25,8 @@ 'failed' => 'Failed', default => ucfirst($executionStatus), }; - $server = data_get($task, 'application.destination.server') - ?? data_get($task, 'service.destination.server'); + $server = data_get($this->task, 'application.destination.server') + ?? data_get($this->task, 'service.destination.server'); @endphp
diff --git a/tests/Feature/ScheduledTaskCrossTeamIdorTest.php b/tests/Feature/ScheduledTaskCrossTeamIdorTest.php index b250db119b..bb5792ca07 100644 --- a/tests/Feature/ScheduledTaskCrossTeamIdorTest.php +++ b/tests/Feature/ScheduledTaskCrossTeamIdorTest.php @@ -60,8 +60,8 @@ describe('ScheduledTask locked properties', function () { expect($attributes)->not->toBeEmpty(); }); - test('Executions component task property has Locked attribute', function () { - $property = new ReflectionProperty(Executions::class, 'task'); + test('Executions component task id property has Locked attribute', function () { + $property = new ReflectionProperty(Executions::class, 'taskId'); $attributes = $property->getAttributes(Locked::class); expect($attributes)->not->toBeEmpty(); diff --git a/tests/Feature/ScheduledTaskDeleteTest.php b/tests/Feature/ScheduledTaskDeleteTest.php new file mode 100644 index 0000000000..b34a38428b --- /dev/null +++ b/tests/Feature/ScheduledTaskDeleteTest.php @@ -0,0 +1,127 @@ + 0]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $privateKey->id]); + $this->destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +afterEach(function () { + Event::forget(RouteMatched::class); +}); + +/** + * @param array $parameters + */ +function bindScheduledTaskDeleteRoute(array $parameters): void +{ + Event::listen(RouteMatched::class, function (RouteMatched $event) use ($parameters): void { + foreach ($parameters as $name => $value) { + $event->route->setParameter($name, $value); + } + }); +} + +test('deleting a service scheduled task returns true so the confirmation modal does not refresh the deleted task', function () { + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + $task = ScheduledTask::factory()->create([ + 'team_id' => $this->team->id, + 'service_id' => $service->id, + 'name' => 'nightly', + 'command' => 'echo nightly', + 'frequency' => 'daily', + ]); + $parameters = [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'service_uuid' => $service->uuid, + ]; + bindScheduledTaskDeleteRoute([...$parameters, 'task_uuid' => $task->uuid]); + + Livewire::test(Show::class) + ->call('delete') + ->assertReturned(true) + ->assertRedirect(route('project.service.scheduled-tasks.show', $parameters)); + + expect($task->fresh())->toBeNull(); +}); + +test('deleting an application scheduled task returns true and redirects to the task list', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + $task = ScheduledTask::factory()->create([ + 'team_id' => $this->team->id, + 'application_id' => $application->id, + 'name' => 'nightly', + 'command' => 'echo nightly', + 'frequency' => 'daily', + ]); + $parameters = [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'application_uuid' => $application->uuid, + ]; + bindScheduledTaskDeleteRoute([...$parameters, 'task_uuid' => $task->uuid]); + + Livewire::test(Show::class) + ->call('delete') + ->assertReturned(true) + ->assertRedirect(route('project.application.scheduled-tasks.show', $parameters)); + + expect($task->fresh())->toBeNull(); +}); + +test('the executions poll still works after its task is deleted while the page navigates away', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + $task = ScheduledTask::factory()->create([ + 'team_id' => $this->team->id, + 'application_id' => $application->id, + ]); + + $component = Livewire::test(Executions::class, ['taskId' => $task->id]); + $task->delete(); + + $component->call('refreshExecutions')->assertOk(); +}); diff --git a/tests/v4/Browser/ScheduledTaskDeleteTest.php b/tests/v4/Browser/ScheduledTaskDeleteTest.php new file mode 100644 index 0000000000..7b180f911f --- /dev/null +++ b/tests/v4/Browser/ScheduledTaskDeleteTest.php @@ -0,0 +1,77 @@ +stack = seedBrowserResourceStack(); + $service = createBrowserService($this->stack, ['uuid' => 'svc-browser-task', 'name' => 'Task Service'])['service']; + $this->task = ScheduledTask::factory()->create([ + 'team_id' => 0, + 'service_id' => $service->id, + 'name' => 'nightly-cleanup', + 'command' => 'echo cleanup', + 'frequency' => 'daily', + ]); + $this->base = serviceConfigurationUrl($this->stack['project'], $this->stack['environment'], $service); +}); + +/** + * Records the HTTP status of every Livewire request the page sends. + */ +function recordScheduledTaskLivewireStatuses(mixed $page): void +{ + $page->script(<<<'JS' + (() => { + window.livewireStatuses = []; + const fetch = window.fetch; + window.fetch = async (...args) => { + const response = await fetch(...args); + if (String(args[0]).includes('/livewire')) { + window.livewireStatuses.push(response.status); + } + return response; + }; + })() + JS); +} + +it('deletes a service scheduled task and returns to the task list', function () { + loginAndSkipBoarding(); + + $page = visit("{$this->base}/tasks/{$this->task->uuid}") + ->assertSee('Task configuration'); + recordScheduledTaskLivewireStatuses($page); + + $page->click('button:visible:has-text("Delete")') + ->fill('input[x-model="userConfirmationText"]', 'nightly-cleanup') + ->click('button:visible:has-text("Permanently delete")') + ->wait(3); + + expect($this->task->fresh())->toBeNull() + ->and($page->script('window.livewireStatuses'))->not->toContain(404); + + $page->assertPathIs(parse_url("{$this->base}/scheduled-tasks", PHP_URL_PATH)) + ->screenshot(filename: 'scheduled-task-deleted'); +}); + +it('keeps polling executions without a 404 after the task is deleted', function () { + loginAndSkipBoarding(); + + $page = visit("{$this->base}/tasks/{$this->task->uuid}") + ->assertSee('Recent executions'); + recordScheduledTaskLivewireStatuses($page); + + // The executions list polls every 5 seconds, so a poll can run after the delete and before the list page loads. + $this->task->delete(); + $page->script(<<<'JS' + Livewire.all().find((component) => component.name === 'project.shared.scheduled-task.executions') + .$wire.refreshExecutions() + JS); + $page->wait(1); + + expect($page->script('window.livewireStatuses'))->toBe([200]); + $page->screenshot(filename: 'scheduled-task-poll-after-delete'); +});