*/ private array $tempFiles = []; protected function setUp(): void { parent::setUp(); Functions\stubTranslationFunctions(); // error_log() is a PHP internal and cannot be stubbed; redirect it to a temp file instead. $this->logFile = $this->tempFile(); $this->previousLog = ini_set('error_log', $this->logFile); $pair = sodium_crypto_sign_keypair(); $this->secretKey = sodium_crypto_sign_secretkey($pair); $this->publicKey = base64_encode(sodium_crypto_sign_publickey($pair)); } protected function tearDown(): void { ini_set('error_log', (string) $this->previousLog); foreach ($this->tempFiles as $file) { if (file_exists($file)) { unlink($file); } } parent::tearDown(); } public function testRegistersItsFilters(): void { $updater = $this->updater(); Filters\expectAdded('update_plugins_' . GiteaUpdater::HOST)->once()->with([$updater, 'offer'], 10, 3); Filters\expectAdded('upgrader_pre_download')->once()->with([$updater, 'verifyDownload'], 10, 2); $updater->register(); } public function testLatestReleaseIsOfferedWithItsPackage(): void { $this->givenRelease(200, self::releaseBody('v1.0.0', self::ZIP_NAME, self::ZIP)); $update = $this->updater()->offer(false, [], self::BASENAME); self::assertSame('1.0.0', $update['version']); self::assertSame(self::ZIP, $update['package']); self::assertSame('wp-lit-mcp', $update['slug']); self::assertSame('', $this->log()); } public function testOtherPluginsAreLeftAlone(): void { Functions\expect('wp_remote_get')->never(); self::assertFalse($this->updater()->offer(false, [], 'anderes/anderes.php')); } public function testGitCheckoutIsNeverOffered(): void { $dir = sys_get_temp_dir() . '/lit-mcp-checkout-' . uniqid(); mkdir($dir . '/.git', 0777, true); Functions\expect('wp_remote_get')->never(); try { $updater = new GiteaUpdater(self::BASENAME, $dir, new PackageSignature($this->publicKey)); self::assertFalse($updater->offer(false, [], self::BASENAME)); } finally { rmdir($dir . '/.git'); rmdir($dir); } } public function testUnreachableGiteaOffersNothingAndLogsWhy(): void { Functions\when('wp_remote_get')->justReturn(new WP_Error('http_request_failed', 'cURL error 28')); self::assertFalse($this->updater()->offer(false, [], self::BASENAME)); self::assertStringContainsString('update check failed: cURL error 28', $this->log()); } public function testHttpErrorOffersNothingAndLogsTheStatus(): void { $this->givenRelease(404, '{"message":"not found"}'); self::assertFalse($this->updater()->offer(false, [], self::BASENAME)); self::assertStringContainsString('update check failed: HTTP 404', $this->log()); } /** @dataProvider unusableReleases */ public function testUnusableReleaseOffersNothingAndLogsIt(string $body): void { $this->givenRelease(200, $body); self::assertFalse($this->updater()->offer(false, [], self::BASENAME)); self::assertStringContainsString('update check failed', $this->log()); } /** @return array */ public static function unusableReleases(): array { $foreign = 'https://evil.example/releases/download/v1.0.0/' . self::ZIP_NAME; return [ 'no JSON' => [''], 'tag is no version' => [self::releaseBody('latest', self::ZIP_NAME, self::ZIP)], 'pre-release tag' => [self::releaseBody('v1.0.0-rc1', self::ZIP_NAME, self::ZIP)], 'tag with trailing newline' => [self::releaseBody("v1.0.0\n", self::ZIP_NAME, self::ZIP)], 'asset missing' => [self::releaseBody('v1.0.0', 'other.zip', self::ZIP)], 'release without assets' => ['{"tag_name":"v1.0.0"}'], 'package from a foreign host' => [self::releaseBody('v1.0.0', self::ZIP_NAME, $foreign)], 'package outside the release downloads' => [ self::releaseBody('v1.0.0', self::ZIP_NAME, 'https://' . GiteaUpdater::HOST . '/other/repo/x.zip'), ], ]; } public function testDownloadWithAValidSignatureIsHandedToWordPress(): void { $zip = $this->tempFile('release bytes'); $this->givenDownload($zip, $this->sign(self::ZIP_NAME, $zip)); self::assertSame($zip, $this->updater()->verifyDownload(false, self::ZIP)); self::assertFileExists($zip); } public function testDownloadWithAForeignSignatureIsDeletedAndRefused(): void { $zip = $this->tempFile('release bytes'); $other = $this->tempFile('other bytes'); $this->givenDownload($zip, $this->sign(self::ZIP_NAME, $other)); $result = $this->updater()->verifyDownload(false, self::ZIP); self::assertInstanceOf(WP_Error::class, $result); self::assertFileDoesNotExist($zip); self::assertStringContainsString('update failed: signature of ' . self::ZIP_NAME, $this->log()); } public function testSignatureOfAnotherVersionDoesNotPassAsThisOne(): void { $zip = $this->tempFile('old release bytes'); $this->givenDownload($zip, $this->sign('wp-lit-mcp-0.9.0.zip', $zip)); self::assertInstanceOf(WP_Error::class, $this->updater()->verifyDownload(false, self::ZIP)); } public function testDownloadWithoutSignatureFileIsRefused(): void { $zip = $this->tempFile('release bytes'); $this->givenDownload($zip, ''); self::assertInstanceOf(WP_Error::class, $this->updater()->verifyDownload(false, self::ZIP)); self::assertFileDoesNotExist($zip); } public function testFailedDownloadIsReturnedAsIs(): void { $error = new WP_Error('download_failed', 'cURL error 28'); Functions\expect('download_url')->once()->with(self::ZIP)->andReturn($error); self::assertSame($error, $this->updater()->verifyDownload(false, self::ZIP)); } public function testOtherPackagesAndEarlierDecisionsAreNotTouched(): void { Functions\expect('download_url')->never(); self::assertFalse($this->updater()->verifyDownload(false, 'https://downloads.wordpress.org/plugin/x.zip')); self::assertSame('/tmp/earlier.zip', $this->updater()->verifyDownload('/tmp/earlier.zip', self::ZIP)); } private function updater(): GiteaUpdater { // __DIR__ has no .git folder. return new GiteaUpdater(self::BASENAME, __DIR__, new PackageSignature($this->publicKey)); } private function tempFile(string $contents = ''): string { $file = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-test'); file_put_contents($file, $contents); $this->tempFiles[] = $file; return $file; } private function sign(string $assetName, string $zipPath): string { $message = PackageSignature::message($assetName, $zipPath); return base64_encode(sodium_crypto_sign_detached($message, $this->secretKey)); } private function log(): string { return (string) file_get_contents($this->logFile); } private function givenDownload(string $zip, string $signatureBody): void { Functions\expect('download_url')->once()->with(self::ZIP)->andReturn($zip); Functions\expect('wp_safe_remote_get')->once() ->with(self::ZIP . '.sig', Mockery::type('array')) ->andReturn(['body' => $signatureBody]); Functions\when('wp_remote_retrieve_body')->justReturn($signatureBody); Functions\when('wp_delete_file')->alias(static function (string $file): void { unlink($file); }); } private function givenRelease(int $status, string $body): void { $latestRelease = Mockery::pattern('#^https://git\.loosen-it\.de/api/v1/repos/.+/releases/latest$#'); Functions\expect('wp_remote_get')->once() ->with($latestRelease, Mockery::type('array')) ->andReturn(['body' => $body]); Functions\when('wp_remote_retrieve_response_code')->justReturn($status); Functions\when('wp_remote_retrieve_body')->justReturn($body); } private static function releaseBody(string $tag, string $assetName, string $url): string { // Data providers run before setUp(), so the wp_json_encode() stub does not exist yet. return (string) json_encode([ // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode 'tag_name' => $tag, 'assets' => [['name' => $assetName, 'browser_download_url' => $url]], ]); } }