feat: change log tab with filters, revision links and clear button
ChangeLog gains query()/abilities()/clear() (filters: ability, user, result, date range - all optional and additive). LogTab/LogListTable render the "Protokoll" admin tab: WP_List_Table with pagination, links to the post/attachment/nav_menu edit screen by object_type, a revision link shown only when the user may edit the revision's parent post, and a nonce+manage_options-gated "Protokoll leeren" with a JS confirmation and highlighted 'partial' rows. 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:
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Admin;
|
||||
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use WP_Post;
|
||||
use WP_Term;
|
||||
|
||||
// phpcs:disable PSR1.Methods.CamelCapsMethodName.NotCamelCaps -- method names are defined by WP_List_Table.
|
||||
|
||||
/**
|
||||
* List of change log entries (spec §8.3, ruling C5/Q13). WP_List_Table must be loaded
|
||||
* before this file (LogTab::render() takes care of that).
|
||||
*/
|
||||
final class LogListTable extends \WP_List_Table
|
||||
{
|
||||
private const PER_PAGE = 50;
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* ability: string, user_id: int, result?: string, date_from?: string, date_to?: string
|
||||
* } $filters
|
||||
*/
|
||||
public function __construct(private ChangeLog $log, private array $filters)
|
||||
{
|
||||
parent::__construct(['singular' => 'lit_mcp_log_entry', 'plural' => 'lit_mcp_log_entries', 'ajax' => false]);
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
public function get_columns(): array
|
||||
{
|
||||
return [
|
||||
'created_at' => __('Zeit (UTC)', 'wp-lit-mcp'),
|
||||
'user' => __('Benutzer', 'wp-lit-mcp'),
|
||||
'client' => __('Client', 'wp-lit-mcp'),
|
||||
'ability' => __('Tool', 'wp-lit-mcp'),
|
||||
'object' => __('Objekt', 'wp-lit-mcp'),
|
||||
'summary' => __('Zusammenfassung', 'wp-lit-mcp'),
|
||||
'revision' => __('Revision', 'wp-lit-mcp'),
|
||||
'result' => __('Ergebnis', 'wp-lit-mcp'),
|
||||
];
|
||||
}
|
||||
|
||||
public function prepare_items(): void
|
||||
{
|
||||
$page = max(1, (int) $this->get_pagenum());
|
||||
$data = $this->log->query($this->filters, self::PER_PAGE, ($page - 1) * self::PER_PAGE);
|
||||
$this->items = $data['rows'];
|
||||
$this->_column_headers = [$this->get_columns(), [], []];
|
||||
$this->set_pagination_args(['total_items' => $data['total'], 'per_page' => self::PER_PAGE]);
|
||||
}
|
||||
|
||||
public function no_items(): void
|
||||
{
|
||||
esc_html_e('Keine Einträge.', 'wp-lit-mcp');
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlights rows with result "partial" (a write that failed after an irreversible
|
||||
* step, spec §2 principle 7/8.2) so admins notice them without reading every row.
|
||||
*
|
||||
* @param array<string, mixed> $item
|
||||
*/
|
||||
public function single_row($item): void
|
||||
{
|
||||
$style = ($item['result'] ?? '') === 'partial' ? ' style="background:#fcf3e3;"' : '';
|
||||
printf('<tr%s>', $style); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- fixed string.
|
||||
$this->single_row_columns($item);
|
||||
echo '</tr>';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $item
|
||||
* @param string $column_name
|
||||
*/
|
||||
protected function column_default($item, $column_name): string
|
||||
{
|
||||
return match ($column_name) {
|
||||
'user' => $this->userName((int) ($item['user_id'] ?? 0)),
|
||||
'object' => $this->objectLink((string) ($item['object_type'] ?? ''), (string) ($item['object_id'] ?? '')),
|
||||
'revision' => $this->revisionLink((int) ($item['revision_id'] ?? 0)),
|
||||
default => esc_html((string) ($item[$column_name] ?? '')),
|
||||
};
|
||||
}
|
||||
|
||||
private function userName(int $userId): string
|
||||
{
|
||||
$user = get_userdata($userId);
|
||||
|
||||
return esc_html(is_object($user) ? (string) $user->display_name : '#' . $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Links by object_type (ruling C5/Q13): known post types (not attachment) via
|
||||
* get_edit_post_link(); attachment via the media edit screen; nav_menu (a term id)
|
||||
* via the menus screen; settings/menu_location/anything else has no admin screen
|
||||
* to link to and is shown as plain text.
|
||||
*/
|
||||
private function objectLink(string $objectType, string $objectId): string
|
||||
{
|
||||
$label = trim($objectType . ' ' . $objectId);
|
||||
$url = $objectId !== '' && ctype_digit($objectId) ? $this->urlFor($objectType, (int) $objectId) : '';
|
||||
|
||||
return $url !== '' ? sprintf('<a href="%s">%s</a>', esc_url($url), esc_html($label)) : esc_html($label);
|
||||
}
|
||||
|
||||
private function urlFor(string $objectType, int $id): string
|
||||
{
|
||||
if ($objectType === 'attachment') {
|
||||
return get_post($id) instanceof WP_Post ? admin_url('upload.php?item=' . $id) : '';
|
||||
}
|
||||
|
||||
if ($objectType === 'nav_menu') {
|
||||
return get_term($id, 'nav_menu') instanceof WP_Term ? admin_url('nav-menus.php?menu=' . $id) : '';
|
||||
}
|
||||
|
||||
if ($objectType !== '' && post_type_exists($objectType)) {
|
||||
$link = get_edit_post_link($id, 'raw');
|
||||
|
||||
return is_string($link) ? $link : '';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Only shown when the user could actually act on it: revision_id set and the
|
||||
* revision's parent post is one the current user may edit (ruling C5/Q13).
|
||||
*/
|
||||
private function revisionLink(int $revisionId): string
|
||||
{
|
||||
if ($revisionId <= 0) {
|
||||
return '–';
|
||||
}
|
||||
|
||||
$revision = get_post($revisionId);
|
||||
$parentId = $revision instanceof WP_Post ? (int) $revision->post_parent : 0;
|
||||
if ($parentId <= 0 || ! current_user_can('edit_post', $parentId)) {
|
||||
return '–';
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'<a href="%s">%s</a>',
|
||||
esc_url(admin_url('revision.php?revision=' . $revisionId)),
|
||||
esc_html__('ansehen / wiederherstellen', 'wp-lit-mcp')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Admin;
|
||||
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use LIT\MCP\Limits;
|
||||
|
||||
/**
|
||||
* Tab "Protokoll": filterable list of change log entries, links to revisions, and
|
||||
* "Protokoll leeren" (spec §8.3).
|
||||
*/
|
||||
final class LogTab implements AdminTab
|
||||
{
|
||||
public const CLEAR_ACTION = 'lit_mcp_clear_log';
|
||||
private const RESULTS = ['ok', 'partial'];
|
||||
|
||||
/** @var callable(): void */
|
||||
private $terminate;
|
||||
|
||||
public function __construct(private ChangeLog $log, private Limits $limits, ?callable $terminate = null)
|
||||
{
|
||||
$this->terminate = $terminate ?? static function (): void {
|
||||
exit;
|
||||
};
|
||||
}
|
||||
|
||||
public function slug(): string
|
||||
{
|
||||
return 'protokoll';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return __('Protokoll', 'wp-lit-mcp');
|
||||
}
|
||||
|
||||
public function registerSettings(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function registerActions(): void
|
||||
{
|
||||
add_action('admin_post_' . self::CLEAR_ACTION, [$this, 'handleClear']);
|
||||
}
|
||||
|
||||
/** @return array{ability: string, user_id: int} */
|
||||
public function filters(): array
|
||||
{
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only list filters.
|
||||
$ability = isset($_GET['ability']) ? sanitize_text_field(wp_unslash((string) $_GET['ability'])) : '';
|
||||
$userId = isset($_GET['user_id']) ? absint($_GET['user_id']) : 0;
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Recommended
|
||||
|
||||
return [
|
||||
'ability' => preg_match('#^[a-z0-9-]+/[a-z0-9-]+$#', $ability) === 1 ? $ability : '',
|
||||
'user_id' => $userId,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Additional whitelisted filters (ruling C5/Q13: ability, result, user, date range).
|
||||
* Kept separate from filters() so that method's return shape stays exactly what is
|
||||
* unit-tested (and what earlier callers of this tab may already rely on).
|
||||
*
|
||||
* @return array{result: string, date_from: string, date_to: string}
|
||||
*/
|
||||
public function extraFilters(): array
|
||||
{
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only list filters.
|
||||
$result = isset($_GET['result']) ? sanitize_text_field(wp_unslash((string) $_GET['result'])) : '';
|
||||
$dateFrom = isset($_GET['date_from']) ? sanitize_text_field(wp_unslash((string) $_GET['date_from'])) : '';
|
||||
$dateTo = isset($_GET['date_to']) ? sanitize_text_field(wp_unslash((string) $_GET['date_to'])) : '';
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Recommended
|
||||
|
||||
return [
|
||||
'result' => in_array($result, self::RESULTS, true) ? $result : '',
|
||||
'date_from' => self::isDate($dateFrom) ? $dateFrom : '',
|
||||
'date_to' => self::isDate($dateTo) ? $dateTo : '',
|
||||
];
|
||||
}
|
||||
|
||||
private static function isDate(string $value): bool
|
||||
{
|
||||
return preg_match('#^\d{4}-\d{2}-\d{2}$#', $value) === 1;
|
||||
}
|
||||
|
||||
public function render(): void
|
||||
{
|
||||
if (! class_exists('WP_List_Table')) {
|
||||
require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
|
||||
}
|
||||
|
||||
$filters = $this->filters();
|
||||
$extra = $this->extraFilters();
|
||||
printf(
|
||||
'<p>%s</p>',
|
||||
esc_html(sprintf(
|
||||
/* translators: %d: number of days */
|
||||
__('Einträge werden nach %d Tagen automatisch gelöscht (einstellbar im Tab „Limits“).', 'wp-lit-mcp'),
|
||||
$this->limits->logRetentionDays()
|
||||
))
|
||||
);
|
||||
printf('<p>%s</p>', esc_html__('Das Protokoll enthält nur Verweise, keine Inhalte.', 'wp-lit-mcp'));
|
||||
|
||||
$this->renderFilterForm($filters, $extra);
|
||||
|
||||
$table = new LogListTable($this->log, $filters + $extra);
|
||||
$table->prepare_items();
|
||||
$table->display();
|
||||
|
||||
$this->renderClearForm();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{ability: string, user_id: int} $filters
|
||||
* @param array{result: string, date_from: string, date_to: string} $extra
|
||||
*/
|
||||
private function renderFilterForm(array $filters, array $extra): void
|
||||
{
|
||||
echo '<form method="get">';
|
||||
printf('<input type="hidden" name="page" value="%s">', esc_attr(SettingsPage::SLUG));
|
||||
printf('<input type="hidden" name="tab" value="%s">', esc_attr($this->slug()));
|
||||
|
||||
echo '<select name="ability"><option value="">' . esc_html__('Alle Tools', 'wp-lit-mcp') . '</option>';
|
||||
foreach ($this->log->abilities() as $ability) {
|
||||
printf(
|
||||
'<option value="%1$s"%2$s>%1$s</option>',
|
||||
esc_attr($ability),
|
||||
selected($filters['ability'], $ability, false)
|
||||
);
|
||||
}
|
||||
echo '</select> ';
|
||||
|
||||
wp_dropdown_users([
|
||||
'name' => 'user_id',
|
||||
'show_option_all' => __('Alle Benutzer', 'wp-lit-mcp'),
|
||||
'selected' => $filters['user_id'],
|
||||
]);
|
||||
echo ' ';
|
||||
|
||||
echo '<select name="result">';
|
||||
printf('<option value="">%s</option>', esc_html__('Alle Ergebnisse', 'wp-lit-mcp'));
|
||||
foreach (self::RESULTS as $result) {
|
||||
printf(
|
||||
'<option value="%1$s"%2$s>%1$s</option>',
|
||||
esc_attr($result),
|
||||
selected($extra['result'], $result, false)
|
||||
);
|
||||
}
|
||||
echo '</select> ';
|
||||
|
||||
printf(
|
||||
'<label>%s <input type="date" name="date_from" value="%s"></label> ',
|
||||
esc_html__('von', 'wp-lit-mcp'),
|
||||
esc_attr($extra['date_from'])
|
||||
);
|
||||
printf(
|
||||
'<label>%s <input type="date" name="date_to" value="%s"></label> ',
|
||||
esc_html__('bis', 'wp-lit-mcp'),
|
||||
esc_attr($extra['date_to'])
|
||||
);
|
||||
|
||||
submit_button(__('Filtern', 'wp-lit-mcp'), 'secondary', '', false);
|
||||
echo '</form>';
|
||||
}
|
||||
|
||||
private function renderClearForm(): void
|
||||
{
|
||||
printf('<form method="post" action="%s">', esc_url(admin_url('admin-post.php')));
|
||||
printf('<input type="hidden" name="action" value="%s">', esc_attr(self::CLEAR_ACTION));
|
||||
wp_nonce_field(self::CLEAR_ACTION);
|
||||
$confirmMessage = esc_js(__(
|
||||
'Das gesamte Protokoll unwiderruflich löschen?',
|
||||
'wp-lit-mcp'
|
||||
));
|
||||
submit_button(
|
||||
__('Protokoll leeren', 'wp-lit-mcp'),
|
||||
'delete',
|
||||
'submit',
|
||||
false,
|
||||
['onclick' => "return window.confirm('" . $confirmMessage . "');"]
|
||||
);
|
||||
echo '</form>';
|
||||
}
|
||||
|
||||
public function handleClear(): void
|
||||
{
|
||||
if (! current_user_can('manage_options')) {
|
||||
wp_die(esc_html__('Keine Berechtigung.', 'wp-lit-mcp'), '', ['response' => 403]);
|
||||
}
|
||||
|
||||
check_admin_referer(self::CLEAR_ACTION);
|
||||
$this->log->clear();
|
||||
wp_safe_redirect(SettingsPage::url($this->slug(), ['lit_mcp_notice' => 'cleared']));
|
||||
($this->terminate)();
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,80 @@ class ChangeLog
|
||||
return (int) $wpdb->query($wpdb->prepare("DELETE FROM {$table} WHERE created_at < %s", $cutoff));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads rows for the "Protokoll" admin tab (spec §8.3), newest first. All filters are
|
||||
* optional and whitelisted by the caller (LogTab); values only ever reach the query
|
||||
* through wpdb::prepare() placeholders.
|
||||
*
|
||||
* @param array{
|
||||
* ability?: string, user_id?: int, result?: string, date_from?: string, date_to?: string
|
||||
* } $filters
|
||||
* @return array{rows: list<array<string, mixed>>, total: int}
|
||||
*/
|
||||
public function query(array $filters, int $limit, int $offset): array
|
||||
{
|
||||
$wpdb = $this->db();
|
||||
$table = $this->tableName();
|
||||
$where = ['1 = %d'];
|
||||
$args = [1];
|
||||
if (($filters['ability'] ?? '') !== '') {
|
||||
$where[] = 'ability = %s';
|
||||
$args[] = (string) $filters['ability'];
|
||||
}
|
||||
if ((int) ($filters['user_id'] ?? 0) > 0) {
|
||||
$where[] = 'user_id = %d';
|
||||
$args[] = (int) $filters['user_id'];
|
||||
}
|
||||
if (($filters['result'] ?? '') !== '') {
|
||||
$where[] = 'result = %s';
|
||||
$args[] = (string) $filters['result'];
|
||||
}
|
||||
if (($filters['date_from'] ?? '') !== '') {
|
||||
$where[] = 'created_at >= %s';
|
||||
$args[] = $filters['date_from'] . ' 00:00:00';
|
||||
}
|
||||
if (($filters['date_to'] ?? '') !== '') {
|
||||
$where[] = 'created_at <= %s';
|
||||
$args[] = $filters['date_to'] . ' 23:59:59';
|
||||
}
|
||||
$whereSql = implode(' AND ', $where);
|
||||
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $whereSql holds one %s/%d per filter actually present.
|
||||
$total = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE {$whereSql}", ...$args));
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE {$whereSql} ORDER BY id DESC LIMIT %d OFFSET %d",
|
||||
...array_merge($args, [$limit, $offset])
|
||||
),
|
||||
'ARRAY_A'
|
||||
);
|
||||
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
|
||||
|
||||
return ['rows' => is_array($rows) ? array_values($rows) : [], 'total' => $total];
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function abilities(): array
|
||||
{
|
||||
$wpdb = $this->db();
|
||||
$table = $this->tableName();
|
||||
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- own table name.
|
||||
$abilities = (array) $wpdb->get_col("SELECT DISTINCT ability FROM {$table} ORDER BY ability");
|
||||
|
||||
return array_values(array_map('strval', $abilities));
|
||||
}
|
||||
|
||||
/** Deletes every row (button "Protokoll leeren", spec §8.3). Irreversible. */
|
||||
public function clear(): void
|
||||
{
|
||||
$wpdb = $this->db();
|
||||
$table = $this->tableName();
|
||||
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- own table name.
|
||||
$wpdb->query("DELETE FROM {$table}");
|
||||
}
|
||||
|
||||
/**
|
||||
* RequestContext::clientName() is a client-chosen string (app-password name or
|
||||
* OAuth client_id) – treat it as untrusted input before it lands in the DB.
|
||||
|
||||
@@ -12,6 +12,7 @@ use LIT\MCP\Admin\ConnectionRepository;
|
||||
use LIT\MCP\Admin\ConnectionStatus;
|
||||
use LIT\MCP\Admin\ConnectionTab;
|
||||
use LIT\MCP\Admin\LimitsTab;
|
||||
use LIT\MCP\Admin\LogTab;
|
||||
use LIT\MCP\Admin\SettingsPage;
|
||||
use LIT\MCP\Admin\SiteHealth;
|
||||
use LIT\MCP\Admin\ToolsTab;
|
||||
@@ -85,6 +86,7 @@ final class Plugin
|
||||
new ConnectionTab($status),
|
||||
new AccessTab($access, new ConnectionRepository()),
|
||||
new ToolsTab($registry, $policy),
|
||||
new LogTab($changeLog, $limits),
|
||||
new LimitsTab(),
|
||||
]);
|
||||
if (is_admin()) {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
if (! class_exists('WP_List_Table')) {
|
||||
/**
|
||||
* Minimal stand-in for wp-admin/includes/class-wp-list-table.php: just enough of the
|
||||
* real class's shape (constructor args, $items, single_row_columns dispatching to
|
||||
* column_<name>()/column_default()) for LogListTable's own logic to be unit-tested
|
||||
* without a full WP admin bootstrap.
|
||||
*/
|
||||
class WP_List_Table
|
||||
{
|
||||
/** @var list<array<string, mixed>> */
|
||||
public $items = [];
|
||||
|
||||
/** @var array{0: array<string, string>, 1: list<string>, 2: list<string>}|null */
|
||||
public $_column_headers;
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
protected $_args;
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
protected $_pagination_args = [];
|
||||
|
||||
/** @param array<string, mixed> $args */
|
||||
public function __construct(array $args = [])
|
||||
{
|
||||
$this->_args = $args;
|
||||
}
|
||||
|
||||
public function get_pagenum(): int
|
||||
{
|
||||
return isset($_GET['paged']) ? max(1, (int) $_GET['paged']) : 1; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $args */
|
||||
public function set_pagination_args(array $args): void
|
||||
{
|
||||
$this->_pagination_args = $args;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function pagination_args(): array
|
||||
{
|
||||
return $this->_pagination_args;
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
public function get_columns(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function single_row($item): void
|
||||
{
|
||||
echo '<tr>';
|
||||
$this->single_row_columns($item);
|
||||
echo '</tr>';
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $item */
|
||||
protected function single_row_columns($item): void
|
||||
{
|
||||
foreach (array_keys($this->get_columns()) as $column_name) {
|
||||
$method = 'column_' . $column_name;
|
||||
$value = method_exists($this, $method) ? $this->{$method}($item) : $this->column_default($item, $column_name);
|
||||
printf('<td class="%s">%s</td>', $column_name, $value);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $item */
|
||||
protected function column_default($item, $column_name): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function no_items(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function prepare_items(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function display(): void
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Admin;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Admin\LogListTable;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
use WP_Post;
|
||||
use WP_Term;
|
||||
|
||||
final class LogListTableTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Functions\stubEscapeFunctions();
|
||||
Functions\stubTranslationFunctions();
|
||||
// Harmless defaults so single_row()'s full column pass doesn't blow up in tests
|
||||
// that only care about a subset of columns; overridden per test where needed.
|
||||
Functions\when('get_userdata')->justReturn(false);
|
||||
Functions\when('post_type_exists')->justReturn(false);
|
||||
Functions\when('get_post')->justReturn(null);
|
||||
Functions\when('get_term')->justReturn(null);
|
||||
Functions\when('current_user_can')->justReturn(false);
|
||||
Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://kunde.de/wp-admin/' . $path);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
unset($_GET['paged']);
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testPrepareItemsQueriesTheCurrentPageAndSetsPagination(): void
|
||||
{
|
||||
$_GET['paged'] = '3';
|
||||
$log = Mockery::mock(ChangeLog::class);
|
||||
$log->expects('query')
|
||||
->with(['ability' => 'lit/content-update', 'user_id' => 0], 50, 100)
|
||||
->andReturn(['rows' => [['id' => '1']], 'total' => 120]);
|
||||
|
||||
$table = new LogListTable($log, ['ability' => 'lit/content-update', 'user_id' => 0]);
|
||||
$table->prepare_items();
|
||||
|
||||
self::assertSame([['id' => '1']], $table->items);
|
||||
self::assertSame(['total_items' => 120, 'per_page' => 50], $table->pagination_args());
|
||||
}
|
||||
|
||||
public function testColumnDefaultEscapesUntrustedClientAndSummaryText(): void
|
||||
{
|
||||
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
|
||||
|
||||
$item = ['client' => 'Claude <script>alert(1)</script>', 'summary' => '<b>böse</b> Zusammenfassung'];
|
||||
|
||||
self::assertStringNotContainsString('<script>', $this->columnDefault($table, $item, 'client'));
|
||||
self::assertStringContainsString('<script>', $this->columnDefault($table, $item, 'client'));
|
||||
self::assertStringNotContainsString('<b>', $this->columnDefault($table, $item, 'summary'));
|
||||
}
|
||||
|
||||
public function testColumnDefaultShowsUserDisplayNameOrFallback(): void
|
||||
{
|
||||
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
|
||||
Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Jan <b>']);
|
||||
|
||||
$withUser = $this->columnDefault($table, ['user_id' => 3], 'user');
|
||||
self::assertStringContainsString('Jan <b>', $withUser);
|
||||
}
|
||||
|
||||
public function testColumnDefaultUserFallsBackToIdWhenUserIsGone(): void
|
||||
{
|
||||
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
|
||||
Functions\when('get_userdata')->justReturn(false);
|
||||
|
||||
self::assertSame('#7', $this->columnDefault($table, ['user_id' => 7], 'user'));
|
||||
}
|
||||
|
||||
public function testColumnDefaultLinksTheObjectForAKnownPostType(): void
|
||||
{
|
||||
Functions\when('post_type_exists')->justReturn(true);
|
||||
Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=12&action=edit');
|
||||
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
|
||||
|
||||
$html = $this->columnDefault($table, ['object_type' => 'page', 'object_id' => '12'], 'object');
|
||||
|
||||
self::assertStringContainsString('href="https://kunde.de/wp-admin/post.php?post=12&action=edit"', $html);
|
||||
self::assertStringContainsString('page 12', $html);
|
||||
}
|
||||
|
||||
public function testColumnDefaultShowsPlainLabelWhenGetEditPostLinkRefusesIt(): void
|
||||
{
|
||||
Functions\when('post_type_exists')->justReturn(true);
|
||||
Functions\when('get_edit_post_link')->justReturn(null);
|
||||
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
|
||||
|
||||
$html = $this->columnDefault($table, ['object_type' => 'page', 'object_id' => '12'], 'object');
|
||||
|
||||
self::assertSame('page 12', $html);
|
||||
}
|
||||
|
||||
public function testColumnDefaultLinksAnAttachmentToTheMediaEditScreen(): void
|
||||
{
|
||||
Functions\when('post_type_exists')->justReturn(true);
|
||||
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5]));
|
||||
Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://kunde.de/wp-admin/' . $path);
|
||||
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
|
||||
|
||||
$html = $this->columnDefault($table, ['object_type' => 'attachment', 'object_id' => '5'], 'object');
|
||||
|
||||
self::assertStringContainsString('href="https://kunde.de/wp-admin/upload.php?item=5"', $html);
|
||||
}
|
||||
|
||||
public function testColumnDefaultShowsPlainLabelForADeletedAttachment(): void
|
||||
{
|
||||
Functions\when('get_post')->justReturn(null);
|
||||
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
|
||||
|
||||
self::assertSame('attachment 5', $this->columnDefault($table, ['object_type' => 'attachment', 'object_id' => '5'], 'object'));
|
||||
}
|
||||
|
||||
public function testColumnDefaultLinksANavMenuToTheMenusScreen(): void
|
||||
{
|
||||
Functions\when('get_term')->justReturn(new WP_Term(['term_id' => 3, 'taxonomy' => 'nav_menu']));
|
||||
Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://kunde.de/wp-admin/' . $path);
|
||||
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
|
||||
|
||||
$html = $this->columnDefault($table, ['object_type' => 'nav_menu', 'object_id' => '3'], 'object');
|
||||
|
||||
self::assertStringContainsString('href="https://kunde.de/wp-admin/nav-menus.php?menu=3"', $html);
|
||||
}
|
||||
|
||||
/** @dataProvider unlinkableObjectTypes */
|
||||
public function testColumnDefaultNeverLinksSettingsOrMenuLocations(string $type, string $id): void
|
||||
{
|
||||
Functions\when('post_type_exists')->justReturn(false);
|
||||
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
|
||||
|
||||
self::assertSame(trim($type . ' ' . $id), $this->columnDefault($table, ['object_type' => $type, 'object_id' => $id], 'object'));
|
||||
}
|
||||
|
||||
/** @return list<list<string>> */
|
||||
public static function unlinkableObjectTypes(): array
|
||||
{
|
||||
return [
|
||||
['option', 'blogname,blogdescription'],
|
||||
['menu_location', 'primary'],
|
||||
['unknown', '42'],
|
||||
];
|
||||
}
|
||||
|
||||
public function testRevisionColumnShowsADashWithoutARevision(): void
|
||||
{
|
||||
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
|
||||
|
||||
self::assertSame('–', $this->columnDefault($table, ['revision_id' => 0], 'revision'));
|
||||
}
|
||||
|
||||
public function testRevisionColumnLinksOnlyWhenTheUserCanEditTheParentPost(): void
|
||||
{
|
||||
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 811, 'post_type' => 'revision', 'post_parent' => 12]));
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://kunde.de/wp-admin/' . $path);
|
||||
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
|
||||
|
||||
$html = $this->columnDefault($table, ['revision_id' => 811], 'revision');
|
||||
|
||||
self::assertStringContainsString('href="https://kunde.de/wp-admin/revision.php?revision=811"', $html);
|
||||
}
|
||||
|
||||
public function testRevisionColumnHidesTheLinkWithoutEditPermission(): void
|
||||
{
|
||||
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 811, 'post_type' => 'revision', 'post_parent' => 12]));
|
||||
Functions\when('current_user_can')->justReturn(false);
|
||||
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
|
||||
|
||||
self::assertSame('–', $this->columnDefault($table, ['revision_id' => 811], 'revision'));
|
||||
}
|
||||
|
||||
public function testSingleRowHighlightsPartialResults(): void
|
||||
{
|
||||
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
|
||||
|
||||
ob_start();
|
||||
$table->single_row(['result' => 'partial']);
|
||||
$partial = (string) ob_get_clean();
|
||||
|
||||
ob_start();
|
||||
$table->single_row(['result' => 'ok']);
|
||||
$ok = (string) ob_get_clean();
|
||||
|
||||
self::assertStringContainsString('<tr style=', $partial);
|
||||
self::assertSame('<tr>', substr($ok, 0, 4));
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $item */
|
||||
private function columnDefault(LogListTable $table, array $item, string $column): string
|
||||
{
|
||||
return (string) (new \ReflectionMethod($table, 'column_default'))->invoke($table, $item, $column);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<?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());
|
||||
}
|
||||
|
||||
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'] . '"><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([]);
|
||||
|
||||
$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);
|
||||
}
|
||||
}
|
||||
@@ -126,4 +126,70 @@ final class ChangeLogTest extends TestCase
|
||||
|
||||
self::assertSame(3, (new ChangeLog($db))->purgeOlderThan(90));
|
||||
}
|
||||
|
||||
public function testQueryFiltersAndPaginatesNewestFirst(): void
|
||||
{
|
||||
$db = Mockery::mock(\wpdb::class)->makePartial();
|
||||
$db->expects('get_var')
|
||||
->with(Mockery::on(static fn (string $sql): bool => $sql
|
||||
=== "SELECT COUNT(*) FROM wp_lit_mcp_log WHERE 1 = 1 AND ability = 'lit/content-update'"
|
||||
. " AND user_id = 3"))
|
||||
->andReturn('12');
|
||||
$db->expects('get_results')
|
||||
->with(
|
||||
Mockery::on(static fn (string $sql): bool => str_ends_with($sql, 'ORDER BY id DESC LIMIT 50 OFFSET 50')),
|
||||
'ARRAY_A'
|
||||
)
|
||||
->andReturn([['id' => '7', 'ability' => 'lit/content-update']]);
|
||||
|
||||
$result = (new ChangeLog($db))->query(['ability' => 'lit/content-update', 'user_id' => 3], 50, 50);
|
||||
|
||||
self::assertSame(12, $result['total']);
|
||||
self::assertSame('7', $result['rows'][0]['id']);
|
||||
}
|
||||
|
||||
public function testQueryFiltersByResultAndDateRange(): void
|
||||
{
|
||||
$db = Mockery::mock(\wpdb::class)->makePartial();
|
||||
$db->expects('get_var')
|
||||
->with(Mockery::on(static fn (string $sql): bool => $sql
|
||||
=== "SELECT COUNT(*) FROM wp_lit_mcp_log WHERE 1 = 1 AND result = 'partial'"
|
||||
. " AND created_at >= '2026-09-01 00:00:00' AND created_at <= '2026-09-25 23:59:59'"))
|
||||
->andReturn('2');
|
||||
$db->expects('get_results')->andReturn([]);
|
||||
|
||||
$result = (new ChangeLog($db))->query(
|
||||
['result' => 'partial', 'date_from' => '2026-09-01', 'date_to' => '2026-09-25'],
|
||||
50,
|
||||
0
|
||||
);
|
||||
|
||||
self::assertSame(2, $result['total']);
|
||||
self::assertSame([], $result['rows']);
|
||||
}
|
||||
|
||||
public function testQueryWithoutFiltersUsesTheBaseCondition(): void
|
||||
{
|
||||
$db = Mockery::mock(\wpdb::class)->makePartial();
|
||||
$db->expects('get_var')
|
||||
->with('SELECT COUNT(*) FROM wp_lit_mcp_log WHERE 1 = 1')
|
||||
->andReturn('0');
|
||||
$db->expects('get_results')->andReturn(null);
|
||||
|
||||
$result = (new ChangeLog($db))->query([], 50, 0);
|
||||
|
||||
self::assertSame(0, $result['total']);
|
||||
self::assertSame([], $result['rows']);
|
||||
}
|
||||
|
||||
public function testAbilitiesAndClear(): void
|
||||
{
|
||||
$db = Mockery::mock(\wpdb::class)->makePartial();
|
||||
$db->expects('get_col')->with('SELECT DISTINCT ability FROM wp_lit_mcp_log ORDER BY ability')->andReturn(['lit/a']);
|
||||
$db->expects('query')->with('DELETE FROM wp_lit_mcp_log')->andReturn(4);
|
||||
$log = new ChangeLog($db);
|
||||
|
||||
self::assertSame(['lit/a'], $log->abilities());
|
||||
$log->clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,3 +14,4 @@ require_once __DIR__ . '/Stubs/Wpdb.php';
|
||||
require_once __DIR__ . '/Stubs/WpPost.php';
|
||||
require_once __DIR__ . '/Stubs/WpTerm.php';
|
||||
require_once __DIR__ . '/Stubs/WpApplicationPasswords.php';
|
||||
require_once __DIR__ . '/Stubs/WpListTable.php';
|
||||
|
||||
Reference in New Issue
Block a user