diff --git a/README.md b/README.md index 47d63ca..86026a1 100644 --- a/README.md +++ b/README.md @@ -73,10 +73,10 @@ Package wpackagist-plugin/my-closed-plugin is abandoned because https://wordpres ## Why -When a plugin is closed on WordPress.org, [WPackagist](https://wpackagist.org/) not always remove it from its database immediately. -As a result, some closed plugins remain available for installation via WPackagist. +When a plugin is closed on WordPress.org, Composer repositories (e.g: [WPackagist](https://wpackagist.org/) & [WP Packages](https://wp-packages.org/) not always remove it from its database immediately. +Worse still, even after the Composer repositories removed it., a closed plugin might persists in `composer.lock` with references to download.wordPress.org endpoint and [WordPress plugin subversion repository](https://plugins.svn.wordpress.org/) +As a result, closed plugins remain installable. -Moreover, even if a plugin is closed, its existing versions are still downloadable from WordPress.org and the subversion repository. ```json { "repositories": [ @@ -87,7 +87,7 @@ Moreover, even if a plugin is closed, its existing versions are still downloadab "version": "1.0", "source": { "type": "svn", - "url": "https://plugins.svn.wordpress.org/my-closed-plugin/", + "url": "https://plugins.svn.wordpress.org/my-closed-plugin/", // subversion repository "reference": "tags/1.0" } } @@ -99,7 +99,7 @@ Moreover, even if a plugin is closed, its existing versions are still downloadab "version": "1.0", "dist": { "type": "zip", - "url": "https://downloads.wordpress.org/plugin/your-closed-plugin.1.0.zip" + "url": "https://downloads.wordpress.org/plugin/your-closed-plugin.1.0.zip" // download.wordPress.org endpoint } } } @@ -107,7 +107,7 @@ Moreover, even if a plugin is closed, its existing versions are still downloadab } ``` -To catch these closed plugins, `WP Org Closed Plugin` queries [WordPress.org API](https://codex.wordpress.org/WordPress.org_API#Plugins) to check whether a plugin is closed and mark them as abandoned in Composer. +To catch these closed plugins, `WP Org Closed Plugin` queries [WP Packages API](https://wp-packages.org/api/packages/wp-plugin/closed) for the list of plugins closed on WordPress.org and marks them as abandoned in Composer. ## What to do when a plugin is closed? @@ -151,50 +151,6 @@ Skipped checking for closed plugins because of --locked. You should run `composer audit` without `--locked` to check for closed plugins. -### Cache - -WordPress.org API responses are cached for 10 minutes. - -If you must clear the cache, delete the `/wp-org-closed-plugin` directory. - -```sh -rm -rf $(composer config cache-dir)/wp-org-closed-plugin -``` - -### Why `allow_self_signed` when connecting to `https://api.wordpress.org`? - -> [!IMPORTANT] -> **Help Wanted!** -> -> Please send pull requests if you know how to get around the error: -> -> ```console -> $ curl --http3-only 'https://api.wordpress.org/plugins/info/1.2/?action=plugin_information&slug=better-delete-revision' -> curl: (56) ngtcp2_conn_writev_stream returned error: ERR_DRAINING -> ``` - -It is a hack to disallow HTTP/3, forcing `HttpDownloader` to use `RemoteFilesystem` instead of `CurlDownloader`. - -I suspect api.wordpress.org does not properly support HTTP/3: - -```console -$ curl --http1.1 'https://api.wordpress.org/plugins/info/1.2/?action=plugin_information&slug=better-delete-revision' -...json response - -$ curl --http2 'https://api.wordpress.org/plugins/info/1.2/?action=plugin_information&slug=better-delete-revision' -...json response - -$ curl --http3-only 'https://api.wordpress.org/plugins/info/1.2/?action=plugin_information&slug=better-delete-revision' -...sometimes json response -...but most of the time ERR_DRAINING -curl: (56) ngtcp2_conn_writev_stream returned error: ERR_DRAINING -``` - -See: -- https://github.com/composer/composer/pull/12363 -- https://github.com/composer/composer/blob/f5854b140ca27164d352ce30deece798acf3e36b/src/Composer/Util/HttpDownloader.php#L537 -- https://github.com/typisttech/wp-org-closed-plugin/pull/22 - > [!TIP] > **Hire Tang Rufus!** > diff --git a/src/MarkClosedPluginAsAbandoned.php b/src/MarkClosedPluginAsAbandoned.php index a6a3093..d3f83c3 100644 --- a/src/MarkClosedPluginAsAbandoned.php +++ b/src/MarkClosedPluginAsAbandoned.php @@ -8,29 +8,20 @@ use Composer\Composer; use Composer\IO\IOInterface; use Composer\Package\CompletePackageInterface; -use TypistTech\WpOrgClosedPlugin\WpOrg\Api\ArrayCache; -use TypistTech\WpOrgClosedPlugin\WpOrg\Api\CacheProxy; -use TypistTech\WpOrgClosedPlugin\WpOrg\Api\Client; -use TypistTech\WpOrgClosedPlugin\WpOrg\Api\FileCache; use TypistTech\WpOrgClosedPlugin\WpOrg\UrlParser\DownloadUrlParser; use TypistTech\WpOrgClosedPlugin\WpOrg\UrlParser\MultiUrlParser; use TypistTech\WpOrgClosedPlugin\WpOrg\UrlParser\SvnUrlParser; use TypistTech\WpOrgClosedPlugin\WpOrg\UrlParser\UrlParserInterface; +use TypistTech\WpOrgClosedPlugin\WpPackages\Api\Client; readonly class MarkClosedPluginAsAbandoned { public static function create(Composer $composer, IOInterface $io): self { - $loop = $composer->getLoop(); - $config = $composer->getConfig(); $cachePath = "{$config->get('cache-dir')}/wp-org-closed-plugin"; - $composerCache = new Cache($io, $cachePath); - $composerCache->setReadOnly($config->get('cache-read-only')); - $cache = new CacheProxy( - new ArrayCache, - new FileCache($composerCache), - ); + $cache = new Cache($io, $cachePath); + $cache->setReadOnly($config->get('cache-read-only')); return new self( new MultiUrlParser( @@ -38,9 +29,9 @@ public static function create(Composer $composer, IOInterface $io): self new SvnUrlParser, ), new Client( - $loop->getHttpDownloader(), - $loop, + $composer->getLoop()->getHttpDownloader(), $cache, + $composer->getEventDispatcher(), ), $io, ); @@ -52,28 +43,6 @@ public function __construct( private IOInterface $io, ) {} - public function warmCache(CompletePackageInterface ...$packages): void - { - $slugs = array_map( - fn (CompletePackageInterface $package): ?string => $this->slug( - ...$package->getDistUrls(), - ...$package->getSourceUrls(), - ), - $packages, - ); - $slugs = array_filter($slugs, static fn (?string $slug) => $slug !== null); - - $slugCount = count($slugs); - $message = sprintf( - 'Warming WordPress.org plugin status cache for %d %s', - $slugCount, - $slugCount === 1 ? 'slug' : 'slugs', - ); - $this->io->debug($message); - - $this->client->warmCache(...$slugs); - } - public function __invoke(CompletePackageInterface $package): void { $this->io->debug( diff --git a/src/Plugin.php b/src/Plugin.php index 9eb05e7..b2a226a 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -48,14 +48,8 @@ public function uninstall(Composer $composer, IOInterface $io): void public static function getSubscribedEvents(): array { return [ - PackageEvents::PRE_PACKAGE_INSTALL => [ - ['warmCache', PHP_INT_MAX - 500], - ['markClosedAsAbandoned', PHP_INT_MAX - 1000], - ], - PackageEvents::PRE_PACKAGE_UPDATE => [ - ['warmCache', PHP_INT_MAX - 500], - ['markClosedAsAbandoned', PHP_INT_MAX - 1000], - ], + PackageEvents::PRE_PACKAGE_INSTALL => ['markClosedAsAbandoned', PHP_INT_MAX - 1000], + PackageEvents::PRE_PACKAGE_UPDATE => ['markClosedAsAbandoned', PHP_INT_MAX - 1000], ScriptEvents::POST_INSTALL_CMD => ['markClosedLockedPackagesIfNotAlready', PHP_INT_MAX - 1000], ScriptEvents::POST_UPDATE_CMD => ['markClosedLockedPackagesIfNotAlready', PHP_INT_MAX - 1000], @@ -64,22 +58,6 @@ public static function getSubscribedEvents(): array ]; } - public function warmCache(PackageEvent $event): void - { - $packages = array_map( - static fn ($operation) => match (true) { - $operation instanceof InstallOperation => $operation->getPackage(), - $operation instanceof UpdateOperation => $operation->getTargetPackage(), - default => null, - }, - $event->getOperations(), - ); - $packages = array_filter($packages, static fn ($package) => $package instanceof CompletePackageInterface); - $packages = array_filter($packages, static fn ($package) => ! $package->isAbandoned()); - - $this->markClosedAsAbandoned->warmCache(...$packages); - } - public function markClosedAsAbandoned(PackageEvent $event): void { $operation = $event->getOperation(); @@ -109,18 +87,18 @@ public function markClosedLockedPackagesIfNotAlready(ScriptEvent $event): void ->getLocker() ->getLockedRepository(true); - $packages = $lockedRepository->getPackages(); - $packages = array_filter($packages, static fn ($package) => $package instanceof CompletePackage); - $packages = array_filter($packages, static fn ($package) => ! $package->isAbandoned()); $packages = array_filter( - $packages, - fn ($package) => ! in_array($package->getPrettyName(), $this->marked, true), + $lockedRepository->getPackages(), + static fn ($package) => $package instanceof CompletePackage && ! $package->isAbandoned(), ); - $this->markClosedAsAbandoned->warmCache(...$packages); - foreach ($packages as $package) { - $this->marked[] = $package->getPrettyName(); + $prettyName = $package->getPrettyName(); + if (in_array($prettyName, $this->marked, true)) { + continue; + } + + $this->marked[] = $prettyName; $this->markClosedAsAbandoned->__invoke($package); } } diff --git a/src/WpOrg/Api/ArrayCache.php b/src/WpOrg/Api/ArrayCache.php deleted file mode 100644 index 6c23cd4..0000000 --- a/src/WpOrg/Api/ArrayCache.php +++ /dev/null @@ -1,21 +0,0 @@ - */ - private array $data = []; - - public function read(string $slug): ?bool - { - return $this->data[$slug] ?? null; - } - - public function write(string $slug, bool $isClosed): void - { - $this->data[$slug] = $isClosed; - } -} diff --git a/src/WpOrg/Api/CacheInterface.php b/src/WpOrg/Api/CacheInterface.php deleted file mode 100644 index e80b782..0000000 --- a/src/WpOrg/Api/CacheInterface.php +++ /dev/null @@ -1,12 +0,0 @@ -fast->read($slug); - if ($result !== null) { - return $result; - } - - $result = $this->slow->read($slug); - if ($result !== null) { - $this->fast->write($slug, $result); - } - - return $result; - } - - public function write(string $slug, bool $isClosed): void - { - $this->fast->write($slug, $isClosed); - $this->slow->write($slug, $isClosed); - } -} diff --git a/src/WpOrg/Api/Client.php b/src/WpOrg/Api/Client.php deleted file mode 100644 index a1084f3..0000000 --- a/src/WpOrg/Api/Client.php +++ /dev/null @@ -1,136 +0,0 @@ - [ - 'allow_self_signed' => true, - ], - ]; - - public function __construct( - private HttpDownloader $httpDownloader, - private Loop $loop, - private CacheInterface $cache, - ) {} - - public function warmCache(string ...$slugs): void - { - $slugs = array_map('trim', $slugs); - $slugs = array_filter($slugs, static fn (string $slug) => $slug !== ''); - - $promises = array_map( - fn (string $slug) => $this->isClosedAsync($slug), - $slugs, - ); - - $this->loop->wait($promises); - } - - public function isClosed(string $slug): bool - { - $slug = trim($slug); - if ($slug === '') { - return false; - } - - $result = null; - $promise = $this->isClosedAsync($slug) - ->then(function (bool $isClosed) use (&$result): void { - $result = $isClosed; - }); - $this->loop->wait([$promise]); - - /** @var bool */ - return $result; - } - - /** - * @return PromiseInterface - */ - private function isClosedAsync(string $slug): PromiseInterface - { - /** @var Promise */ - return new Promise(function (callable $resolve) use ($slug): void { - $cached = $this->cache->read($slug); - $next = $cached ?? $this->fetchAndCacheAsync($slug); - $resolve($next); - }); - } - - /** - * @return PromiseInterface - */ - private function fetchAndCacheAsync(string $slug): PromiseInterface - { - return $this->fetchAsync($slug) - ->then(function (bool $isClosed) use ($slug): bool { - $this->cache->write($slug, $isClosed); - - return $isClosed; - }); - } - - /** - * @return PromiseInterface - */ - private function fetchAsync(string $slug): PromiseInterface - { - $url = sprintf( - 'https://api.wordpress.org/plugins/info/1.2/?%s', - http_build_query([ - 'action' => 'plugin_information', - 'slug' => $slug, - ], '', '&'), - ); - - return $this->httpDownloader->add($url, self::OPTIONS) - ->then(static fn () => null) // Ignore successful responses. Closed plugins always return 404. - ->catch(static function (TransportException $e): ?string { - // Closed plugins always return 404. - return $e->getStatusCode() === 404 - ? $e->getResponse() - : null; - })->then(static function (?string $body): bool { - if ($body === null) { - return false; - } - - /** @var array{error?: string} $json */ - $json = json_decode($body, true, 512, JSON_THROW_ON_ERROR); - $error = $json['error'] ?? null; - - return $error === 'closed'; - })->catch(static fn () => false); - } -} diff --git a/src/WpOrg/Api/FileCache.php b/src/WpOrg/Api/FileCache.php deleted file mode 100644 index 05841f6..0000000 --- a/src/WpOrg/Api/FileCache.php +++ /dev/null @@ -1,63 +0,0 @@ -key($slug); - $age = $this->cache->getAge($key); - - // Missed or expired. - if ($age === false || $age > self::TTL) { - return null; - } - - $content = (string) $this->cache->read($key); - - return match (true) { - str_starts_with($content, 'closed'.PHP_EOL) => true, - str_starts_with($content, 'open'.PHP_EOL) => false, - default => null, // Unexpected content. Treat as a miss. - }; - } - - public function write(string $slug, bool $isClosed): void - { - if ($this->cache->isReadOnly()) { - return; - } - - $content = sprintf( - '%s%s%s%s%s%s', - $isClosed ? 'closed' : 'open', - PHP_EOL, - $slug, - PHP_EOL, - date(DateTimeInterface::RFC3339), - PHP_EOL - ); - - $this->cache->write( - $this->key($slug), - $content, - ); - } - - private function key(string $slug): string - { - return "{$slug}.txt"; - } -} diff --git a/src/WpPackages/Api/Client.php b/src/WpPackages/Api/Client.php new file mode 100644 index 0000000..787e9d7 --- /dev/null +++ b/src/WpPackages/Api/Client.php @@ -0,0 +1,150 @@ +|null */ + private ?array $closedSlugs = null; + + public function __construct( + private readonly HttpDownloader $httpDownloader, + private readonly Cache $cache, + private readonly ?EventDispatcher $eventDispatcher = null, + ) {} + + public function isClosed(string $slug): bool + { + $slug = trim($slug); + if ($slug === '') { + return false; + } + + $this->closedSlugs ??= $this->load(); + + return array_key_exists($slug, $this->closedSlugs); + } + + /** + * Return the cached list while it is fresh, otherwise refresh from the endpoint. + * + * A transport failure or an empty/malformed body falls back to the cached + * copy instead of discarding known data. + * + * @return array + */ + private function load(): array + { + $download = $this->download(); + $cached = $this->cache->read($download['cacheKey']); + $cachedSlugs = $this->decode($cached); + if ($cachedSlugs !== null && $this->isCacheFresh($download['cacheKey'])) { + return $cachedSlugs; + } + + $fallback = $cachedSlugs ?? []; + + try { + $response = $this->httpDownloader->get($download['url'], $download['options']); + } catch (TransportException) { + return $fallback; + } + + if ($this->eventDispatcher !== null) { + $postFileDownloadEvent = new PostFileDownloadEvent( + PluginEvents::POST_FILE_DOWNLOAD, + null, + null, + $download['url'], + 'metadata', + ['response' => $response, 'wp-packages-api-client' => $this], + ); + $this->eventDispatcher->dispatch($postFileDownloadEvent->getName(), $postFileDownloadEvent); + } + + $body = $response->getBody(); + if ($body === null) { + return $fallback; + } + + $fresh = $this->decode($body); + if ($fresh === null) { + return $fallback; + } + + $this->cache->write($download['cacheKey'], $body); + + return $fresh; + } + + private function isCacheFresh(string $cacheKey): bool + { + $age = $this->cache->getAge($cacheKey); + + return $age !== false && $age < self::CACHE_TTL_SECONDS; + } + + /** + * @return array{url: non-empty-string, options: mixed[], cacheKey: string} + */ + private function download(): array + { + $url = self::URL; + $options = []; + $cacheKey = $url; + + if ($this->eventDispatcher === null) { + return ['url' => $url, 'options' => $options, 'cacheKey' => $cacheKey]; + } + + $preFileDownloadEvent = new PreFileDownloadEvent( + PluginEvents::PRE_FILE_DOWNLOAD, + $this->httpDownloader, + $url, + 'metadata', + ['wp-packages-api-client' => $this], + ); + $preFileDownloadEvent->setTransportOptions($options); + $this->eventDispatcher->dispatch($preFileDownloadEvent->getName(), $preFileDownloadEvent); + + $url = $preFileDownloadEvent->getProcessedUrl(); + $options = $preFileDownloadEvent->getTransportOptions(); + $cacheKey = $preFileDownloadEvent->getCustomCacheKey() ?? $url; + + return ['url' => $url, 'options' => $options, 'cacheKey' => $cacheKey]; + } + + /** + * Decode the endpoint's JSON array of slugs into a set keyed by slug, or + * null when the payload is not a JSON array so the caller can fall back. + * + * @return array|null + */ + private function decode(string|false $body): ?array + { + $slugs = $body === false ? null : json_decode($body, true); + if (! is_array($slugs) || ! array_is_list($slugs)) { + return null; + } + + return array_fill_keys(array_filter($slugs, is_string(...)), true); + } +} diff --git a/tests/Feature/TestCase.php b/tests/Feature/TestCase.php deleted file mode 100644 index dd4c796..0000000 --- a/tests/Feature/TestCase.php +++ /dev/null @@ -1,19 +0,0 @@ -getLoop(); - } -} diff --git a/tests/Feature/WpOrg/Api/ClientTest.php b/tests/Feature/WpOrg/Api/ClientTest.php deleted file mode 100644 index f40a89c..0000000 --- a/tests/Feature/WpOrg/Api/ClientTest.php +++ /dev/null @@ -1,93 +0,0 @@ - ['paid-memberships-pro', true], - 'author_request_permanent_2' => ['be-media-from-production', true], - 'guideline_violation' => ['text-control', true], - 'guideline_violation_permanent' => ['no-longer-in-directory', true], - 'licensing-trademark-violation' => ['tiutiu-facebook-friends-widget', true], - 'security_issue' => ['better-delete-revision', true], - 'security_issue_2' => ['browser-bookmark', true], - 'unknown' => ['rumgallery', true], - 'unknown_permanent' => ['link-linker', true], - 'unused' => ['auto-translator', true], - 'unused_permanent' => ['spam-stopgap', true], - 'unused_permanent_2' => ['update-linkroll', true], - - // Not closed. - 'open' => ['hello-dolly', false], - 'not_found' => ['not-found-foo-bar-baz-qux', false], - 'empty_slug' => ['', false], - 'whitespace' => [' ', false], - ]; - }); - - dataset('slugs', static function (): array { - return [ - // Closed. - 'unused_permanent' => ['spam-stopgap', true], - // Not closed. - 'open' => ['hello-dolly', false], - ]; - }); - - it('returns true if and only if the plugin is closed', function (string $slug, bool $expected): void { - $loop = $this->loop(); - $httpDownloader = $loop->getHttpDownloader(); - $cache = Mockery::spy(CacheInterface::class); - - $client = new Client($httpDownloader, $loop, $cache); - - $actual = $client->isClosed($slug); - - expect($actual)->toBe($expected); - })->with('many_slugs'); - - it('writes to cache', function (string $slug, bool $expected): void { - $loop = $this->loop(); - $httpDownloader = $loop->getHttpDownloader(); - $cache = Mockery::spy(CacheInterface::class); - - $client = new Client($httpDownloader, $loop, $cache); - - $actual = $client->isClosed($slug); - - $cache->shouldHaveReceived('write', [$slug, $expected]); - expect($actual)->toBe($expected); - })->with('slugs'); - - it('reads from cache', function (string $slug, bool $expected): void { - $loop = $this->loop(); - $httpDownloader = Mockery::spy(HttpDownloader::class); - - $cache = Mockery::spy(CacheInterface::class); - $cache->expects() - ->read() - ->with($slug) - ->andReturn($expected); - - $client = new Client($httpDownloader, $loop, $cache); - - $actual = $client->isClosed($slug); - - $httpDownloader->shouldNotHaveReceived('add'); - $cache->shouldNotHaveReceived('write'); - expect($actual)->toBe($expected); - })->with('slugs'); - }); -}); diff --git a/tests/Feature/WpPackages/Api/ClientTest.php b/tests/Feature/WpPackages/Api/ClientTest.php new file mode 100644 index 0000000..054dfbf --- /dev/null +++ b/tests/Feature/WpPackages/Api/ClientTest.php @@ -0,0 +1,40 @@ +setReadOnly(true); + + $client = new Client($httpDownloader, $cache); + + expect($client->isClosed($slug))->toBe($expected); + })->group('network')->with([ + // Closed. + 'author_request_permanent' => ['paid-memberships-pro', true], + 'guideline_violation_permanent' => ['no-longer-in-directory', true], + 'security_issue' => ['better-delete-revision', true], + 'unused_permanent' => ['spam-stopgap', true], + + // Not closed. + 'open' => ['hello-dolly', false], + 'not_found' => ['not-found-foo-bar-baz-qux', false], + 'empty_slug' => ['', false], + 'whitespace' => [' ', false], + ]); + }); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 18d90f9..a0f7db7 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -10,7 +10,6 @@ */ pest()->group('feature') - ->extend(\Tests\Feature\TestCase::class) ->in('Feature'); pest()->group('unit') diff --git a/tests/Unit/WpOrg/Api/ArrayCacheTest.php b/tests/Unit/WpOrg/Api/ArrayCacheTest.php deleted file mode 100644 index 3fb20fb..0000000 --- a/tests/Unit/WpOrg/Api/ArrayCacheTest.php +++ /dev/null @@ -1,61 +0,0 @@ -toBeInstanceOf(CacheInterface::class); - }); - - describe('::read()', static function (): void { - test('hit', function (bool $expected): void { - $cache = new ArrayCache; - $cache->write('foo', $expected); - - $actual = $cache->read('foo'); - - expect($actual)->toBe($expected); - })->with([true, false]); - - test('miss', function (): void { - $cache = new ArrayCache; - - $actual = $cache->read('foo'); - - expect($actual)->toBeNull(); - }); - }); - - describe('::write()', static function (): void { - it('stores', function (bool $isClosed): void { - $cache = new ArrayCache; - - $cache->write('foo', $isClosed); - - expect($cache->read('foo'))->toBe($isClosed); - })->with([true, false]); - - test('last write wins', function (bool $first, bool $last): void { - $cache = new ArrayCache; - - $cache->write('foo', $first); - $cache->write('foo', $last); - - expect($cache->read('foo'))->toBe($last); - })->with([ - [true, false], - [false, true], - [true, true], - [false, false], - ]); - }); -}); diff --git a/tests/Unit/WpOrg/Api/CacheProxyTest.php b/tests/Unit/WpOrg/Api/CacheProxyTest.php deleted file mode 100644 index 6430538..0000000 --- a/tests/Unit/WpOrg/Api/CacheProxyTest.php +++ /dev/null @@ -1,88 +0,0 @@ -toBeInstanceOf(CacheInterface::class); - }); - - describe('::read()', static function (): void { - test('when fast hits', function (bool $fastValue): void { - $fast = Mockery::spy(CacheInterface::class); - $fast->expects() - ->read() - ->with('foo') - ->andReturn($fastValue); - - $slow = Mockery::spy(CacheInterface::class); - - $proxy = new CacheProxy($fast, $slow); - - $actual = $proxy->read('foo'); - - expect($actual)->toBe($fastValue); - $slow->shouldNotHaveReceived('read'); - $fast->shouldNotHaveReceived('write'); - $slow->shouldNotHaveReceived('write'); - })->with([true, false]); - - test('when fast misses while slow hits', function (bool $slowValue): void { - $fast = Mockery::spy(CacheInterface::class); - - $slow = Mockery::spy(CacheInterface::class); - $slow->expects() - ->read() - ->with('foo') - ->andReturn($slowValue); - - $proxy = new CacheProxy($fast, $slow); - - $actual = $proxy->read('foo'); - - expect($actual)->toBe($slowValue); - $fast->shouldHaveReceived('write', ['foo', $slowValue]); - $slow->shouldNotHaveReceived('write'); - })->with([true, false]); - - test('when both fast and slow miss', function (): void { - $fast = Mockery::spy(CacheInterface::class); - $slow = Mockery::spy(CacheInterface::class); - - $proxy = new CacheProxy($fast, $slow); - - $actual = $proxy->read('foo'); - - expect($actual)->toBeNull(); - $fast->shouldNotHaveReceived('write'); - $slow->shouldNotHaveReceived('write'); - }); - }); - - describe('::write()', static function (): void { - it('forwards write to both fast and slow', function (bool $isClosed): void { - $fast = Mockery::spy(CacheInterface::class); - $slow = Mockery::spy(CacheInterface::class); - - $proxy = new CacheProxy($fast, $slow); - - $proxy->write('foo', $isClosed); - - $fast->shouldHaveReceived('write', ['foo', $isClosed]); - $slow->shouldHaveReceived('write', ['foo', $isClosed]); - })->with([true, false]); - }); -}); diff --git a/tests/Unit/WpOrg/Api/FileCacheTest.php b/tests/Unit/WpOrg/Api/FileCacheTest.php deleted file mode 100644 index 1c664ff..0000000 --- a/tests/Unit/WpOrg/Api/FileCacheTest.php +++ /dev/null @@ -1,118 +0,0 @@ -toBeInstanceOf(CacheInterface::class); - }); - - dataset('slugs', static function (): array { - return [ - // Closed. - 'unused_permanent' => ['spam-stopgap', true, 'closed'], - // Not closed. - 'open' => ['hello-dolly', false, 'open'], - ]; - }); - - describe('::read()', static function (): void { - it('reads the first line of from {$slug}.txt', - function (string $slug, bool $expected, string $firstLine): void { - $composerCache = Mockery::mock(ComposerCache::class); - $composerCache->expects() - ->getAge() - ->with("{$slug}.txt") - ->andReturn(123); - $composerCache->expects() - ->read() - ->with("{$slug}.txt") - ->andReturn($firstLine.PHP_EOL.$slug.PHP_EOL.'2006-01-02T15:04:05+07:00'.PHP_EOL); - - $cache = new FileCache($composerCache); - - $actual = $cache->read($slug); - - expect($actual)->toBe($expected); - })->with('slugs'); - - test('when missed or expired', function (false|int $age): void { - $composerCache = Mockery::mock(ComposerCache::class); - $composerCache->expects() - ->getAge() - ->with('foo.txt') - ->andReturn($age); - - $cache = new FileCache($composerCache); - - $actual = $cache->read('foo'); - - expect($actual)->toBeNull(); - })->with([ - 'missed' => false, - 'expired' => 999_999, - ]); - - test('when unexpected content', function (false|string $content): void { - $composerCache = Mockery::mock(ComposerCache::class); - $composerCache->expects() - ->getAge() - ->with('foo.txt') - ->andReturn(123); - $composerCache->expects() - ->read() - ->with('foo.txt') - ->andReturn($content); - - $cache = new FileCache($composerCache); - - $actual = $cache->read('foo'); - - expect($actual)->toBeNull(); - })->with([ - 'missed' => false, - 'unexpected' => 'not-closed-nor-open', - ]); - }); - - describe('::write()', static function (): void { - it('writes the result into {$slug}.txt as the first line', - function (string $slug, bool $isClosed, string $expected): void { - $composerCache = Mockery::spy(ComposerCache::class); - $cache = new FileCache($composerCache); - - $cache->write($slug, $isClosed); - - $composerCache->shouldHaveReceived('write', [ - "{$slug}.txt", - Mockery::on(static fn (string $actual) => str_starts_with($actual, $expected.PHP_EOL.$slug.PHP_EOL)), - ]); - })->with('slugs'); - - test('when composer cache is read only', function (string $slug, bool $isClosed): void { - $composerCache = Mockery::spy(ComposerCache::class); - $composerCache->expects() - ->isReadOnly() - ->andReturnTrue(); - - $cache = new FileCache($composerCache); - - $cache->write($slug, $isClosed); - - $composerCache->shouldNotHaveReceived('write'); - })->with('slugs'); - }); -}); diff --git a/tests/Unit/WpPackages/Api/ClientTest.php b/tests/Unit/WpPackages/Api/ClientTest.php new file mode 100644 index 0000000..6080cd6 --- /dev/null +++ b/tests/Unit/WpPackages/Api/ClientTest.php @@ -0,0 +1,297 @@ +getLoop() +// ->getHttpDownloader(); +// +// $cache = Mockery::mock(Cache::class); +// $cache->allows()->read(Client::URL)->andReturnFalse(); +// $cache->allows()->write(Client::URL, Mockery::any()); +// +// $client = new Client($httpDownloader, $cache); +// +// expect($client->isClosed($slug))->toBe($expected); +// })->with([ +// // Closed. +// 'author_request_permanent' => ['paid-memberships-pro', true], +// 'guideline_violation_permanent' => ['no-longer-in-directory', true], +// 'security_issue' => ['better-delete-revision', true], +// 'unused_permanent' => ['spam-stopgap', true], +// +// // Not closed. +// 'open' => ['hello-dolly', false], +// 'not_found' => ['not-found-foo-bar-baz-qux', false], +// 'empty_slug' => ['', false], +// 'whitespace' => [' ', false], +// ]); + + it('returns false for an empty or whitespace-only slug without any I/O', function (string $slug): void { + $httpDownloader = Mockery::spy(HttpDownloader::class); + $cache = Mockery::spy(Cache::class); + + $client = new Client($httpDownloader, $cache); + + expect($client->isClosed($slug))->toBeFalse(); + + $httpDownloader->shouldNotHaveReceived('get'); + $cache->shouldNotHaveReceived('read'); + })->with([ + 'empty' => [''], + 'whitespace' => [' '], + ]); + + it('uses fresh cache without sending an HTTP request', function (): void { + $cache = Mockery::mock(Cache::class); + $cache->allows()->read(Client::URL)->andReturn('["spam-stopgap", "better-delete-revision"]'); + $cache->allows()->getAge(Client::URL)->andReturn(599); + + $httpDownloader = Mockery::mock(HttpDownloader::class); + $httpDownloader->shouldNotReceive('get'); + + $client = new Client($httpDownloader, $cache); + + expect($client->isClosed('spam-stopgap'))->toBeTrue() + ->and($client->isClosed('hello-dolly'))->toBeFalse(); + + $cache->shouldNotHaveReceived('write'); + }); + + it('writes the fetched body to cache on a cache miss', function (): void { + $cache = Mockery::mock(Cache::class); + $cache->allows()->read(Client::URL)->andReturnFalse(); + $cache->expects() + ->write(Client::URL, '["spam-stopgap"]') + ->once(); + + $response = new Response(['url' => Client::URL], 200, [], '["spam-stopgap"]'); + $httpDownloader = Mockery::mock(HttpDownloader::class); + $httpDownloader->expects() + ->get(Client::URL, []) + ->once() + ->andReturn($response); + + $client = new Client($httpDownloader, $cache); + + expect($client->isClosed('spam-stopgap'))->toBeTrue(); + }); + + it('resolves the closed list at most once', function (): void { + $cache = Mockery::mock(Cache::class); + $cache->expects()->read(Client::URL)->once()->andReturnFalse(); + $cache->allows()->write(Client::URL, '["spam-stopgap"]'); + + $response = new Response(['url' => Client::URL], 200, [], '["spam-stopgap"]'); + $httpDownloader = Mockery::mock(HttpDownloader::class); + $httpDownloader->expects() + ->get(Client::URL, []) + ->once() + ->andReturn($response); + + $client = new Client($httpDownloader, $cache); + + expect($client->isClosed('spam-stopgap'))->toBeTrue() + ->and($client->isClosed('hello-dolly'))->toBeFalse() + ->and($client->isClosed('spam-stopgap'))->toBeTrue(); + }); + + it('fetches a changed list from stale cache and overwrites the cache', function (): void { + $cache = Mockery::mock(Cache::class); + $cache->allows()->read(Client::URL)->andReturn('["spam-stopgap"]'); + $cache->allows()->getAge(Client::URL)->andReturn(600); + $cache->expects() + ->write(Client::URL, '["better-delete-revision"]') + ->once(); + + $response = new Response(['url' => Client::URL], 200, [], '["better-delete-revision"]'); + $httpDownloader = Mockery::mock(HttpDownloader::class); + $httpDownloader->expects() + ->get(Client::URL, []) + ->once() + ->andReturn($response); + + $client = new Client($httpDownloader, $cache); + + expect($client->isClosed('better-delete-revision'))->toBeTrue() + ->and($client->isClosed('spam-stopgap'))->toBeFalse(); + }); + + it('keeps using the stale cached list when the endpoint is unreachable', function (): void { + $cache = Mockery::mock(Cache::class); + $cache->allows()->read(Client::URL)->andReturn('["better-delete-revision"]'); + $cache->allows()->getAge(Client::URL)->andReturn(600); + + $httpDownloader = Mockery::mock(HttpDownloader::class); + $httpDownloader->expects() + ->get(Client::URL, []) + ->andThrow(new TransportException('Connection refused')); + + $client = new Client($httpDownloader, $cache); + + expect($client->isClosed('better-delete-revision'))->toBeTrue() + ->and($client->isClosed('hello-dolly'))->toBeFalse(); + + $cache->shouldNotHaveReceived('write'); + }); + + it('treats the plugin as not closed when unreachable with an empty cache', function (): void { + $cache = Mockery::mock(Cache::class); + $cache->allows()->read(Client::URL)->andReturnFalse(); + + $httpDownloader = Mockery::mock(HttpDownloader::class); + $httpDownloader->expects() + ->get(Client::URL, []) + ->andThrow(new TransportException('Connection refused')); + + $client = new Client($httpDownloader, $cache); + + expect($client->isClosed('better-delete-revision'))->toBeFalse(); + + $cache->shouldNotHaveReceived('write'); + }); + + it('keeps the cached list and skips caching an unusable response', function (?string $body): void { + $cache = Mockery::mock(Cache::class); + $cache->allows()->read(Client::URL)->andReturn('["better-delete-revision"]'); + $cache->allows()->getAge(Client::URL)->andReturn(600); + + $response = new Response(['url' => Client::URL], 200, [], $body); + $httpDownloader = Mockery::mock(HttpDownloader::class); + $httpDownloader->expects() + ->get(Client::URL, []) + ->andReturn($response); + + $client = new Client($httpDownloader, $cache); + + expect($client->isClosed('better-delete-revision'))->toBeTrue(); + + $cache->shouldNotHaveReceived('write'); + })->with([ + 'not json' => ['not-json'], + 'json object' => ['{"error": "better-delete-revision"}'], + 'json string' => ['"better-delete-revision"'], + 'empty body' => [''], + 'null body' => [null], + ]); + + it('caches a legitimately empty closed list', function (): void { + // A valid but empty `[]` is distinct from a malformed body: it is + // cached and returned, rather than falling back. + $cache = Mockery::mock(Cache::class); + $cache->allows()->read(Client::URL)->andReturnFalse(); + $cache->expects() + ->write(Client::URL, '[]') + ->once(); + + $response = new Response(['url' => Client::URL], 200, [], '[]'); + $httpDownloader = Mockery::mock(HttpDownloader::class); + $httpDownloader->expects() + ->get(Client::URL, []) + ->once() + ->andReturn($response); + + $client = new Client($httpDownloader, $cache); + + expect($client->isClosed('spam-stopgap'))->toBeFalse(); + }); + + it('dispatches file download events and uses the processed URL as the cache key', function (): void { + $processedUrl = 'https://mirror.example.com/closed.json'; + $options = ['http' => ['header' => ['X-Mirror: 1']]]; + $response = new Response(['url' => $processedUrl], 200, [], '["mirrored-plugin"]'); + + $cache = Mockery::mock(Cache::class); + $cache->allows()->read($processedUrl)->andReturnFalse(); + $cache->expects() + ->write($processedUrl, '["mirrored-plugin"]') + ->once(); + + $httpDownloader = Mockery::mock(HttpDownloader::class); + $httpDownloader->expects() + ->get($processedUrl, $options) + ->once() + ->andReturn($response); + + $eventDispatcher = Mockery::mock(EventDispatcher::class); + $eventDispatcher->expects() + ->dispatch(PluginEvents::PRE_FILE_DOWNLOAD, Mockery::on( + static function (PreFileDownloadEvent $event) use ($processedUrl, $options): bool { + expect($event->getProcessedUrl())->toBe(Client::URL) + ->and($event->getType())->toBe('metadata'); + + $event->setProcessedUrl($processedUrl); + $event->setTransportOptions($options); + + return true; + }, + )) + ->once(); + $eventDispatcher->expects() + ->dispatch(PluginEvents::POST_FILE_DOWNLOAD, Mockery::on( + static fn (PostFileDownloadEvent $event): bool => $event->getUrl() === $processedUrl + && $event->getType() === 'metadata' + && ($event->getContext()['response'] ?? null) === $response, + )) + ->once(); + + $client = new Client($httpDownloader, $cache, $eventDispatcher); + + expect($client->isClosed('mirrored-plugin'))->toBeTrue(); + }); + + it('uses a custom cache key from the pre file download event', function (): void { + $customCacheKey = 'custom-closed-list.json'; + $response = new Response(['url' => Client::URL], 200, [], '["custom-cache-plugin"]'); + + $cache = Mockery::mock(Cache::class); + $cache->allows()->read($customCacheKey)->andReturnFalse(); + $cache->expects() + ->write($customCacheKey, '["custom-cache-plugin"]') + ->once(); + + $httpDownloader = Mockery::mock(HttpDownloader::class); + $httpDownloader->expects() + ->get(Client::URL, []) + ->once() + ->andReturn($response); + + $eventDispatcher = Mockery::mock(EventDispatcher::class); + $eventDispatcher->expects() + ->dispatch(PluginEvents::PRE_FILE_DOWNLOAD, Mockery::on( + static function (PreFileDownloadEvent $event) use ($customCacheKey): bool { + $event->setCustomCacheKey($customCacheKey); + + return true; + }, + )) + ->once(); + $eventDispatcher->allows() + ->dispatch(PluginEvents::POST_FILE_DOWNLOAD, Mockery::type(PostFileDownloadEvent::class)); + + $client = new Client($httpDownloader, $cache, $eventDispatcher); + + expect($client->isClosed('custom-cache-plugin'))->toBeTrue(); + }); + }); +});