From ce489a2a0d4ef373124e67cd6d2298a37f22d5fb Mon Sep 17 00:00:00 2001 From: Anna Larch Date: Tue, 19 May 2026 23:33:34 +0200 Subject: [PATCH] fix(installer): re-enable disabled-incompatible apps after appstore update When a Nextcloud server is upgraded to a new major version, apps incompatible with the old version range are automatically disabled. Previously, updating such an app via the web UI (or occ app:update) would download and upgrade the app files but leave the app disabled, requiring a manual re-enable or reinstall. updateAppstoreApp() now checks whether the app was disabled due to version incompatibility before downloading the update. After a successful upgradeApp(), if those conditions were true, enableApp() is called automatically. Also adds debug logging to previously-silent return paths in isUpdateAvailable() (git-installed apps, no newer version found, app not in store), making update failures diagnosable from debug logs. Signed-off-by: Anna Larch --- lib/private/Installer.php | 47 ++++++++++++++++- tests/lib/InstallerTest.php | 101 ++++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 1 deletion(-) diff --git a/lib/private/Installer.php b/lib/private/Installer.php index f853a7a1eedcf..ec20596147ab4 100644 --- a/lib/private/Installer.php +++ b/lib/private/Installer.php @@ -101,6 +101,22 @@ public function installApp(string $appId, bool $forceEnable = false): string { */ public function updateAppstoreApp(string $appId, bool $allowUnstable = false): bool { if ($this->isUpdateAvailable($appId, $allowUnstable) !== false) { + // Before downloading, check whether the app is currently disabled due to version + // incompatibility with this NC version. If so, re-enable it after a successful update. + $isDisabled = !$this->appManager->isEnabledForAnyone($appId); + $wasIncompatible = false; + if ($isDisabled) { + $currentInfo = $this->appManager->getAppInfo($appId); + $ncVersion = implode('.', Util::getVersion()); + $wasIncompatible = $currentInfo !== null && !$this->appManager->isAppCompatible($ncVersion, $currentInfo); + $this->logger->debug('App {appId} is disabled; incompatible with NC {version}: {incompat}', [ + 'appId' => $appId, + 'version' => $ncVersion, + 'incompat' => $wasIncompatible ? 'yes' : 'no', + 'app' => 'updater', + ]); + } + try { $this->downloadApp($appId, $allowUnstable); } catch (\Exception $e) { @@ -109,9 +125,29 @@ public function updateAppstoreApp(string $appId, bool $allowUnstable = false): b ]); return false; } - return $this->appManager->upgradeApp($appId); + + $result = $this->appManager->upgradeApp($appId); + + if ($result && $isDisabled && $wasIncompatible) { + $this->logger->info('Re-enabling {appId} after update: it was disabled due to version incompatibility', [ + 'appId' => $appId, + 'app' => 'updater', + ]); + try { + $this->appManager->enableApp($appId); + } catch (\Exception $e) { + $this->logger->warning('Could not re-enable {appId} after update: {error}', [ + 'appId' => $appId, + 'error' => $e->getMessage(), + 'app' => 'updater', + ]); + } + } + + return $result; } + $this->logger->debug('No update available for {appId}, skipping', ['appId' => $appId, 'app' => 'updater']); return false; } @@ -373,6 +409,7 @@ public function isUpdateAvailable($appId, $allowUnstable = false): string|false } if ($this->isInstalledFromGit($appId) === true) { + $this->logger->debug('App {appId} is installed from git, skipping update check', ['appId' => $appId, 'app' => 'updater']); return false; } @@ -385,17 +422,25 @@ public function isUpdateAvailable($appId, $allowUnstable = false): string|false $currentVersion = $this->appManager->getAppVersion($appId, true); if (!isset($app['releases'][0]['version'])) { + $this->logger->debug('App {appId} has no release version in app store data', ['appId' => $appId, 'app' => 'updater']); return false; } $newestVersion = $app['releases'][0]['version']; if ($currentVersion !== '0' && version_compare($newestVersion, $currentVersion, '>')) { return $newestVersion; } else { + $this->logger->debug('No newer version available for {appId}: current={current}, newest={newest}', [ + 'appId' => $appId, + 'current' => $currentVersion, + 'newest' => $newestVersion, + 'app' => 'updater', + ]); return false; } } } + $this->logger->debug('App {appId} not found in app store', ['appId' => $appId, 'app' => 'updater']); return false; } diff --git a/tests/lib/InstallerTest.php b/tests/lib/InstallerTest.php index 5852a11437254..58a6e19a573da 100644 --- a/tests/lib/InstallerTest.php +++ b/tests/lib/InstallerTest.php @@ -601,6 +601,107 @@ public function testDownloadAppSuccessful(): void { } + public function testIsUpdateAvailableLogsDebugForGitInstall(): void { + $tmpDir = sys_get_temp_dir() . '/nc_test_git_' . uniqid(); + mkdir($tmpDir . '/.git', 0700, true); + + $this->appManager + ->expects($this->once()) + ->method('getAppPath') + ->with('myapp') + ->willReturn($tmpDir); + $this->logger + ->expects($this->once()) + ->method('debug') + ->with( + 'App {appId} is installed from git, skipping update check', + $this->callback(fn($ctx) => $ctx['appId'] === 'myapp') + ); + + $installer = $this->getInstaller(); + $result = $installer->isUpdateAvailable('myapp'); + $this->assertFalse($result); + + rmdir($tmpDir . '/.git'); + rmdir($tmpDir); + } + + protected function getPartialInstaller(array $onlyMethods): Installer&\PHPUnit\Framework\MockObject\MockObject { + return $this->getMockBuilder(Installer::class) + ->setConstructorArgs([ + $this->appFetcher, + $this->clientService, + $this->tempManager, + $this->logger, + $this->config, + $this->appManager, + $this->l10nFactory, + false, + ]) + ->onlyMethods($onlyMethods) + ->getMock(); + } + + public function testUpdateAppstoreAppReEnablesDisabledIncompatibleApp(): void { + $installer = $this->getPartialInstaller(['isUpdateAvailable', 'downloadApp']); + $installer->method('isUpdateAvailable')->willReturn('1.0.0'); + $installer->method('downloadApp')->willReturn(null); + + $this->appManager->method('isEnabledForAnyone')->with('myapp')->willReturn(false); + $this->appManager->method('getAppInfo')->with('myapp')->willReturn(['id' => 'myapp', 'version' => '0.0.1']); + $this->appManager->method('isAppCompatible')->willReturn(false); + $this->appManager->method('upgradeApp')->with('myapp')->willReturn(true); + $this->appManager->expects($this->once())->method('enableApp')->with('myapp'); + + $result = $installer->updateAppstoreApp('myapp'); + $this->assertTrue($result); + } + + public function testUpdateAppstoreAppDoesNotReEnableCompatibleButDisabledApp(): void { + $installer = $this->getPartialInstaller(['isUpdateAvailable', 'downloadApp']); + $installer->method('isUpdateAvailable')->willReturn('1.0.0'); + $installer->method('downloadApp')->willReturn(null); + + $this->appManager->method('isEnabledForAnyone')->with('myapp')->willReturn(false); + $this->appManager->method('getAppInfo')->with('myapp')->willReturn(['id' => 'myapp', 'version' => '1.0.0']); + $this->appManager->method('isAppCompatible')->willReturn(true); + $this->appManager->method('upgradeApp')->with('myapp')->willReturn(true); + $this->appManager->expects($this->never())->method('enableApp'); + + $result = $installer->updateAppstoreApp('myapp'); + $this->assertTrue($result); + } + + public function testUpdateAppstoreAppDoesNotReEnableAlreadyEnabledApp(): void { + $installer = $this->getPartialInstaller(['isUpdateAvailable', 'downloadApp']); + $installer->method('isUpdateAvailable')->willReturn('1.0.0'); + $installer->method('downloadApp')->willReturn(null); + + $this->appManager->method('isEnabledForAnyone')->with('myapp')->willReturn(true); + $this->appManager->method('upgradeApp')->with('myapp')->willReturn(true); + $this->appManager->expects($this->never())->method('enableApp'); + $this->appManager->expects($this->never())->method('getAppInfo'); + $this->appManager->expects($this->never())->method('isAppCompatible'); + + $result = $installer->updateAppstoreApp('myapp'); + $this->assertTrue($result); + } + + public function testUpdateAppstoreAppDoesNotReEnableWhenUpgradeFails(): void { + $installer = $this->getPartialInstaller(['isUpdateAvailable', 'downloadApp']); + $installer->method('isUpdateAvailable')->willReturn('1.0.0'); + $installer->method('downloadApp')->willReturn(null); + + $this->appManager->method('isEnabledForAnyone')->with('myapp')->willReturn(false); + $this->appManager->method('getAppInfo')->with('myapp')->willReturn(['id' => 'myapp', 'version' => '0.0.1']); + $this->appManager->method('isAppCompatible')->willReturn(false); + $this->appManager->method('upgradeApp')->with('myapp')->willReturn(false); + $this->appManager->expects($this->never())->method('enableApp'); + + $result = $installer->updateAppstoreApp('myapp'); + $this->assertFalse($result); + } + public function testDownloadAppWithDowngrade(): void { // Use previous test to download the application in version 0.9 $this->testDownloadAppSuccessful();