From a5acf81751d09bf80e8ae386eb413e99988189ae Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:32:13 +0200 Subject: [PATCH] fix(scheduled-tasks): stop 404 after deleting a scheduled task The executions list polled with the task stored as a model, so a poll after the delete failed to load it and showed a 404. Load the task by id on each request and skip the refresh when it is gone. Return true from delete so the confirmation modal does not refresh the deleted task. Closes #11814 Co-Authored-By: Claude Opus 5.5 --- .../Shared/ScheduledTask/Executions.php | 32 +++-- .../Project/Shared/ScheduledTask/Show.php | 9 +- .../scheduled-task/executions.blade.php | 4 +- .../ScheduledTaskCrossTeamIdorTest.php | 4 +- tests/Feature/ScheduledTaskDeleteTest.php | 127 ++++++++++++++++++ tests/v4/Browser/ScheduledTaskDeleteTest.php | 77 +++++++++++ 6 files changed, 235 insertions(+), 18 deletions(-) create mode 100644 tests/Feature/ScheduledTaskDeleteTest.php create mode 100644 tests/v4/Browser/ScheduledTaskDeleteTest.php 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'); +});