Without /D, "$" also matches before a trailing "\n", so "custom_meta_telefon\n", a color setting "5\n" (stored as sent) or a whole reference followed by "\n" passed VariableRef. Added /D to every ^...$ pattern that validates client or admin input: - VariableRef META_KEY, NUMBER, WHOLE (canonical/typeOf; Placeholders::field shares META_KEY) - Cursor::decode, GuideRepository topic names (MCP input and filter keys) - ExposurePolicy::sanitize ability keys and group ids, LogTab list filters Not changed: Placeholders::PATTERN (runs on trim()), Warnings hex checks (hints only), NodeSummary/AttrPathIndex/LogListTable (stored or Divi data, no client input). Each fixed pattern has a test with a trailing "\n" that is rejected. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
186 lines
7.9 KiB
PHP
186 lines
7.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace LIT\MCP\Tests\Unit\Admin;
|
|
|
|
use Brain\Monkey\Functions;
|
|
use LIT\MCP\Admin\LogTab;
|
|
use LIT\MCP\ChangeLog\ChangeLog;
|
|
use LIT\MCP\Limits;
|
|
use LIT\MCP\Tests\Unit\TestCase;
|
|
use Mockery;
|
|
use RuntimeException;
|
|
|
|
final class LogTabTest extends TestCase
|
|
{
|
|
protected function tearDown(): void
|
|
{
|
|
unset($_GET['ability'], $_GET['user_id'], $_GET['result'], $_GET['date_from'], $_GET['date_to']);
|
|
parent::tearDown();
|
|
}
|
|
|
|
public function testFiltersAreReadSanitized(): void
|
|
{
|
|
$tab = new LogTab(Mockery::mock(ChangeLog::class), new Limits());
|
|
$_GET['ability'] = 'lit/content-update';
|
|
$_GET['user_id'] = '3x';
|
|
|
|
self::assertSame(['ability' => 'lit/content-update', 'user_id' => 3], $tab->filters());
|
|
|
|
$_GET['ability'] = 'lit/x"><script>';
|
|
self::assertSame('', $tab->filters()['ability']);
|
|
}
|
|
|
|
public function testExtraFiltersWhitelistResultAndDateRange(): void
|
|
{
|
|
$tab = new LogTab(Mockery::mock(ChangeLog::class), new Limits());
|
|
|
|
self::assertSame(
|
|
['result' => '', 'date_from' => '', 'date_to' => ''],
|
|
$tab->extraFilters(),
|
|
'absent GET params sanitize to empty strings'
|
|
);
|
|
|
|
$_GET['result'] = 'partial';
|
|
$_GET['date_from'] = '2026-09-01';
|
|
$_GET['date_to'] = '2026-09-25';
|
|
|
|
self::assertSame(
|
|
['result' => 'partial', 'date_from' => '2026-09-01', 'date_to' => '2026-09-25'],
|
|
$tab->extraFilters()
|
|
);
|
|
}
|
|
|
|
public function testExtraFiltersRejectUnknownResultAndMalformedDates(): void
|
|
{
|
|
$tab = new LogTab(Mockery::mock(ChangeLog::class), new Limits());
|
|
|
|
$_GET['result'] = 'ok"><script>';
|
|
$_GET['date_from'] = 'not-a-date';
|
|
$_GET['date_to'] = '2026-13-99'; // wrong shape is enough to reject; no calendar validation needed here.
|
|
|
|
self::assertSame(['result' => '', 'date_from' => '', 'date_to' => '2026-13-99'], $tab->extraFilters());
|
|
}
|
|
|
|
/**
|
|
* "$" without /D also matches before a trailing newline. sanitize_text_field() already drops
|
|
* it in WordPress; the whitelist must hold on its own, so the sanitizer passes it through here.
|
|
*/
|
|
public function testFiltersRejectATrailingNewlineEvenWithoutTheSanitizer(): void
|
|
{
|
|
Functions\when('sanitize_text_field')->returnArg();
|
|
$tab = new LogTab(Mockery::mock(ChangeLog::class), new Limits());
|
|
$_GET['ability'] = "lit/content-update\n";
|
|
$_GET['date_from'] = "2026-09-01\n";
|
|
$_GET['date_to'] = "2026-09-25\n";
|
|
|
|
self::assertSame('', $tab->filters()['ability']);
|
|
self::assertSame(['result' => '', 'date_from' => '', 'date_to' => ''], $tab->extraFilters());
|
|
}
|
|
|
|
public function testClearNeedsNonceAndRights(): void
|
|
{
|
|
Functions\stubTranslationFunctions();
|
|
Functions\when('current_user_can')->justReturn(true);
|
|
Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://kunde.de/wp-admin/' . $path);
|
|
Functions\when('add_query_arg')->alias(static fn (array $args, string $url): string => $url . '?' . http_build_query($args));
|
|
Functions\expect('check_admin_referer')->once()->with('lit_mcp_clear_log');
|
|
Functions\expect('wp_safe_redirect')->once()->with(Mockery::on(
|
|
static fn (string $url): bool => str_contains($url, 'tab=protokoll') && str_contains($url, 'lit_mcp_notice=cleared')
|
|
));
|
|
$log = Mockery::mock(ChangeLog::class);
|
|
$log->expects('clear')->once();
|
|
$terminated = false;
|
|
|
|
(new LogTab($log, new Limits(), static function () use (&$terminated): void {
|
|
$terminated = true;
|
|
}))->handleClear();
|
|
|
|
self::assertTrue($terminated);
|
|
}
|
|
|
|
public function testClearRequiresManageOptions(): void
|
|
{
|
|
Functions\stubTranslationFunctions();
|
|
Functions\expect('current_user_can')->once()->with('manage_options')->andReturn(false);
|
|
Functions\expect('wp_die')->once()->andThrow(new RuntimeException('died'));
|
|
Functions\expect('check_admin_referer')->never();
|
|
$log = Mockery::mock(ChangeLog::class);
|
|
$log->expects('clear')->never();
|
|
|
|
$this->expectException(RuntimeException::class);
|
|
(new LogTab($log, new Limits()))->handleClear();
|
|
}
|
|
|
|
public function testRegistersTheClearAction(): void
|
|
{
|
|
$tab = new LogTab(Mockery::mock(ChangeLog::class), new Limits());
|
|
Functions\expect('add_action')->once()->with('admin_post_lit_mcp_clear_log', [$tab, 'handleClear']);
|
|
|
|
$tab->registerActions();
|
|
}
|
|
|
|
public function testRenderShowsFiltersAndAConfirmProtectedClearButton(): void
|
|
{
|
|
Functions\stubEscapeFunctions();
|
|
Functions\stubTranslationFunctions();
|
|
Functions\when('selected')->alias(static fn ($a, $b = true, $echo = true): string => $a === $b ? ' selected="selected"' : '');
|
|
Functions\when('checked')->justReturn('');
|
|
Functions\when('settings_fields')->justReturn(null);
|
|
Functions\when('submit_button')->alias(static function (
|
|
string $text = '',
|
|
string $type = 'primary',
|
|
string $name = 'submit',
|
|
bool $wrap = true,
|
|
$other = ''
|
|
): void {
|
|
$attrs = is_array($other) ? implode(' ', array_map(
|
|
static fn ($k, $v): string => sprintf('%s="%s"', $k, $v),
|
|
array_keys($other),
|
|
$other
|
|
)) : (string) $other;
|
|
printf('<button type="submit" name="%s" %s>%s</button>', esc_attr($name), $attrs, esc_html($text));
|
|
});
|
|
Functions\when('wp_nonce_field')->alias(static function (string $action): void {
|
|
echo '<input type="hidden" name="_wpnonce" value="nonce-' . $action . '">';
|
|
});
|
|
Functions\when('wp_dropdown_users')->alias(static function (array $args): void {
|
|
echo '<select name="' . $args['name'] . '" id="' . $args['id'] . '"><option>Alle</option></select>';
|
|
});
|
|
Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://kunde.de/wp-admin/' . $path);
|
|
Functions\when('get_option')->justReturn([]);
|
|
Functions\when('add_query_arg')->alias(static fn (array $args, string $url): string => $url . '?' . http_build_query($args));
|
|
|
|
$log = Mockery::mock(ChangeLog::class);
|
|
$log->allows('abilities')->andReturn(['lit/content-update', 'lit/x"><script>']);
|
|
$log->allows('query')->andReturn(['rows' => [], 'total' => 0]);
|
|
|
|
ob_start();
|
|
(new LogTab($log, new Limits()))->render();
|
|
$html = (string) ob_get_clean();
|
|
|
|
self::assertStringContainsString('name="result"', $html);
|
|
self::assertStringContainsString('name="date_from"', $html);
|
|
self::assertStringContainsString('name="date_to"', $html);
|
|
self::assertStringContainsString('lit/content-update', $html);
|
|
self::assertStringNotContainsString('<script>', $html);
|
|
self::assertStringContainsString('lit_mcp_clear_log', $html);
|
|
self::assertStringContainsString('confirm(', $html);
|
|
self::assertStringContainsString('nonce-lit_mcp_clear_log', $html);
|
|
|
|
// One intro line; the retention days link to the Limits tab.
|
|
self::assertMatchesRegularExpression('#<p class="description">[^<]*<a href="[^"]*tab=limits">90 Tagen</a>#', $html);
|
|
// Every filter control has a (screen-reader) label.
|
|
foreach (['ability', 'user_id', 'result', 'date_from', 'date_to'] as $name) {
|
|
self::assertMatchesRegularExpression(
|
|
'#<label class="screen-reader-text" for="lit-mcp-filter-' . str_replace('_', '-', $name) . '">#',
|
|
$html,
|
|
'label for ' . $name
|
|
);
|
|
}
|
|
self::assertStringContainsString('<option value="ok">erfolgreich</option>', $html);
|
|
self::assertStringContainsString('<option value="partial">teilweise</option>', $html);
|
|
}
|
|
}
|