From 261972751fab4ac3e596b21e7c7461680941ece8 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 25 Sep 2026 21:57:29 +0200 Subject: [PATCH] feat: change log table with retention cron and limits option Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/ChangeLog/ChangeLog.php | 95 +++++++++++++++++++ src/ChangeLog/ChangeLogCleanup.php | 37 ++++++++ src/ChangeLog/ChangeLogInstaller.php | 57 +++++++++++ src/Lifecycle.php | 27 ++++++ src/Limits.php | 59 ++++++++++++ src/Plugin.php | 7 ++ tests/Stubs/Wpdb.php | 67 +++++++++++++ tests/Unit/ChangeLog/ChangeLogCleanupTest.php | 40 ++++++++ .../Unit/ChangeLog/ChangeLogInstallerTest.php | 37 ++++++++ tests/Unit/ChangeLog/ChangeLogTest.php | 82 ++++++++++++++++ tests/Unit/LifecycleTest.php | 36 +++++++ tests/Unit/LimitsTest.php | 45 +++++++++ tests/bootstrap.php | 1 + wp-lit-mcp.php | 3 + 14 files changed, 593 insertions(+) create mode 100644 src/ChangeLog/ChangeLog.php create mode 100644 src/ChangeLog/ChangeLogCleanup.php create mode 100644 src/ChangeLog/ChangeLogInstaller.php create mode 100644 src/Lifecycle.php create mode 100644 src/Limits.php create mode 100644 tests/Stubs/Wpdb.php create mode 100644 tests/Unit/ChangeLog/ChangeLogCleanupTest.php create mode 100644 tests/Unit/ChangeLog/ChangeLogInstallerTest.php create mode 100644 tests/Unit/ChangeLog/ChangeLogTest.php create mode 100644 tests/Unit/LifecycleTest.php create mode 100644 tests/Unit/LimitsTest.php diff --git a/src/ChangeLog/ChangeLog.php b/src/ChangeLog/ChangeLog.php new file mode 100644 index 0000000..73449f8 --- /dev/null +++ b/src/ChangeLog/ChangeLog.php @@ -0,0 +1,95 @@ +db()->prefix . self::TABLE; + } + + public function charsetCollate(): string + { + return (string) $this->db()->get_charset_collate(); + } + + public function record( + string $ability, + string $objectType, + string $objectId, + string $summary, + ?int $revisionId = null, + ?string $snapshotId = null, + string $result = 'ok' + ): ?int { + $wpdb = $this->db(); + $inserted = $wpdb->insert( + $this->tableName(), + [ + 'created_at' => current_time('mysql', true), + 'user_id' => get_current_user_id(), + 'client' => $this->sanitizedClientName(), + 'ability' => $ability, + 'object_type' => $objectType, + 'object_id' => $objectId, + 'summary' => mb_substr($summary, 0, 500), + 'revision_id' => $revisionId, + 'snapshot_id' => $snapshotId, + 'result' => $result, + ], + ['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s'] + ); + + if ($inserted === false) { + $message = sprintf('[wp-lit-mcp] change log insert failed for %s %s:%s', $ability, $objectType, $objectId); + error_log($message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + return null; + } + + return (int) $wpdb->insert_id; + } + + public function purgeOlderThan(int $days): int + { + $wpdb = $this->db(); + $table = $this->tableName(); + $cutoff = gmdate('Y-m-d H:i:s', time() - max(1, $days) * 86400); + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is our own constant. + return (int) $wpdb->query($wpdb->prepare("DELETE FROM {$table} WHERE created_at < %s", $cutoff)); + } + + /** + * 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. + */ + private function sanitizedClientName(): string + { + return mb_substr(sanitize_text_field(RequestContext::clientName()), 0, 191); + } + + protected function db(): \wpdb + { + if ($this->db === null) { + global $wpdb; + $this->db = $wpdb; + } + + return $this->db; + } +} diff --git a/src/ChangeLog/ChangeLogCleanup.php b/src/ChangeLog/ChangeLogCleanup.php new file mode 100644 index 0000000..820e584 --- /dev/null +++ b/src/ChangeLog/ChangeLogCleanup.php @@ -0,0 +1,37 @@ +log->purgeOlderThan($this->limits->logRetentionDays()); + } +} diff --git a/src/ChangeLog/ChangeLogInstaller.php b/src/ChangeLog/ChangeLogInstaller.php new file mode 100644 index 0000000..ea1b5fd --- /dev/null +++ b/src/ChangeLog/ChangeLogInstaller.php @@ -0,0 +1,57 @@ +install(); + } + } + + public function install(): void + { + if (! function_exists('dbDelta')) { + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + } + + $table = $this->log->tableName(); + $charset = $this->log->charsetCollate(); + + dbDelta("CREATE TABLE {$table} ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + created_at datetime NOT NULL, + user_id bigint(20) unsigned NOT NULL DEFAULT 0, + client varchar(191) NOT NULL DEFAULT '', + ability varchar(100) NOT NULL, + object_type varchar(50) NOT NULL DEFAULT '', + object_id varchar(191) NOT NULL DEFAULT '', + summary text NOT NULL, + revision_id bigint(20) unsigned DEFAULT NULL, + snapshot_id varchar(64) DEFAULT NULL, + result varchar(20) NOT NULL DEFAULT 'ok', + PRIMARY KEY (id), + KEY created_at (created_at), + KEY ability (ability), + KEY user_id (user_id) +) {$charset};"); + + update_option(self::VERSION_OPTION, self::VERSION); + } +} diff --git a/src/Lifecycle.php b/src/Lifecycle.php new file mode 100644 index 0000000..b19b281 --- /dev/null +++ b/src/Lifecycle.php @@ -0,0 +1,27 @@ +install(); + (new ChangeLogCleanup($log, new Limits()))->ensureScheduled(); + } + + public static function deactivate(): void + { + wp_clear_scheduled_hook(ChangeLogCleanup::HOOK); + } +} diff --git a/src/Limits.php b/src/Limits.php new file mode 100644 index 0000000..f16b81f --- /dev/null +++ b/src/Limits.php @@ -0,0 +1,59 @@ + 5, 'log_retention_days' => 90, 'snapshot_count' => 20]; + private const BOUNDS = ['upload_mb' => [1, 100], 'log_retention_days' => [1, 3650], 'snapshot_count' => [1, 100]]; + + public function uploadBytes(): int + { + return $this->uploadMegabytes() * 1024 * 1024; + } + + public function uploadMegabytes(): int + { + return $this->all()['upload_mb']; + } + + public function logRetentionDays(): int + { + return $this->all()['log_retention_days']; + } + + public function snapshotCount(): int + { + return $this->all()['snapshot_count']; + } + + /** @return array{upload_mb: int, log_retention_days: int, snapshot_count: int} */ + public function all(): array + { + return self::sanitize(get_option(self::OPTION, [])); + } + + /** + * Settings API sanitize callback: always returns all keys, clamped to their bounds. + * + * @return array{upload_mb: int, log_retention_days: int, snapshot_count: int} + */ + public static function sanitize(mixed $input): array + { + $input = is_array($input) ? $input : []; + $clean = self::DEFAULTS; + foreach (self::BOUNDS as $key => [$min, $max]) { + if (isset($input[$key]) && is_numeric($input[$key])) { + $clean[$key] = max($min, min($max, (int) $input[$key])); + } + } + + return $clean; + } +} diff --git a/src/Plugin.php b/src/Plugin.php index bfb740c..371df4d 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -5,6 +5,9 @@ declare(strict_types=1); namespace LIT\MCP; use LIT\MCP\Abilities\AbilityRegistrar; +use LIT\MCP\ChangeLog\ChangeLog; +use LIT\MCP\ChangeLog\ChangeLogCleanup; +use LIT\MCP\ChangeLog\ChangeLogInstaller; use LIT\MCP\Guides\InstructionsBuilder; use LIT\MCP\Integrations\Core\CoreIntegration; use LIT\MCP\Integrations\IntegrationRegistry; @@ -33,6 +36,10 @@ final class Plugin (new Access())->register(); (new RequestContext())->register(); + $changeLog = new ChangeLog(); + (new ChangeLogInstaller($changeLog))->maybeInstall(); + (new ChangeLogCleanup($changeLog, new Limits()))->register(); + $registry = new IntegrationRegistry( new CoreIntegration(self::pluginDir() . '/docs/guides') ); diff --git a/tests/Stubs/Wpdb.php b/tests/Stubs/Wpdb.php new file mode 100644 index 0000000..61c4338 --- /dev/null +++ b/tests/Stubs/Wpdb.php @@ -0,0 +1,67 @@ +makePartial(). + */ + class wpdb + { + public $prefix = 'wp_'; + public $posts = 'wp_posts'; + public $postmeta = 'wp_postmeta'; + public $usermeta = 'wp_usermeta'; + public $options = 'wp_options'; + public $insert_id = 0; + + public function prepare($query, ...$args) + { + if (count($args) === 1 && is_array($args[0])) { + $args = $args[0]; + } + // Quotes string placeholders but does not escape, so tests can assert on raw values. + $query = str_replace("'%s'", '%s', $query); + $query = str_replace('%s', "'%s'", $query); + + return vsprintf($query, $args); + } + + public function esc_like($text) + { + return addcslashes($text, '_%\\'); + } + + public function get_charset_collate() + { + return 'DEFAULT CHARACTER SET utf8mb4'; + } + + public function insert($table, $data, $format = null) + { + return 1; + } + + public function query($query) + { + return 0; + } + + public function get_col($query = null, $x = 0) + { + return []; + } + + public function get_var($query = null, $x = 0, $y = 0) + { + return null; + } + + public function get_results($query = null, $output = 'OBJECT') + { + return []; + } + } +} diff --git a/tests/Unit/ChangeLog/ChangeLogCleanupTest.php b/tests/Unit/ChangeLog/ChangeLogCleanupTest.php new file mode 100644 index 0000000..a3009c1 --- /dev/null +++ b/tests/Unit/ChangeLog/ChangeLogCleanupTest.php @@ -0,0 +1,40 @@ +justReturn(['log_retention_days' => 30]); + $log = Mockery::mock(ChangeLog::class); + $log->expects('purgeOlderThan')->with(30)->andReturn(0); + + (new ChangeLogCleanup($log, new Limits()))->run(); + } + + public function testEnsureScheduledSchedulesADailyEventOnce(): void + { + Functions\when('wp_next_scheduled')->justReturn(false); + Functions\expect('wp_schedule_event')->once()->with(Mockery::type('int'), 'daily', 'lit_mcp_log_cleanup'); + + (new ChangeLogCleanup(Mockery::mock(ChangeLog::class), new Limits()))->ensureScheduled(); + } + + public function testEnsureScheduledDoesNothingWhenAlreadyScheduled(): void + { + Functions\when('wp_next_scheduled')->justReturn(1790330400); + Functions\expect('wp_schedule_event')->never(); + + (new ChangeLogCleanup(Mockery::mock(ChangeLog::class), new Limits()))->ensureScheduled(); + } +} diff --git a/tests/Unit/ChangeLog/ChangeLogInstallerTest.php b/tests/Unit/ChangeLog/ChangeLogInstallerTest.php new file mode 100644 index 0000000..9fa59f9 --- /dev/null +++ b/tests/Unit/ChangeLog/ChangeLogInstallerTest.php @@ -0,0 +1,37 @@ +makePartial(); + Functions\expect('dbDelta')->once()->with(Mockery::on(static function (string $sql): bool { + return str_contains($sql, 'CREATE TABLE wp_lit_mcp_log (') + && str_contains($sql, 'client varchar(191)') + && str_contains($sql, 'revision_id bigint(20) unsigned DEFAULT NULL') + && str_contains($sql, 'snapshot_id varchar(64) DEFAULT NULL') + && str_contains($sql, 'PRIMARY KEY (id)'); + })); + Functions\expect('update_option')->once()->with('lit_mcp_db_version', '1'); + + (new ChangeLogInstaller(new ChangeLog($db)))->install(); + } + + public function testMaybeInstallSkipsWhenTheVersionIsCurrent(): void + { + Functions\when('get_option')->justReturn('1'); + Functions\expect('dbDelta')->never(); + + (new ChangeLogInstaller(new ChangeLog(Mockery::mock(\wpdb::class)->makePartial())))->maybeInstall(); + } +} diff --git a/tests/Unit/ChangeLog/ChangeLogTest.php b/tests/Unit/ChangeLog/ChangeLogTest.php new file mode 100644 index 0000000..1e1df8a --- /dev/null +++ b/tests/Unit/ChangeLog/ChangeLogTest.php @@ -0,0 +1,82 @@ +justReturn('2026-09-25 08:00:00'); + Functions\when('get_current_user_id')->justReturn(3); + RequestContext::reset(true, 'Claude'); + $db = Mockery::mock(\wpdb::class)->makePartial(); + $db->expects('insert') + ->with('wp_lit_mcp_log', Mockery::on(static function (array $row): bool { + return $row['created_at'] === '2026-09-25 08:00:00' + && $row['user_id'] === 3 + && $row['client'] === 'Claude' + && $row['ability'] === 'lit/content-update' + && $row['object_type'] === 'page' + && $row['object_id'] === '12' + && $row['summary'] === 'Seite geändert' + && $row['revision_id'] === 811 + && $row['snapshot_id'] === null + && $row['result'] === 'ok'; + }), Mockery::type('array')) + ->andReturnUsing(static function () use ($db): int { + $db->insert_id = 4711; + return 1; + }); + + $id = (new ChangeLog($db))->record('lit/content-update', 'page', '12', 'Seite geändert', 811); + + self::assertSame(4711, $id); + } + + public function testRecordReturnsNullWhenTheInsertFails(): void + { + Functions\when('current_time')->justReturn('2026-09-25 08:00:00'); + Functions\when('get_current_user_id')->justReturn(3); + $db = Mockery::mock(\wpdb::class)->makePartial(); + $db->allows('insert')->andReturn(false); + $log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); + $previous = ini_set('error_log', $log); + + try { + $id = (new ChangeLog($db))->record('lit/content-update', 'page', '12', 'x'); + } finally { + ini_set('error_log', (string) $previous); + } + + self::assertNull($id); + self::assertStringContainsString('change log insert failed', (string) file_get_contents($log)); + unlink($log); + } + + public function testPurgeDeletesRowsOlderThanTheRetention(): void + { + $db = Mockery::mock(\wpdb::class)->makePartial(); + $db->expects('query') + ->with(Mockery::on(static fn (string $sql): bool => str_starts_with( + $sql, + "DELETE FROM wp_lit_mcp_log WHERE created_at < '" + ))) + ->andReturn(3); + + self::assertSame(3, (new ChangeLog($db))->purgeOlderThan(90)); + } +} diff --git a/tests/Unit/LifecycleTest.php b/tests/Unit/LifecycleTest.php new file mode 100644 index 0000000..2082d4c --- /dev/null +++ b/tests/Unit/LifecycleTest.php @@ -0,0 +1,36 @@ +makePartial(); + Functions\expect('dbDelta')->once(); + Functions\expect('update_option')->once()->with('lit_mcp_db_version', '1'); + Functions\when('wp_next_scheduled')->justReturn(false); + Functions\expect('wp_schedule_event')->once(); + + Lifecycle::activate(); + } + + public function testDeactivateUnschedulesTheCleanup(): void + { + Functions\expect('wp_clear_scheduled_hook')->once()->with('lit_mcp_log_cleanup'); + + Lifecycle::deactivate(); + } +} diff --git a/tests/Unit/LimitsTest.php b/tests/Unit/LimitsTest.php new file mode 100644 index 0000000..943547e --- /dev/null +++ b/tests/Unit/LimitsTest.php @@ -0,0 +1,45 @@ +justReturn([]); + $limits = new Limits(); + + self::assertSame(5 * 1024 * 1024, $limits->uploadBytes()); + self::assertSame(5, $limits->uploadMegabytes()); + self::assertSame(90, $limits->logRetentionDays()); + self::assertSame(20, $limits->snapshotCount()); + } + + public function testStoredValuesAreClamped(): void + { + Functions\when('get_option')->justReturn(['upload_mb' => 500, 'log_retention_days' => 0, 'snapshot_count' => '7']); + + self::assertSame(['upload_mb' => 100, 'log_retention_days' => 1, 'snapshot_count' => 7], (new Limits())->all()); + } + + public function testCorruptOptionFallsBackToDefaults(): void + { + Functions\when('get_option')->justReturn('garbage'); + + self::assertSame(Limits::DEFAULTS, (new Limits())->all()); + } + + public function testSanitizeReturnsACompleteClampedArray(): void + { + self::assertSame( + ['upload_mb' => 12, 'log_retention_days' => 90, 'snapshot_count' => 100], + Limits::sanitize(['upload_mb' => '12', 'log_retention_days' => 'x', 'snapshot_count' => 1000]) + ); + self::assertSame(Limits::DEFAULTS, Limits::sanitize(null)); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index e77c426..15ea6ca 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -10,3 +10,4 @@ require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/Stubs/WpError.php'; require_once __DIR__ . '/Stubs/WpUser.php'; require_once __DIR__ . '/Stubs/WpRestRequest.php'; +require_once __DIR__ . '/Stubs/Wpdb.php'; diff --git a/wp-lit-mcp.php b/wp-lit-mcp.php index da14209..7968e5b 100644 --- a/wp-lit-mcp.php +++ b/wp-lit-mcp.php @@ -33,6 +33,9 @@ if (! is_readable($lit_mcp_autoload)) { require_once $lit_mcp_autoload; +register_activation_hook(__FILE__, [\LIT\MCP\Lifecycle::class, 'activate']); +register_deactivation_hook(__FILE__, [\LIT\MCP\Lifecycle::class, 'deactivate']); + add_action('plugins_loaded', static function (): void { \LIT\MCP\Plugin::boot(__FILE__); });