Prepare auto updates

This commit is contained in:
Jan-Niclas Loosen
2026-11-04 13:51:01 +01:00
parent a066d47bfd
commit 3337a63c5d
15 changed files with 784 additions and 16 deletions
+16 -11
View File
@@ -115,13 +115,16 @@ function update( WP_REST_Request $request ) {
// Create preview post link.
// Equivalent of this on WordPress block editor can be seen at WP_REST_Autosaves_Controller->prepare_item_for_response().
$preview_url = get_preview_post_link(
$post_id,
[
'preview_id' => $post_id,
'preview_nonce' => wp_create_nonce( 'post_preview_' . $post_id ),
]
);
$preview_query_args = [
'preview_id' => $post_id,
'preview_nonce' => wp_create_nonce( 'post_preview_' . $post_id ),
];
if ( ET_BUILDER_LAYOUT_POST_TYPE === get_post_type( $post_id ) ) {
$preview_query_args['p'] = $post_id;
}
$preview_url = get_preview_post_link( $post_id, $preview_query_args );
return self::response_success(
[
@@ -192,11 +195,13 @@ function update( WP_REST_Request $request ) {
}
}
// Persist main post content/meta only for singular VB saves.
// On non-singular pages (home/archive), request post_id may be the first
// main-query loop post and must not be converted to Divi (#49679).
// Persist main post content/meta only for singular VB saves, plus builder-enabled WooCommerce shop pages
// that hydrate post content while keeping `mainLoopType` as `post_type_archive` (#52168).
// On other non-singular pages (home/archive), request post_id may be the first main-query loop post
// and must not be converted to Divi (#49679).
$main_post = get_post( $post_id );
$should_persist_main_post = $main_post instanceof \WP_Post && 'singular' === $main_loop_type;
$should_persist_main_post = $main_post instanceof \WP_Post
&& self::_should_persist_main_post_content( $post_id, $main_loop_type );
if (
$should_persist_main_post
+2
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit;
use LIT\MCP\Plugin;
use LIT\MCP\Updater\GiteaUpdater;
/** The plugin header: version in step with Plugin::VERSION, neutral wording (no chatbot branding). */
final class PluginHeaderTest extends TestCase
@@ -16,5 +17,6 @@ final class PluginHeaderTest extends TestCase
self::assertMatchesRegularExpression('/^ \* Description: .*KI-Assistenten\.$/m', $header);
self::assertStringNotContainsString('claude', strtolower($header));
self::assertStringContainsString(' * Version: ' . Plugin::VERSION . "\n", $header);
self::assertStringContainsString(' * Update URI: ' . GiteaUpdater::REPOSITORY_URL . "\n", $header);
}
}
+251
View File
@@ -0,0 +1,251 @@
<?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]],
]);
}
}
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Updater;
use LIT\MCP\Updater\PackageSignature;
use PHPUnit\Framework\TestCase;
final class PackageSignatureTest extends TestCase
{
private string $zip = '';
private string $secretKey = '';
private PackageSignature $signature;
protected function setUp(): void
{
$pair = sodium_crypto_sign_keypair();
$this->secretKey = sodium_crypto_sign_secretkey($pair);
$this->signature = new PackageSignature(base64_encode(sodium_crypto_sign_publickey($pair)));
$this->zip = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-sig');
file_put_contents($this->zip, 'zip bytes');
}
protected function tearDown(): void
{
unlink($this->zip);
}
public function testASignatureOverNameAndContentVerifies(): void
{
$signature = $this->sign('wp-lit-mcp-1.0.0.zip');
self::assertTrue($this->signature->verifies('wp-lit-mcp-1.0.0.zip', $this->zip, $signature));
}
public function testAnotherAssetNameDoesNotVerify(): void
{
$signature = $this->sign('wp-lit-mcp-1.0.0.zip');
self::assertFalse($this->signature->verifies('wp-lit-mcp-1.0.1.zip', $this->zip, $signature));
}
public function testChangedContentDoesNotVerify(): void
{
$signature = $this->sign('x.zip');
file_put_contents($this->zip, 'changed bytes');
self::assertFalse($this->signature->verifies('x.zip', $this->zip, $signature));
}
public function testAnotherKeyDoesNotVerify(): void
{
$foreign = new PackageSignature(base64_encode(sodium_crypto_sign_publickey(sodium_crypto_sign_keypair())));
self::assertFalse($foreign->verifies('x.zip', $this->zip, $this->sign('x.zip')));
}
/** @dataProvider malformedInput */
public function testMalformedInputFailsClosed(string $signature, string $publicKey, string $zipPath): void
{
$subject = new PackageSignature($publicKey === 'real' ? $this->realKey() : $publicKey);
self::assertFalse($subject->verifies('x.zip', $zipPath === 'zip' ? $this->zip : $zipPath, $signature));
}
/** @return array<string, array{string, string, string}> */
public static function malformedInput(): array
{
return [
'signature is no base64' => ['not base64 !!', 'real', 'zip'],
'signature is empty' => ['', 'real', 'zip'],
'signature has the wrong length' => [base64_encode('short'), 'real', 'zip'],
'no key configured' => ['AAAA', '', 'zip'],
'key has the wrong length' => ['AAAA', base64_encode('short'), 'zip'],
'file is missing' => ['AAAA', 'real', '/nonexistent/x.zip'],
];
}
private function sign(string $assetName): string
{
$message = PackageSignature::message($assetName, $this->zip);
return base64_encode(sodium_crypto_sign_detached($message, $this->secretKey));
}
private function realKey(): string
{
return base64_encode(sodium_crypto_sign_publickey(sodium_crypto_sign_keypair()));
}
}