- Divi-Vollformat: headingLevel nur h1 bis h6 (ValueCleaner, StoredNodeGuard, ShortformWriter); sonst ließ sich für Konten ohne unfiltered_html ein Skript-Element erzeugen. - content-update, divi-update und wp-settings-update sind destructive, damit MCP-Clients nachfragen. - mcp-adapter/execute-ability und get-ability-info nehmen auf dem OAuth-Server nur lit/-Abilities an. - release-publish.sh übergibt den Gitea-Token per Konfigurationsdatei statt als curl-Argument. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
297 lines
12 KiB
PHP
297 lines
12 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Settings;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Abilities\WriteFailed;
|
||
use LIT\MCP\Integrations\WordPress\Settings\SettingsWhitelist;
|
||
use LIT\MCP\Integrations\WordPress\Settings\WpSettingsGetAbility;
|
||
use LIT\MCP\Integrations\WordPress\Settings\WpSettingsUpdateAbility;
|
||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use RuntimeException;
|
||
use WP_Error;
|
||
|
||
final class WpSettingsAbilitiesTest extends TestCase
|
||
{
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
// Like core: blogname/blogdescription are stored esc_html()'d, the rest as given here.
|
||
Functions\when('sanitize_option')->alias(static fn (string $key, $value) => in_array($key, ['blogname', 'blogdescription'], true)
|
||
? htmlspecialchars((string) $value, ENT_QUOTES)
|
||
: $value);
|
||
Functions\when('wp_specialchars_decode')->alias(static fn (string $text, $quotes = ENT_NOQUOTES): string => htmlspecialchars_decode($text, ENT_QUOTES));
|
||
}
|
||
|
||
/**
|
||
* A stateful option store: update_option() sanitizes like core and returns false when
|
||
* nothing changes.
|
||
*
|
||
* @param array<string, mixed> $options
|
||
*/
|
||
private function optionStore(array &$options): void
|
||
{
|
||
Functions\when('get_option')->alias(static function (string $key) use (&$options) {
|
||
return $options[$key];
|
||
});
|
||
Functions\when('update_option')->alias(static function (string $key, $value) use (&$options): bool {
|
||
$value = in_array($key, ['blogname', 'blogdescription'], true) ? htmlspecialchars((string) $value, ENT_QUOTES) : $value;
|
||
if ($options[$key] === $value) {
|
||
return false;
|
||
}
|
||
$options[$key] = $value;
|
||
|
||
return true;
|
||
});
|
||
}
|
||
|
||
public function testTextWithAnAmpersandCanBeSetTwiceAlongsideAnotherKey(): void
|
||
{
|
||
$options = ['blogname' => 'Alt', 'blogdescription' => 'Praxis'];
|
||
$this->optionStore($options);
|
||
$log = new FakeChangeLog(40);
|
||
$ability = new WpSettingsUpdateAbility(new SettingsWhitelist(), $log);
|
||
$input = ['settings' => ['blogname' => 'Müller & Söhne', 'blogdescription' => 'Zahnärzte']];
|
||
|
||
$first = $ability->execute($input);
|
||
$second = $ability->execute($input);
|
||
|
||
self::assertSame('Müller & Söhne', $options['blogname']);
|
||
self::assertSame(['blogname', 'blogdescription'], array_column($first['changed'], 'id'));
|
||
self::assertTrue($second['ok']);
|
||
self::assertSame([], $second['changed']);
|
||
self::assertSame(['blogname' => 'Müller & Söhne', 'blogdescription' => 'Zahnärzte'], $second['previous']);
|
||
}
|
||
|
||
public function testGetReturnsTextAsTheUserWouldTypeIt(): void
|
||
{
|
||
Functions\when('get_option')->alias(static fn (string $key) => $key === 'blogname' ? 'Müller & Söhne "A"' : '1');
|
||
|
||
$settings = (new WpSettingsGetAbility(new SettingsWhitelist()))->execute(['group' => 'general'])['settings'];
|
||
|
||
self::assertSame('Müller & Söhne "A"', $settings[0]['value']);
|
||
}
|
||
|
||
public function testAFailingChangeLogIsOnlyAWarning(): void
|
||
{
|
||
$options = ['blogname' => 'Alt'];
|
||
$this->optionStore($options);
|
||
$log = new FakeChangeLog(1, new RuntimeException('log kaputt'));
|
||
$file = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
|
||
$previous = ini_set('error_log', $file);
|
||
|
||
try {
|
||
$result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), $log))->execute(['settings' => ['blogname' => 'Neu']]);
|
||
} finally {
|
||
ini_set('error_log', (string) $previous);
|
||
unlink($file);
|
||
}
|
||
|
||
self::assertTrue($result['ok']);
|
||
self::assertNull($result['log_id']);
|
||
self::assertCount(1, $result['warnings']);
|
||
$log->assertRecorded([]);
|
||
}
|
||
|
||
protected function tearDown(): void
|
||
{
|
||
unset($GLOBALS['wp_rewrite']);
|
||
parent::tearDown();
|
||
}
|
||
|
||
public function testGetFiltersByGroup(): void
|
||
{
|
||
Functions\when('get_option')->justReturn('Kunde');
|
||
|
||
$result = (new WpSettingsGetAbility(new SettingsWhitelist()))->execute(['group' => 'discussion']);
|
||
|
||
self::assertSame(['default_comment_status', 'default_ping_status'], array_column($result['settings'], 'key'));
|
||
self::assertSame('discussion', $result['settings'][0]['group']);
|
||
}
|
||
|
||
public function testUpdateIsAllOrNothing(): void
|
||
{
|
||
Functions\expect('update_option')->never();
|
||
|
||
$result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), FakeChangeLog::forbidden()))
|
||
->execute(['settings' => ['blogname' => 'Neu', 'admin_email' => 'x@y.de']]);
|
||
|
||
self::assertToolError($result, 'validation_failed');
|
||
self::assertArrayHasKey('admin_email', self::errorFields($result));
|
||
}
|
||
|
||
public function testRejectsAGenuinelyUnknownKey(): void
|
||
{
|
||
Functions\expect('update_option')->never();
|
||
|
||
$result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), FakeChangeLog::forbidden()))
|
||
->execute(['settings' => ['template' => 'twentyten']]);
|
||
|
||
self::assertToolError($result, 'validation_failed');
|
||
self::assertArrayHasKey('template', self::errorFields($result));
|
||
}
|
||
|
||
public function testUpdatesOnlyChangedValuesAndFlushesPermalinks(): void
|
||
{
|
||
// A mutating stub: writeOne() verifies a write via get_option() afterwards, so
|
||
// the stub must actually reflect what update_option()/set_permalink_structure()
|
||
// "wrote", not just return a fixed value.
|
||
$options = [
|
||
'blogname' => 'Alt',
|
||
'blogdescription' => 'Gleich',
|
||
'permalink_structure' => '',
|
||
];
|
||
Functions\when('get_option')->alias(static function (string $key) use (&$options) {
|
||
return $options[$key];
|
||
});
|
||
Functions\expect('update_option')->once()->with('blogname', 'Neu')
|
||
->andReturnUsing(static function (string $key, $value) use (&$options): bool {
|
||
$options[$key] = $value;
|
||
|
||
return true;
|
||
});
|
||
$rewrite = Mockery::mock();
|
||
$rewrite->expects('set_permalink_structure')->with('/%postname%/')
|
||
->andReturnUsing(static function (string $structure) use (&$options): void {
|
||
$options['permalink_structure'] = $structure;
|
||
});
|
||
$GLOBALS['wp_rewrite'] = $rewrite;
|
||
Functions\expect('flush_rewrite_rules')->once();
|
||
$log = new FakeChangeLog(30);
|
||
|
||
$result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), $log))->execute(['settings' => [
|
||
'blogname' => 'Neu',
|
||
'blogdescription' => 'Gleich',
|
||
'permalink_structure' => '/%postname%/',
|
||
]]);
|
||
|
||
self::assertSame(['blogname', 'permalink_structure'], array_column($result['changed'], 'id'));
|
||
self::assertSame(['blogname' => 'Alt', 'blogdescription' => 'Gleich', 'permalink_structure' => ''], $result['previous']);
|
||
self::assertSame(30, $result['log_id']);
|
||
$log->assertRecorded([
|
||
'ability' => 'lit/wp-settings-update',
|
||
'object_type' => 'option',
|
||
'object_id' => 'blogname,permalink_structure',
|
||
]);
|
||
}
|
||
|
||
public function testDoesNotTreatAnAlreadyStoredValueAsAWriteFailure(): void
|
||
{
|
||
// update_option() returns false both for a genuine failure and for a value that
|
||
// already matches the stored option. WPLANG's public value "en_US" normalizes to
|
||
// WordPress' internal "" – if the "already set?" check compared the raw client
|
||
// input against SettingsWhitelist::current()'s aliased public form instead of the
|
||
// normalized value against the actual stored option, this looked like a change,
|
||
// update_option() returned false because nothing needed to change, and that false
|
||
// was wrongly read as a failure – rolling back the unrelated blogname change too.
|
||
Functions\when('get_option')->alias(static fn (string $key) => [
|
||
'blogname' => 'Alt',
|
||
'WPLANG' => '',
|
||
][$key]);
|
||
Functions\expect('update_option')->once()->with('blogname', 'Neu')->andReturn(true);
|
||
$log = new FakeChangeLog(31);
|
||
|
||
$result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), $log))->execute(['settings' => [
|
||
'blogname' => 'Neu',
|
||
'WPLANG' => 'en_US',
|
||
]]);
|
||
|
||
self::assertSame(['blogname'], array_column($result['changed'], 'id'));
|
||
self::assertSame(31, $result['log_id']);
|
||
$log->assertRecorded([]);
|
||
}
|
||
|
||
public function testRestoresPreviousValuesWhenAWriteFailsPartway(): void
|
||
{
|
||
Functions\when('get_option')->alias(static fn (string $key) => [
|
||
'blogname' => 'Alt',
|
||
'blogdescription' => 'Vorher',
|
||
][$key]);
|
||
Functions\expect('update_option')->once()->with('blogname', 'Neu')->andReturn(true);
|
||
Functions\expect('update_option')->once()->with('blogdescription', 'Auch neu')->andReturn(false);
|
||
Functions\expect('update_option')->once()->with('blogname', 'Alt')->andReturn(true);
|
||
$log = FakeChangeLog::forbidden();
|
||
|
||
$error = $this->failure(new WpSettingsUpdateAbility(new SettingsWhitelist(), $log), [
|
||
'blogname' => 'Neu',
|
||
'blogdescription' => 'Auch neu',
|
||
]);
|
||
|
||
self::assertToolError($error, 'write_failed');
|
||
$log->assertRecorded();
|
||
$log->assertPartials();
|
||
}
|
||
|
||
public function testReportsPartialWriteWhenRestoringAlsoFails(): void
|
||
{
|
||
// Stateful: the restore write must genuinely not take effect (stay "Neu") for
|
||
// isStored() to correctly report the rollback as incomplete.
|
||
$options = [
|
||
'blogname' => 'Alt',
|
||
'blogdescription' => 'Vorher',
|
||
];
|
||
Functions\when('get_option')->alias(static function (string $key) use (&$options) {
|
||
return $options[$key];
|
||
});
|
||
Functions\expect('update_option')->once()->with('blogname', 'Neu')
|
||
->andReturnUsing(static function (string $key, $value) use (&$options): bool {
|
||
$options[$key] = $value;
|
||
|
||
return true;
|
||
});
|
||
Functions\expect('update_option')->once()->with('blogdescription', 'Auch neu')->andReturn(false);
|
||
Functions\expect('update_option')->once()->with('blogname', 'Alt')->andReturn(false);
|
||
$log = FakeChangeLog::partialsOnly();
|
||
|
||
$error = $this->failure(new WpSettingsUpdateAbility(new SettingsWhitelist(), $log), [
|
||
'blogname' => 'Neu',
|
||
'blogdescription' => 'Auch neu',
|
||
]);
|
||
|
||
self::assertToolError($error, 'partial_write');
|
||
self::assertStringContainsString('blogname', $error->get_error_message());
|
||
self::assertStringContainsString('lit/wp-settings-get', $error->get_error_data()['hint']);
|
||
$log->assertPartials([
|
||
'ability' => 'lit/wp-settings-update',
|
||
'object_type' => 'option',
|
||
'object_id' => 'blogname',
|
||
]);
|
||
}
|
||
|
||
/** @param array<string, mixed> $settings */
|
||
private function failure(WpSettingsUpdateAbility $ability, array $settings): WP_Error
|
||
{
|
||
try {
|
||
$ability->execute(['settings' => $settings]);
|
||
} catch (WriteFailed $e) {
|
||
return $e->toError();
|
||
}
|
||
|
||
self::fail('WriteFailed expected');
|
||
}
|
||
|
||
public function testSettingsToolsNeedManageOptions(): void
|
||
{
|
||
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'manage_options');
|
||
|
||
self::assertToolError((new WpSettingsGetAbility(new SettingsWhitelist()))->permission([]), 'forbidden');
|
||
self::assertSame(
|
||
'lit_forbidden',
|
||
(new WpSettingsUpdateAbility(new SettingsWhitelist(), FakeChangeLog::forbidden()))->permission([])->get_error_code()
|
||
);
|
||
}
|
||
|
||
/** Security audit 2026-11, B2: site settings (front page, permalinks, title) are overwritten. */
|
||
public function testUpdatingSettingsIsMarkedDestructive(): void
|
||
{
|
||
$annotations = (new WpSettingsUpdateAbility(new SettingsWhitelist(), FakeChangeLog::forbidden()))->annotations();
|
||
|
||
self::assertTrue($annotations['destructive']);
|
||
self::assertFalse($annotations['readonly']);
|
||
}
|
||
}
|