252 lines
9.3 KiB
PHP
252 lines
9.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace LIT\MCP\Tests\Unit\Updater;
|
|
|
|
use Brain\Monkey\Filters;
|
|
use Brain\Monkey\Functions;
|
|
use LIT\MCP\Tests\Unit\TestCase;
|
|
use LIT\MCP\Updater\GiteaUpdater;
|
|
use LIT\MCP\Updater\PackageSignature;
|
|
use Mockery;
|
|
use WP_Error;
|
|
|
|
final class GiteaUpdaterTest extends TestCase
|
|
{
|
|
private const BASENAME = 'wp-lit-mcp/wp-lit-mcp.php';
|
|
private const ZIP_NAME = 'wp-lit-mcp-1.0.0.zip';
|
|
private const ZIP = GiteaUpdater::REPOSITORY_URL . '/releases/download/v1.0.0/' . self::ZIP_NAME;
|
|
|
|
private string $logFile = '';
|
|
private string|false $previousLog = '';
|
|
private string $secretKey = '';
|
|
private string $publicKey = '';
|
|
/** @var list<string> */
|
|
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<string, array{string}> */
|
|
public static function unusableReleases(): array
|
|
{
|
|
$foreign = 'https://evil.example/releases/download/v1.0.0/' . self::ZIP_NAME;
|
|
|
|
return [
|
|
'no JSON' => ['<html>'],
|
|
'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]],
|
|
]);
|
|
}
|
|
}
|