From 53ea227d1f76b2bf0e3529a65c46f17815ce8c56 Mon Sep 17 00:00:00 2001 From: dualfroz Date: Thu, 24 Sep 2026 18:46:25 +0200 Subject: [PATCH 1/2] Fix plugin update crashing after the rows are refreshed Plugin::refreshRows() drops the Sushi connection, and only a newly constructed model boots it again. updatePlugin() called refresh() on the instance it was given, which queries through the dropped connection and fails with "Call to a member function query() on null". Load the plugin again with findOrFail(), like the plugin jobs already do. --- app/Services/Helpers/PluginService.php | 2 +- .../Services/PluginServiceTest.php | 58 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/app/Services/Helpers/PluginService.php b/app/Services/Helpers/PluginService.php index f794155059..fb90ba7d48 100644 --- a/app/Services/Helpers/PluginService.php +++ b/app/Services/Helpers/PluginService.php @@ -321,7 +321,7 @@ public function updatePlugin(Plugin $plugin): void $this->downloadPluginFromUrl($downloadUrl, $plugin->id); Plugin::refreshRows(); - $plugin = $plugin->refresh(); + $plugin = Plugin::findOrFail($plugin->id); $this->installPlugin($plugin, false); diff --git a/tests/Integration/Services/PluginServiceTest.php b/tests/Integration/Services/PluginServiceTest.php index 3d0c38f27c..66d3805c59 100644 --- a/tests/Integration/Services/PluginServiceTest.php +++ b/tests/Integration/Services/PluginServiceTest.php @@ -2,6 +2,8 @@ namespace App\Tests\Integration\Services; +use App\Enums\PluginStatus; +use App\Models\Plugin; use App\Services\Helpers\PluginService; use App\Tests\Integration\IntegrationTestCase; use Exception; @@ -9,6 +11,7 @@ use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Process; use PHPUnit\Framework\Attributes\DataProvider; use Spatie\TemporaryDirectory\TemporaryDirectory; use ZipArchive; @@ -43,6 +46,9 @@ protected function tearDown(): void File::deleteDirectory($leftover); } + // Don't leak the in-memory Sushi rows of the plugins removed above into later tests. + Plugin::refreshRows(); + // TemporaryDirectory::make() does not clean up on its own; remove the upload fixtures. foreach ($this->uploadDirs as $dir) { $dir->delete(); @@ -388,6 +394,44 @@ public function test_import_keeps_the_existing_plugin_when_the_move_fails(): voi $this->assertDirectoryDoesNotExist(plugin_path('.test-clean-plugin.bak')); } + public function test_update_downloads_and_reinstalls_the_new_version(): void + { + $this->importedPlugins[] = 'test-update-plugin'; + + // Updates are resolved against the panel version, which is null on canary. + config()->set('app.version', '1.0.0'); + Process::fake(); + + $updateUrl = 'https://example.test/test-update-plugin/update.json'; + $downloadUrl = 'https://example.test/test-update-plugin/2.0.0.zip'; + + $this->service->downloadPluginFromFile($this->makeUpload('test-update-plugin.zip', [ + 'test-update-plugin/plugin.json' => $this->updatableManifest('test-update-plugin', '1.0.0', $updateUrl), + ])); + + $update = file_get_contents($this->makeUpload('2.0.0.zip', [ + 'test-update-plugin/plugin.json' => $this->updatableManifest('test-update-plugin', '2.0.0', $updateUrl), + ])->getPathname()); + + Http::fake([ + $updateUrl => Http::response(['*' => ['version' => '2.0.0', 'download_url' => $downloadUrl]]), + $downloadUrl => Http::response($update), + ]); + + Plugin::refreshRows(); + $plugin = Plugin::findOrFail('test-update-plugin'); + + $this->service->updatePlugin($plugin); + + $this->assertSame('2.0.0', $this->installedVersion('test-update-plugin')); + + // installPlugin() ran against the reloaded row and recorded the plugin as installed but disabled. + $this->assertSame(PluginStatus::Disabled->value, File::json(plugin_path('test-update-plugin', 'plugin.json'))['meta']['status']); + + Plugin::refreshRows(); + $this->assertSame('2.0.0', Plugin::findOrFail('test-update-plugin')->version); + } + private function installedVersion(string $id): string { return File::json(plugin_path($id, 'plugin.json'))['version']; @@ -398,6 +442,20 @@ private function manifest(string $id, string $version = '1.0.0'): string return json_encode(['id' => $id, 'version' => $version]); } + private function updatableManifest(string $id, string $version, string $updateUrl): string + { + return json_encode([ + 'id' => $id, + 'name' => 'Test Update Plugin', + 'author' => 'Pelican', + 'version' => $version, + 'category' => 'plugin', + 'update_url' => $updateUrl, + 'namespace' => 'TestUpdatePlugin', + 'class' => 'TestUpdatePlugin', + ]); + } + /** * Build a zip on disk from the given [path => contents] map and wrap it in an UploadedFile. * From d2f9da9aefe567bf1fe8ca3bba7e0549a4f374ee Mon Sep 17 00:00:00 2001 From: Lance Pioch Date: Fri, 25 Sep 2026 14:46:34 -0400 Subject: [PATCH 2/2] Return the reloaded plugin from updatePlugin so the API responds with the new version --- .../Controllers/Api/Application/Plugins/PluginController.php | 2 +- app/Services/Helpers/PluginService.php | 4 +++- tests/Integration/Services/PluginServiceTest.php | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/Http/Controllers/Api/Application/Plugins/PluginController.php b/app/Http/Controllers/Api/Application/Plugins/PluginController.php index 22828e227f..7e74e44675 100644 --- a/app/Http/Controllers/Api/Application/Plugins/PluginController.php +++ b/app/Http/Controllers/Api/Application/Plugins/PluginController.php @@ -122,7 +122,7 @@ public function update(WritePluginRequest $request, Plugin $plugin): array { throw_unless($plugin->isUpdateAvailable(), new PanelException("Plugin doesn't need updating")); - $this->pluginService->updatePlugin($plugin); + $plugin = $this->pluginService->updatePlugin($plugin); return $this->response->item($plugin) ->transformWith(PluginData::class) diff --git a/app/Services/Helpers/PluginService.php b/app/Services/Helpers/PluginService.php index fb90ba7d48..a078209ceb 100644 --- a/app/Services/Helpers/PluginService.php +++ b/app/Services/Helpers/PluginService.php @@ -313,7 +313,7 @@ public function installPlugin(Plugin $plugin, bool $enable = true): void } /** @throws Exception */ - public function updatePlugin(Plugin $plugin): void + public function updatePlugin(Plugin $plugin): Plugin { $downloadUrl = $plugin->getDownloadUrlForUpdate(); throw_unless($downloadUrl, new Exception('No download url found.')); @@ -326,6 +326,8 @@ public function updatePlugin(Plugin $plugin): void $this->installPlugin($plugin, false); cache()->forget("plugins.$plugin->id.update"); + + return $plugin; } /** @throws Exception */ diff --git a/tests/Integration/Services/PluginServiceTest.php b/tests/Integration/Services/PluginServiceTest.php index 66d3805c59..6ab1633bc2 100644 --- a/tests/Integration/Services/PluginServiceTest.php +++ b/tests/Integration/Services/PluginServiceTest.php @@ -421,8 +421,9 @@ public function test_update_downloads_and_reinstalls_the_new_version(): void Plugin::refreshRows(); $plugin = Plugin::findOrFail('test-update-plugin'); - $this->service->updatePlugin($plugin); + $updated = $this->service->updatePlugin($plugin); + $this->assertSame('2.0.0', $updated->version); $this->assertSame('2.0.0', $this->installedVersion('test-update-plugin')); // installPlugin() ran against the reloaded row and recorded the plugin as installed but disabled.