Files
WP-LIT-MCP/tests/Unit/Integrations/WordPress/Settings/WpSettingsAbilitiesTest.php
T
Jan-Niclas LoosenandClaude Sonnet 5.5 165e61d7fe fix(security): headingLevel prüfen, überschreibende Tools kennzeichnen, Meta-Tools auf lit/ beschränken
- 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>
2026-11-05 16:36:16 +01:00

297 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 &amp; 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 &amp; Söhne &quot;A&quot;' : '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']);
}
}