From a4749db539cc2aa0843b0bf26e3db8f0e4dbd5cf Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 23 Apr 2026 11:38:28 -0400 Subject: [PATCH 01/12] perf(appstore): optimize AppFetcher release selection Signed-off-by: Josh --- .../App/AppStore/Fetcher/AppFetcher.php | 133 ++++++++++-------- 1 file changed, 73 insertions(+), 60 deletions(-) diff --git a/lib/private/App/AppStore/Fetcher/AppFetcher.php b/lib/private/App/AppStore/Fetcher/AppFetcher.php index bbf4b00245b33..e873d12bb0f81 100644 --- a/lib/private/App/AppStore/Fetcher/AppFetcher.php +++ b/lib/private/App/AppStore/Fetcher/AppFetcher.php @@ -60,80 +60,93 @@ protected function fetch($ETag, $content, $allowUnstable = false) { return []; } - $allowPreReleases = $allowUnstable || $this->getChannel() === 'beta' || $this->getChannel() === 'daily' || $this->getChannel() === 'git'; - $allowNightly = $allowUnstable || $this->getChannel() === 'daily' || $this->getChannel() === 'git'; + $channel = $this->getChannel(); + $allowPreReleases = $allowUnstable || $channel === 'beta' || $channel === 'daily' || $channel === 'git'; + $allowNightly = $allowUnstable || $channel === 'daily' || $channel === 'git'; + + $versionParser = new VersionParser(); + $ncVersion = $this->getVersion(); + $currentPhpVersion = PHP_VERSION; + $ignoreMaxVersion = $this->ignoreMaxVersion; + + /** @var array $platformSpecCache */ + $platformSpecCache = []; + /** @var array $phpSpecCache */ + $phpSpecCache = []; foreach ($response['data'] as $dataKey => $app) { - $releases = []; + $bestRelease = null; - // Filter all compatible releases + // Filter compatible releases foreach ($app['releases'] as $release) { - // Exclude all nightly and pre-releases if required - if (($allowNightly || $release['isNightly'] === false) - && ($allowPreReleases || !str_contains($release['version'], '-'))) { - // Exclude all versions not compatible with the current version - try { - $versionParser = new VersionParser(); - $serverVersion = $versionParser->getVersion($release['rawPlatformVersionSpec']); - $ncVersion = $this->getVersion(); - $minServerVersion = $serverVersion->getMinimumVersion(); - $maxServerVersion = $serverVersion->getMaximumVersion(); - $minFulfilled = $this->compareVersion->isCompatible($ncVersion, $minServerVersion, '>='); - $maxFulfilled = $maxServerVersion !== '' - && $this->compareVersion->isCompatible($ncVersion, $maxServerVersion, '<='); - $isPhpCompatible = true; - if (($release['rawPhpVersionSpec'] ?? '*') !== '*') { - $phpVersion = $versionParser->getVersion($release['rawPhpVersionSpec']); - $minPhpVersion = $phpVersion->getMinimumVersion(); - $maxPhpVersion = $phpVersion->getMaximumVersion(); - $minPhpFulfilled = $minPhpVersion === '' || $this->compareVersion->isCompatible( - PHP_VERSION, - $minPhpVersion, - '>=' - ); - $maxPhpFulfilled = $maxPhpVersion === '' || $this->compareVersion->isCompatible( - PHP_VERSION, - $maxPhpVersion, - '<=' - ); - - $isPhpCompatible = $minPhpFulfilled && $maxPhpFulfilled; - } - if ($minFulfilled && ($this->ignoreMaxVersion || $maxFulfilled) && $isPhpCompatible) { - $releases[] = $release; + // Exclude nightly builds + if (($release['isNightly'] ?? false) !== false && !$allowNightly) { + continue; + } + + // Exclude pre-releases + if (str_contains($release['version'], '-') && !$allowPreReleases)) { + continue; + } + + try { + $rawPlatformVersionSpec = (string)$release['rawPlatformVersionSpec']; + if (!isset($platformSpecCache[$rawPlatformVersionSpec])) { + $serverVersion = $versionParser->getVersion($rawPlatformVersionSpec); + $platformSpecCache[$rawPlatformVersionSpec] = [ + $serverVersion->getMinimumVersion(), + $serverVersion->getMaximumVersion(), + ]; + } + + [$minServerVersion, $maxServerVersion] = $platformSpecCache[$rawPlatformVersionSpec]; + + $minFulfilled = $this->compareVersion->isCompatible($ncVersion, $minServerVersion, '>='); + $maxFulfilled = $maxServerVersion !== '' && $this->compareVersion->isCompatible($ncVersion, $maxServerVersion, '<='); + + $isPhpCompatible = true; + + $rawPhpVersionSpec = (string)($release['rawPhpVersionSpec'] ?? '*'); + + if ($rawPhpVersionSpec !== '*') { + if (!isset($phpSpecCache[$rawPhpVersionSpec])) { + $phpVersion = $versionParser->getVersion($rawPhpVersionSpec); + $phpSpecCache[$rawPhpVersionSpec] = [ + $phpVersion->getMinimumVersion(), + $phpVersion->getMaximumVersion(), + ]; } - } catch (\InvalidArgumentException $e) { - $this->logger->warning($e->getMessage(), [ - 'exception' => $e, - ]); + + [$minPhpVersion, $maxPhpVersion] = $phpSpecCache[$rawPhpVersionSpec]; + + $minPhpFulfilled = $minPhpVersion === '' || $this->compareVersion->isCompatible($currentPhpVersion, $minPhpVersion, '>='); + $maxPhpFulfilled = $maxPhpVersion === '' || $this->compareVersion->isCompatible($currentPhpVersion, $maxPhpVersion, '<='); + + $isPhpCompatible = $minPhpFulfilled && $maxPhpFulfilled; + } + + $isCompatible = $minFulfilled && ($ignoreMaxVersion || $maxFulfilled) && $isPhpCompatible; + + if (!$isCompatible) { + continue; + } + + $betterRelease = $bestRelease === null || version_compare((string)$release['version'], (string)$bestRelease['version'], '>'); + if ($betterRelease) { + $bestRelease = $release; } + } catch (\InvalidArgumentException $e) { + $this->logger->warning($e->getMessage(), [ 'exception' => $e, ]); } } - if (empty($releases)) { + if ($bestRelease === null) { // Remove apps that don't have a matching release $response['data'][$dataKey] = []; continue; } - // Get the highest version - $versions = []; - foreach ($releases as $release) { - $versions[] = $release['version']; - } - usort($versions, function ($version1, $version2) { - return version_compare($version1, $version2); - }); - $versions = array_reverse($versions); - if (isset($versions[0])) { - $highestVersion = $versions[0]; - foreach ($releases as $release) { - if ((string)$release['version'] === (string)$highestVersion) { - $response['data'][$dataKey]['releases'] = [$release]; - break; - } - } - } + $response['data'][$dataKey]['releases'] = [$bestRelease]; } $response['data'] = array_values(array_filter($response['data'])); From 959540ecbc68e812fa660c136edad4c7b33b7d7a Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 23 Apr 2026 11:42:33 -0400 Subject: [PATCH 02/12] perf(appstore): avoid write/readback for response data in in Fetcher Signed-off-by: Josh --- lib/private/App/AppStore/Fetcher/Fetcher.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/private/App/AppStore/Fetcher/Fetcher.php b/lib/private/App/AppStore/Fetcher/Fetcher.php index 3304b414ad747..9e9438d9f250a 100644 --- a/lib/private/App/AppStore/Fetcher/Fetcher.php +++ b/lib/private/App/AppStore/Fetcher/Fetcher.php @@ -174,7 +174,7 @@ public function get($allowUnstable = false) { } $file->putContent(json_encode($responseJson)); - return json_decode($file->getContent(), true)['data']; + return $responseJson['data']; } catch (ConnectException $e) { $this->logger->warning('Could not connect to appstore: ' . $e->getMessage(), ['app' => 'appstoreFetcher']); return []; From 343baac9efbbb636614003dce00eea78cd51e820 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 23 Apr 2026 11:58:26 -0400 Subject: [PATCH 03/12] perf(appstore): keep stale cache on refresh failure Signed-off-by: Josh --- lib/private/App/AppStore/Fetcher/Fetcher.php | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/private/App/AppStore/Fetcher/Fetcher.php b/lib/private/App/AppStore/Fetcher/Fetcher.php index 9e9438d9f250a..117318ba32bd8 100644 --- a/lib/private/App/AppStore/Fetcher/Fetcher.php +++ b/lib/private/App/AppStore/Fetcher/Fetcher.php @@ -134,6 +134,7 @@ public function get($allowUnstable = false) { $ETag = ''; $content = ''; + $cachedData = null; try { // File does already exists @@ -141,6 +142,10 @@ public function get($allowUnstable = false) { $jsonBlob = json_decode($file->getContent(), true); if (is_array($jsonBlob)) { + if (isset($jsonBlob['data']) && is_array($jsonBlob['data'])) { + $cachedData = $jsonBlob['data']; + } + // No caching when the version has been updated if (isset($jsonBlob['ncversion']) && $jsonBlob['ncversion'] === $this->getVersion()) { // If the timestamp is older than 3600 seconds request the files new @@ -177,13 +182,10 @@ public function get($allowUnstable = false) { return $responseJson['data']; } catch (ConnectException $e) { $this->logger->warning('Could not connect to appstore: ' . $e->getMessage(), ['app' => 'appstoreFetcher']); - return []; + return is_array($cachedData) ? $cachedData : []; } catch (\Exception $e) { - $this->logger->warning($e->getMessage(), [ - 'exception' => $e, - 'app' => 'appstoreFetcher', - ]); - return []; + $this->logger->warning($e->getMessage(), ['exception' => $e, 'app' => 'appstoreFetcher',]); + return is_array($cachedData) ? $cachedData : []; } } From 526b2cf83b22563b467e6682d85cfc70ec59fe6d Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 23 Apr 2026 12:02:50 -0400 Subject: [PATCH 04/12] chore(appstore): fixup AppFetcher.php Signed-off-by: Josh --- lib/private/App/AppStore/Fetcher/AppFetcher.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/private/App/AppStore/Fetcher/AppFetcher.php b/lib/private/App/AppStore/Fetcher/AppFetcher.php index e873d12bb0f81..987542840cc84 100644 --- a/lib/private/App/AppStore/Fetcher/AppFetcher.php +++ b/lib/private/App/AppStore/Fetcher/AppFetcher.php @@ -85,7 +85,7 @@ protected function fetch($ETag, $content, $allowUnstable = false) { } // Exclude pre-releases - if (str_contains($release['version'], '-') && !$allowPreReleases)) { + if (str_contains($release['version'], '-') && !$allowPreReleases) { continue; } From 4efa0aa0a34cc93315fdd9cf155e553be6c70df6 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 23 Apr 2026 12:06:06 -0400 Subject: [PATCH 05/12] perf(appstore): also use cache for non-throwing failure paths in Fetcher Signed-off-by: Josh --- lib/private/App/AppStore/Fetcher/Fetcher.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/private/App/AppStore/Fetcher/Fetcher.php b/lib/private/App/AppStore/Fetcher/Fetcher.php index 117318ba32bd8..d1f61748f70fe 100644 --- a/lib/private/App/AppStore/Fetcher/Fetcher.php +++ b/lib/private/App/AppStore/Fetcher/Fetcher.php @@ -175,7 +175,7 @@ public function get($allowUnstable = false) { $responseJson = $this->fetch($ETag, $content, $allowUnstable); if (empty($responseJson) || empty($responseJson['data'])) { - return []; + return is_array($cachedData) ? $cachedData : []; } $file->putContent(json_encode($responseJson)); From 7e88e47ec9b39d6504e33de183859ace730b6c1a Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 23 Apr 2026 12:28:05 -0400 Subject: [PATCH 06/12] perf(appstore): use hash lookup for constant-time allowList checks Signed-off-by: Josh --- lib/private/App/AppStore/Fetcher/AppFetcher.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/private/App/AppStore/Fetcher/AppFetcher.php b/lib/private/App/AppStore/Fetcher/AppFetcher.php index 987542840cc84..5cd1d2c058b98 100644 --- a/lib/private/App/AppStore/Fetcher/AppFetcher.php +++ b/lib/private/App/AppStore/Fetcher/AppFetcher.php @@ -171,12 +171,13 @@ public function get($allowUnstable = false): array { if (empty($apps)) { return []; } - $allowList = $this->config->getSystemValue('appsallowlist'); // If the admin specified a allow list, filter apps from the appstore + $allowList = $this->config->getSystemValue('appsallowlist'); if (is_array($allowList) && $this->registry->delegateHasValidSubscription()) { - return array_filter($apps, function ($app) use ($allowList) { - return in_array($app['id'], $allowList); + $allowSet = array_flip($allowList); + return array_filter($apps, static function ($app) use ($allowSet) { + return isset($allowSet[$app['id']]); }); } From dcb7617f97af7d0face6bf85ee7e58703211d0ca Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 23 Apr 2026 13:08:28 -0400 Subject: [PATCH 07/12] test(appstore): update FetcherBase coverage for cache fallback behavior Signed-off-by: Josh --- .../lib/App/AppStore/Fetcher/FetcherBase.php | 201 ++++++++++++++---- 1 file changed, 164 insertions(+), 37 deletions(-) diff --git a/tests/lib/App/AppStore/Fetcher/FetcherBase.php b/tests/lib/App/AppStore/Fetcher/FetcherBase.php index 4784e76d574f6..fe86596e286a9 100644 --- a/tests/lib/App/AppStore/Fetcher/FetcherBase.php +++ b/tests/lib/App/AppStore/Fetcher/FetcherBase.php @@ -149,9 +149,8 @@ public function testGetWithNotExistingFileAndUpToDateTimestampAndVersion(): void ->method('putContent') ->with($fileData); $file - ->expects($this->once()) - ->method('getContent') - ->willReturn($fileData); + ->expects($this->never()) + ->method('getContent'); $this->timeFactory ->expects($this->once()) ->method('getTime') @@ -199,12 +198,9 @@ public function testGetWithAlreadyExistingFileAndOutdatedTimestamp(): void { ->method('putContent') ->with($fileData); $file - ->expects($this->exactly(2)) + ->expects($this->once()) ->method('getContent') - ->willReturnOnConsecutiveCalls( - '{"timestamp":1200,"data":{"MyApp":{"id":"MyApp"}},"ncversion":"11.0.0.2"}', - $fileData - ); + ->willReturn('{"timestamp":1200,"data":{"MyApp":{"id":"MyApp"}},"ncversion":"11.0.0.2"}'); $this->timeFactory ->expects($this->exactly(2)) ->method('getTime') @@ -275,12 +271,9 @@ public function testGetWithAlreadyExistingFileAndNoVersion(): void { ->method('putContent') ->with($fileData); $file - ->expects($this->exactly(2)) + ->expects($this->once()) ->method('getContent') - ->willReturnOnConsecutiveCalls( - '{"timestamp":1200,"data":{"MyApp":{"id":"MyApp"}}', - $fileData - ); + ->willReturn('{"timestamp":1200,"data":{"MyApp":{"id":"MyApp"}}'); $this->timeFactory ->expects($this->once()) ->method('getTime') @@ -348,12 +341,9 @@ public function testGetWithAlreadyExistingFileAndOutdatedVersion(): void { ->method('putContent') ->with($fileData); $file - ->expects($this->exactly(2)) + ->expects($this->once()) ->method('getContent') - ->willReturnOnConsecutiveCalls( - '{"timestamp":1200,"data":{"MyApp":{"id":"MyApp"}},"ncversion":"11.0.0.1"', - $fileData - ); + ->willReturn('{"timestamp":1200,"data":{"MyApp":{"id":"MyApp"}},"ncversion":"11.0.0.1"'); $this->timeFactory ->method('getTime') ->willReturn(1201); @@ -388,12 +378,75 @@ public function testGetWithAlreadyExistingFileAndOutdatedVersion(): void { $this->assertSame($expected, $this->fetcher->get()); } - public function testGetWithExceptionInClient(): void { + public function testGetWithExceptionInClientReturnsStaleCachedData(): void { $this->config->method('getSystemValueString') + ->willReturnCallback(function ($key, $default) { + if ($key === 'version') { + return '11.0.0.2'; + } + return $default; + }); + $this->config->method('getSystemValueBool') ->willReturnArgument(1); + + $folder = $this->createMock(ISimpleFolder::class); + $file = $this->createMock(ISimpleFile::class); + $this->appData + ->expects($this->once()) + ->method('getFolder') + ->with('/') + ->willReturn($folder); + $folder + ->expects($this->once()) + ->method('getFile') + ->with($this->fileName) + ->willReturn($file); + $file + ->expects($this->once()) + ->method('getContent') + ->willReturn('{"timestamp":1200,"data":[{"id":"MyApp"}],"ncversion":"11.0.0.2","ETag":"\"myETag\""}'); + + $this->timeFactory + ->expects($this->once()) + ->method('getTime') + ->willReturn(4801); + + $client = $this->createMock(IClient::class); + $this->clientService + ->expects($this->once()) + ->method('newClient') + ->willReturn($client); + $client + ->expects($this->once()) + ->method('get') + ->with($this->endpoint) + ->willThrowException(new \Exception('refresh failed')); + + $expected = [ + [ + 'id' => 'MyApp', + ], + ]; + + $this->assertSame($expected, $this->fetcher->get()); + } + + public function testGetReturnsStaleCachedDataWhenRefreshReturnsEmptyResult(): void { + $this->config->method('getSystemValueString') + ->willReturnCallback(function ($key, $default) { + if ($key === 'version') { + return '11.0.0.2'; + } + return $default; + }); $this->config->method('getSystemValueBool') ->willReturnArgument(1); + $this->config->method('getAppValue') + ->willReturnMap([ + ['settings', 'appstore-fetcher-lastFailure', '0', '4800'], + ]); + $folder = $this->createMock(ISimpleFolder::class); $file = $this->createMock(ISimpleFile::class); $this->appData @@ -409,19 +462,102 @@ public function testGetWithExceptionInClient(): void { $file ->expects($this->once()) ->method('getContent') - ->willReturn('{"timestamp":1200,"data":{"MyApp":{"id":"MyApp"}}}'); + ->willReturn('{"timestamp":1200,"data":[{"id":"MyApp"}],"ncversion":"11.0.0.2","ETag":"\"myETag\""}'); + + $this->timeFactory + ->expects($this->once()) + ->method('getTime') + ->willReturn(4801); + + $expected = [ + [ + 'id' => 'MyApp', + ], + ]; + + $this->assertSame($expected, $this->fetcher->get()); + } + + public function testGetReturnsFreshDataWithoutReadingBackWrittenCache(): void { + $this->config + ->method('getSystemValueString') + ->willReturnCallback(function ($var, $default) { + if ($var === 'appstoreurl') { + return 'https://apps.nextcloud.com/api/v1'; + } elseif ($var === 'version') { + return '11.0.0.2'; + } + return $default; + }); + $this->config->method('getSystemValueBool') + ->willReturnArgument(1); + + $folder = $this->createMock(ISimpleFolder::class); + $file = $this->createMock(ISimpleFile::class); + $this->appData + ->expects($this->once()) + ->method('getFolder') + ->with('/') + ->willReturn($folder); + $folder + ->expects($this->once()) + ->method('getFile') + ->with($this->fileName) + ->willThrowException(new NotFoundException()); + $folder + ->expects($this->once()) + ->method('newFile') + ->with($this->fileName) + ->willReturn($file); + $client = $this->createMock(IClient::class); $this->clientService ->expects($this->once()) ->method('newClient') ->willReturn($client); + + $response = $this->createMock(IResponse::class); $client ->expects($this->once()) ->method('get') ->with($this->endpoint) - ->willThrowException(new \Exception()); + ->willReturn($response); + + $response + ->expects($this->once()) + ->method('getBody') + ->willReturn('[{"id":"MyNewApp","foo":"foo"},{"id":"bar"}]'); + $response + ->method('getHeader') + ->with($this->equalTo('ETag')) + ->willReturn('"myETag"'); + + $fileData = '{"data":[{"id":"MyNewApp","foo":"foo"},{"id":"bar"}],"timestamp":1502,"ncversion":"11.0.0.2","ETag":"\"myETag\""}'; + $file + ->expects($this->once()) + ->method('putContent') + ->with($fileData); - $this->assertSame([], $this->fetcher->get()); + $file + ->expects($this->never()) + ->method('getContent'); + + $this->timeFactory + ->expects($this->once()) + ->method('getTime') + ->willReturn(1502); + + $expected = [ + [ + 'id' => 'MyNewApp', + 'foo' => 'foo', + ], + [ + 'id' => 'bar', + ], + ]; + + $this->assertSame($expected, $this->fetcher->get()); } public function testGetMatchingETag(): void { @@ -462,12 +598,9 @@ public function testGetMatchingETag(): void { ->method('putContent') ->with($newData); $file - ->expects($this->exactly(2)) + ->expects($this->once()) ->method('getContent') - ->willReturnOnConsecutiveCalls( - $origData, - $newData, - ); + ->willReturn($origData); $this->timeFactory ->expects($this->exactly(2)) ->method('getTime') @@ -545,12 +678,9 @@ public function testGetNoMatchingETag(): void { ->method('putContent') ->with($fileData); $file - ->expects($this->exactly(2)) + ->expects($this->once()) ->method('getContent') - ->willReturnOnConsecutiveCalls( - '{"data":[{"id":"MyOldApp","abc":"def"}],"timestamp":1200,"ncversion":"11.0.0.2","ETag":"\"myETag\""}', - $fileData, - ); + ->willReturn('{"data":[{"id":"MyOldApp","abc":"def"}],"timestamp":1200,"ncversion":"11.0.0.2","ETag":"\"myETag\""}'); $this->timeFactory ->expects($this->exactly(2)) ->method('getTime') @@ -636,12 +766,9 @@ public function testFetchAfterUpgradeNoETag(): void { ->method('putContent') ->with($fileData); $file - ->expects($this->exactly(2)) + ->expects($this->once()) ->method('getContent') - ->willReturnOnConsecutiveCalls( - '{"data":[{"id":"MyOldApp","abc":"def"}],"timestamp":1200,"ncversion":"11.0.0.2","ETag":"\"myETag\""}', - $fileData - ); + ->willReturn('{"data":[{"id":"MyOldApp","abc":"def"}],"timestamp":1200,"ncversion":"11.0.0.2","ETag":"\"myETag\""}'); $client = $this->createMock(IClient::class); $this->clientService ->expects($this->once()) From 1dee68610997df48666f3fca4634877a410b2759 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 23 Apr 2026 13:14:14 -0400 Subject: [PATCH 08/12] test(appstore): verify highest compatible release in a single app wins Signed-off-by: Josh --- .../App/AppStore/Fetcher/AppFetcherTest.php | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/tests/lib/App/AppStore/Fetcher/AppFetcherTest.php b/tests/lib/App/AppStore/Fetcher/AppFetcherTest.php index c6f0df7d2fb6f..34f58621f2661 100644 --- a/tests/lib/App/AppStore/Fetcher/AppFetcherTest.php +++ b/tests/lib/App/AppStore/Fetcher/AppFetcherTest.php @@ -2250,4 +2250,97 @@ public function testGetAppsAllowlistCustomAppstore(): void { $this->assertEquals(count($apps), 1); $this->assertEquals($apps[0]['id'], 'contacts'); } + + public function testGetKeepsHighestCompatibleReleaseOnly(): void { + $this->config->method('getSystemValueString') + ->willReturnCallback(function ($key, $default) { + if ($key === 'version') { + return '30.0.0'; + } elseif ($key === 'appstoreurl' && $default === 'https://apps.nextcloud.com/api/v1') { + return 'https://custom.appsstore.endpoint/api/v1'; + } + return $default; + }); + $this->config->method('getSystemValueBool') + ->willReturnArgument(1); + + $file = $this->createMock(ISimpleFile::class); + $folder = $this->createMock(ISimpleFolder::class); + $folder + ->expects($this->once()) + ->method('getFile') + ->with('apps.json') + ->willThrowException(new NotFoundException()); + $folder + ->expects($this->once()) + ->method('newFile') + ->with('apps.json') + ->willReturn($file); + $this->appData + ->expects($this->once()) + ->method('getFolder') + ->with('/') + ->willReturn($folder); + + $client = $this->createMock(IClient::class); + $this->clientService + ->expects($this->once()) + ->method('newClient') + ->willReturn($client); + + $response = $this->createMock(IResponse::class); + $client + ->expects($this->once()) + ->method('get') + ->with('https://custom.appsstore.endpoint/api/v1/apps.json') + ->willReturn($response); + + $response + ->expects($this->once()) + ->method('getBody') + ->willReturn(json_encode([ + [ + 'id' => 'testapp', + 'releases' => [ + [ + 'version' => '1.0.0', + 'isNightly' => false, + 'rawPhpVersionSpec' => '*', + 'rawPlatformVersionSpec' => '>=30 <=30', + ], + [ + 'version' => '1.5.0', + 'isNightly' => false, + 'rawPhpVersionSpec' => '*', + 'rawPlatformVersionSpec' => '>=30 <=30', + ], + [ + 'version' => '2.0.0', + 'isNightly' => false, + 'rawPhpVersionSpec' => '*', + 'rawPlatformVersionSpec' => '>=31 <=31', + ], + ], + ], + ], JSON_THROW_ON_ERROR)); + $response->method('getHeader') + ->with($this->equalTo('ETag')) + ->willReturn('"myETag"'); + + $this->timeFactory + ->expects($this->once()) + ->method('getTime') + ->willReturn(1234); + + $file + ->expects($this->once()) + ->method('putContent'); + + $result = $this->fetcher->get(); + + $this->assertCount(1, $result); + $this->assertSame('testapp', $result[0]['id']); + $this->assertCount(1, $result[0]['releases']); + $this->assertSame('1.5.0', $result[0]['releases'][0]['version']); + } } From 26ee589258b645b5e3d047879419953342228a78 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 23 Apr 2026 18:22:29 -0400 Subject: [PATCH 09/12] test(appstore): fixup FetcherBase.php Signed-off-by: Josh --- tests/lib/App/AppStore/Fetcher/FetcherBase.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/lib/App/AppStore/Fetcher/FetcherBase.php b/tests/lib/App/AppStore/Fetcher/FetcherBase.php index fe86596e286a9..66f972198538f 100644 --- a/tests/lib/App/AppStore/Fetcher/FetcherBase.php +++ b/tests/lib/App/AppStore/Fetcher/FetcherBase.php @@ -465,9 +465,9 @@ public function testGetReturnsStaleCachedDataWhenRefreshReturnsEmptyResult(): vo ->willReturn('{"timestamp":1200,"data":[{"id":"MyApp"}],"ncversion":"11.0.0.2","ETag":"\"myETag\""}'); $this->timeFactory - ->expects($this->once()) + ->expects($this->exactly(2)) ->method('getTime') - ->willReturn(4801); + ->willReturnOnConsecutiveCalls(4801, 4801); $expected = [ [ From dd30f52961acab2001e6d49efe242d2ba7f4d1d4 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 23 Apr 2026 18:29:44 -0400 Subject: [PATCH 10/12] chore(appstore): fixup AppFetcher to preserve keys after filtering Signed-off-by: Josh --- lib/private/App/AppStore/Fetcher/AppFetcher.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/private/App/AppStore/Fetcher/AppFetcher.php b/lib/private/App/AppStore/Fetcher/AppFetcher.php index 5cd1d2c058b98..73bcb9a56bff7 100644 --- a/lib/private/App/AppStore/Fetcher/AppFetcher.php +++ b/lib/private/App/AppStore/Fetcher/AppFetcher.php @@ -174,6 +174,12 @@ public function get($allowUnstable = false): array { // If the admin specified a allow list, filter apps from the appstore $allowList = $this->config->getSystemValue('appsallowlist'); + if (is_array($allowList) && $this->registry->delegateHasValidSubscription()) { + $allowSet = array_flip($allowList); + return array_values(array_filter($apps, static function ($app) use ($allowSet) { + return isset($allowSet[$app['id']]); + })); + } if (is_array($allowList) && $this->registry->delegateHasValidSubscription()) { $allowSet = array_flip($allowList); return array_filter($apps, static function ($app) use ($allowSet) { From 714b9372d7155253c2d15a6cba3d31aa21c7ef00 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 23 Apr 2026 19:35:27 -0400 Subject: [PATCH 11/12] chore(appstore): revert the isNightly null-coalescing change Signed-off-by: Josh --- lib/private/App/AppStore/Fetcher/AppFetcher.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/private/App/AppStore/Fetcher/AppFetcher.php b/lib/private/App/AppStore/Fetcher/AppFetcher.php index 73bcb9a56bff7..b7ae86774632c 100644 --- a/lib/private/App/AppStore/Fetcher/AppFetcher.php +++ b/lib/private/App/AppStore/Fetcher/AppFetcher.php @@ -80,7 +80,7 @@ protected function fetch($ETag, $content, $allowUnstable = false) { // Filter compatible releases foreach ($app['releases'] as $release) { // Exclude nightly builds - if (($release['isNightly'] ?? false) !== false && !$allowNightly) { + if ($release['isNightly'] !== false && !$allowNightly) { continue; } From 90211450e1033cb28933e8206782b511dd27b103 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 23 Apr 2026 20:35:37 -0400 Subject: [PATCH 12/12] chore(appstore): handle rawPlatformVersion spec when absent/null Signed-off-by: Josh --- lib/private/App/AppStore/Fetcher/AppFetcher.php | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/lib/private/App/AppStore/Fetcher/AppFetcher.php b/lib/private/App/AppStore/Fetcher/AppFetcher.php index b7ae86774632c..c14a6228ca38e 100644 --- a/lib/private/App/AppStore/Fetcher/AppFetcher.php +++ b/lib/private/App/AppStore/Fetcher/AppFetcher.php @@ -90,7 +90,10 @@ protected function fetch($ETag, $content, $allowUnstable = false) { } try { - $rawPlatformVersionSpec = (string)$release['rawPlatformVersionSpec']; + $rawPlatformVersionSpec = $release['rawPlatformVersionSpec'] ?? null; + if ($rawPlatformVersionSpec === null) { + continue; // no spec; treat as incompatible, skip + } if (!isset($platformSpecCache[$rawPlatformVersionSpec])) { $serverVersion = $versionParser->getVersion($rawPlatformVersionSpec); $platformSpecCache[$rawPlatformVersionSpec] = [ @@ -106,8 +109,7 @@ protected function fetch($ETag, $content, $allowUnstable = false) { $isPhpCompatible = true; - $rawPhpVersionSpec = (string)($release['rawPhpVersionSpec'] ?? '*'); - + $rawPhpVersionSpec = $release['rawPhpVersionSpec'] ?? '*'; if ($rawPhpVersionSpec !== '*') { if (!isset($phpSpecCache[$rawPhpVersionSpec])) { $phpVersion = $versionParser->getVersion($rawPhpVersionSpec); @@ -174,12 +176,6 @@ public function get($allowUnstable = false): array { // If the admin specified a allow list, filter apps from the appstore $allowList = $this->config->getSystemValue('appsallowlist'); - if (is_array($allowList) && $this->registry->delegateHasValidSubscription()) { - $allowSet = array_flip($allowList); - return array_values(array_filter($apps, static function ($app) use ($allowSet) { - return isset($allowSet[$app['id']]); - })); - } if (is_array($allowList) && $this->registry->delegateHasValidSubscription()) { $allowSet = array_flip($allowList); return array_filter($apps, static function ($app) use ($allowSet) {