fix: settings compare against the sanitized value, fail via WriteFailed
- isStored() compares the stored option with sanitize_option() of the new value (core esc_html()s blogname/blogdescription): setting "Müller & Söhne" again no longer looks like a failed write; lit/wp-settings-get and previous return those two decoded, as the user would type them. - The change log runs as FollowUp (warning on failure); a failed write throws WriteFailed (write_failed / partial_write with keys), partial writes are logged with result partial. - permalink_structure needs a real %tag% (WordPress' own /%[^/%]+%/ rule). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
@@ -64,6 +64,9 @@ final class SettingsWhitelistTest extends TestCase
|
||||
self::assertNull($whitelist->validate('permalink_structure', '/%postname%/', []));
|
||||
self::assertNull($whitelist->validate('permalink_structure', '', []));
|
||||
self::assertNotNull($whitelist->validate('permalink_structure', 'postname', []));
|
||||
self::assertNull($whitelist->validate('permalink_structure', '/blog/%year%/%postname%/', []));
|
||||
self::assertNotNull($whitelist->validate('permalink_structure', '/%%/', []));
|
||||
self::assertNotNull($whitelist->validate('permalink_structure', '/%/', []));
|
||||
self::assertNotNull($whitelist->validate('posts_per_page', 0, []));
|
||||
self::assertNotNull($whitelist->validate('default_comment_status', 'maybe', []));
|
||||
self::assertNull($whitelist->validate('site_icon', 33, []));
|
||||
|
||||
@@ -5,15 +5,99 @@ declare(strict_types=1);
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Settings;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Abilities\WriteFailed;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
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\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 = Mockery::mock(ChangeLog::class);
|
||||
$log->allows('record')->andReturn(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 = Mockery::mock(ChangeLog::class);
|
||||
$log->expects('record')->andThrow(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']);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
unset($GLOBALS['wp_rewrite']);
|
||||
@@ -129,20 +213,14 @@ final class WpSettingsAbilitiesTest extends TestCase
|
||||
Functions\expect('update_option')->once()->with('blogname', 'Alt')->andReturn(true);
|
||||
$log = Mockery::mock(ChangeLog::class);
|
||||
$log->shouldNotReceive('record');
|
||||
$file = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
|
||||
$previous = ini_set('error_log', $file);
|
||||
$log->shouldNotReceive('recordPartial');
|
||||
|
||||
try {
|
||||
$result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), $log))->execute(['settings' => [
|
||||
'blogname' => 'Neu',
|
||||
'blogdescription' => 'Auch neu',
|
||||
]]);
|
||||
} finally {
|
||||
ini_set('error_log', (string) $previous);
|
||||
unlink($file);
|
||||
}
|
||||
$error = $this->failure(new WpSettingsUpdateAbility(new SettingsWhitelist(), $log), [
|
||||
'blogname' => 'Neu',
|
||||
'blogdescription' => 'Auch neu',
|
||||
]);
|
||||
|
||||
self::assertSame('lit_write_failed', $result->get_error_code());
|
||||
self::assertSame('lit_write_failed', $error->get_error_code());
|
||||
}
|
||||
|
||||
public function testReportsPartialWriteWhenRestoringAlsoFails(): void
|
||||
@@ -166,21 +244,28 @@ final class WpSettingsAbilitiesTest extends TestCase
|
||||
Functions\expect('update_option')->once()->with('blogname', 'Alt')->andReturn(false);
|
||||
$log = Mockery::mock(ChangeLog::class);
|
||||
$log->shouldNotReceive('record');
|
||||
$file = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
|
||||
$previous = ini_set('error_log', $file);
|
||||
$log->expects('recordPartial')->with('lit/wp-settings-update', 'option', 'blogname', Mockery::type('string'));
|
||||
|
||||
$error = $this->failure(new WpSettingsUpdateAbility(new SettingsWhitelist(), $log), [
|
||||
'blogname' => 'Neu',
|
||||
'blogdescription' => 'Auch neu',
|
||||
]);
|
||||
|
||||
self::assertSame('lit_partial_write', $error->get_error_code());
|
||||
self::assertStringContainsString('blogname', $error->get_error_message());
|
||||
self::assertStringContainsString('lit/wp-settings-get', $error->get_error_data()['hint']);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $settings */
|
||||
private function failure(WpSettingsUpdateAbility $ability, array $settings): WP_Error
|
||||
{
|
||||
try {
|
||||
$result = (new WpSettingsUpdateAbility(new SettingsWhitelist(), $log))->execute(['settings' => [
|
||||
'blogname' => 'Neu',
|
||||
'blogdescription' => 'Auch neu',
|
||||
]]);
|
||||
} finally {
|
||||
ini_set('error_log', (string) $previous);
|
||||
unlink($file);
|
||||
$ability->execute(['settings' => $settings]);
|
||||
} catch (WriteFailed $e) {
|
||||
return $e->toError();
|
||||
}
|
||||
|
||||
self::assertSame('lit_partial_write', $result->get_error_code());
|
||||
self::assertStringContainsString('blogname', $result->get_error_message());
|
||||
self::fail('WriteFailed expected');
|
||||
}
|
||||
|
||||
public function testSettingsToolsNeedManageOptions(): void
|
||||
|
||||
Reference in New Issue
Block a user