diff --git a/src/Admin/AdminNotices.php b/src/Admin/AdminNotices.php new file mode 100644 index 0000000..5282a39 --- /dev/null +++ b/src/Admin/AdminNotices.php @@ -0,0 +1,63 @@ + */ + public function messages(bool $onOwnPage): array + { + $messages = []; + if (! $this->status->oauthEnabled()) { + $messages[] = __( + 'LIT MCP: Der OAuth-Server ist deaktiviert (Filter wpmedia_mcp_oauth_server_enabled).', + 'wp-lit-mcp' + ) . ' ' . __('Claude kann sich nicht verbinden.', 'wp-lit-mcp'); + } + + $discovery = $this->status->discovery(false); + if (is_array($discovery) && ($discovery['status'] ?? 'good') === 'critical') { + $messages[] = __( + 'LIT MCP: Die OAuth-Discovery-Dokumente unter /.well-known/ sind nicht erreichbar.', + 'wp-lit-mcp' + ) . ' ' . __('Details unter Werkzeuge → Website-Zustand.', 'wp-lit-mcp'); + } + + if ($onOwnPage && ! $this->status->https()) { + $messages[] = __( + 'LIT MCP: Die Website läuft ohne HTTPS; claude.ai verbindet sich nur mit HTTPS-Websites.', + 'wp-lit-mcp' + ); + } + + return $messages; + } + + public function render(): void + { + if (! current_user_can('manage_options')) { + return; + } + + $screen = function_exists('get_current_screen') ? get_current_screen() : null; + $onOwnPage = is_object($screen) && $screen->id === 'settings_page_' . SettingsPage::SLUG; + foreach ($this->messages($onOwnPage) as $message) { + printf('
%s
'
+ . '
' . esc_html__( + 'In claude.ai unter Einstellungen → Connectors → „Add custom connector“ eintragen;', + 'wp-lit-mcp' + ) . ' ' . esc_html__('Die Anmeldung läuft danach über diese Website.', 'wp-lit-mcp') . '
'; + + echo '| %s | %s | %s |
|---|
' . esc_html__('Weitere Prüfungen unter Werkzeuge → Website-Zustand.', 'wp-lit-mcp') . '
'; + } +} diff --git a/src/Admin/SettingsPage.php b/src/Admin/SettingsPage.php new file mode 100644 index 0000000..f635023 --- /dev/null +++ b/src/Admin/SettingsPage.php @@ -0,0 +1,116 @@ + */ + private array $tabs = []; + + /** @param list%s
' . esc_html($detail) . '
', + 'actions' => sprintf( + '%s', + esc_url(SettingsPage::url('verbindung')), + esc_html__('Einstellungen → LIT MCP', 'wp-lit-mcp') + ), + 'test' => self::TEST, + ]; + } +} diff --git a/src/Plugin.php b/src/Plugin.php index 9c48969..641486c 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -6,6 +6,11 @@ namespace LIT\MCP; use LIT\MCP\Abilities\AbilityRegistrar; use LIT\MCP\Abilities\ExposurePolicy; +use LIT\MCP\Admin\AdminNotices; +use LIT\MCP\Admin\ConnectionStatus; +use LIT\MCP\Admin\ConnectionTab; +use LIT\MCP\Admin\SettingsPage; +use LIT\MCP\Admin\SiteHealth; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\ChangeLog\ChangeLogCleanup; use LIT\MCP\ChangeLog\ChangeLogInstaller; @@ -30,6 +35,7 @@ final class Plugin public const VERSION = '0.1.0'; private static string $pluginFile = ''; + private static ?SettingsPage $settingsPage = null; public static function boot(string $pluginFile): void { @@ -68,9 +74,24 @@ final class Plugin $policy ))->register(); + $status = new ConnectionStatus(); + (new SiteHealth($status))->register(); + self::$settingsPage = new SettingsPage([ + new ConnectionTab($status), + ]); + if (is_admin()) { + self::$settingsPage->register(); + (new AdminNotices($status))->register(); + } + \WPMedia\MCP\OAuth\Bootstrap::instance(); } + public static function settingsPage(): ?SettingsPage + { + return self::$settingsPage; + } + public static function pluginDir(): string { return dirname(self::$pluginFile); diff --git a/tests/Unit/Admin/AdminNoticesTest.php b/tests/Unit/Admin/AdminNoticesTest.php new file mode 100644 index 0000000..d1a3312 --- /dev/null +++ b/tests/Unit/Admin/AdminNoticesTest.php @@ -0,0 +1,47 @@ +allows('oauthEnabled')->andReturn($oauth); + $status->allows('discovery')->with(false)->andReturn($discovery); + $status->allows('https')->andReturn($https); + + return $status; + } + + public function testWarnsAboutDisabledOAuthAndBrokenDiscoveryEverywhere(): void + { + $messages = (new AdminNotices($this->status(false, ['status' => 'critical'], true)))->messages(false); + + self::assertCount(2, $messages); + self::assertStringContainsString('OAuth-Server ist deaktiviert', $messages[0]); + self::assertStringContainsString('/.well-known/', $messages[1]); + } + + public function testHttpsHintOnlyOnTheSettingsPage(): void + { + $notices = new AdminNotices($this->status(true, null, false)); + + self::assertSame([], $notices->messages(false)); + self::assertCount(1, $notices->messages(true)); + } +} diff --git a/tests/Unit/Admin/ConnectionStatusTest.php b/tests/Unit/Admin/ConnectionStatusTest.php new file mode 100644 index 0000000..c6cff7d --- /dev/null +++ b/tests/Unit/Admin/ConnectionStatusTest.php @@ -0,0 +1,59 @@ +justReturn(false); + Functions\when('rest_url')->justReturn('https://kunde.de/wp-json/mcp/mcp-oauth-server'); + Functions\expect('wp_remote_post')->once()->with('https://kunde.de/wp-json/mcp/mcp-oauth-server', Mockery::type('array')) + ->andReturn(['response' => ['code' => 401]]); + Functions\when('wp_remote_retrieve_response_code')->justReturn(401); + Functions\when('wp_remote_retrieve_header')->justReturn('Bearer realm="https://kunde.de"'); + Functions\expect('set_transient')->once() + ->with('lit_mcp_endpoint_check', Mockery::on(static fn (array $value): bool => $value['ok'] === true), 300); + + $result = (new ConnectionStatus(static fn (): bool => true))->endpoint(true); + + self::assertTrue($result['ok']); + } + + public function testEndpointAnsweringWithoutChallengeIsReported(): void + { + Functions\when('get_transient')->justReturn(false); + Functions\when('rest_url')->justReturn('https://kunde.de/wp-json/mcp/mcp-oauth-server'); + Functions\when('wp_remote_post')->justReturn(['response' => ['code' => 200]]); + Functions\when('wp_remote_retrieve_response_code')->justReturn(200); + Functions\when('wp_remote_retrieve_header')->justReturn(''); + Functions\when('set_transient')->justReturn(true); + + $result = (new ConnectionStatus(static fn (): bool => true))->endpoint(true); + + self::assertFalse($result['ok']); + self::assertStringContainsString('200', $result['message']); + } + + public function testChecksWithoutFetchingUseOnlyCachedResults(): void + { + Functions\when('get_transient')->justReturn(false); + Functions\expect('wp_remote_post')->never(); + Functions\when('home_url')->justReturn('http://kunde.test'); + + $checks = (new ConnectionStatus(static fn (): bool => false))->checks(false); + + $byLabel = array_column($checks, null, 'label'); + self::assertFalse($byLabel['OAuth-Server']['ok']); + self::assertFalse($byLabel['HTTPS']['ok']); + self::assertNull($byLabel['Discovery (/.well-known/)']['ok']); + self::assertNull($byLabel['Endpunkt ohne Token']['ok']); + } +} diff --git a/tests/Unit/Admin/SettingsPageTest.php b/tests/Unit/Admin/SettingsPageTest.php new file mode 100644 index 0000000..4c20024 --- /dev/null +++ b/tests/Unit/Admin/SettingsPageTest.php @@ -0,0 +1,97 @@ +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\when('sanitize_key')->alias(static fn (string $key): string => strtolower((string) preg_replace('/[^a-z0-9_\-]/i', '', $key))); + } + + protected function tearDown(): void + { + unset($_GET['tab'], $_GET['lit_mcp_notice']); + parent::tearDown(); + } + + private function tab(string $slug, string $label): AdminTab + { + $tab = Mockery::mock(AdminTab::class); + $tab->allows('slug')->andReturn($slug); + $tab->allows('label')->andReturn($label); + $tab->allows('registerActions'); + $tab->allows('render')->andReturnUsing(static function () use ($slug): void { + echo 'Inhalt ' . $slug . '
'; + }); + + return $tab; + } + + public function testRendersTabsAndTheRequestedTab(): void + { + Functions\when('current_user_can')->justReturn(true); + $_GET['tab'] = 'zugriff'; + $page = new SettingsPage([$this->tab('verbindung', 'Verbindung'), $this->tab('zugriff', 'Zugriff')]); + + ob_start(); + $page->render(); + $html = (string) ob_get_clean(); + + self::assertStringContainsString('Inhalt zugriff', $html); + self::assertStringNotContainsString('Inhalt verbindung', $html); + self::assertMatchesRegularExpression('/tab=zugriff" class="nav-tab nav-tab-active"/', $html); + self::assertSame(['verbindung', 'zugriff'], $page->tabSlugs()); + } + + public function testUnknownTabFallsBackToTheFirstAndNoticesAreShown(): void + { + Functions\when('current_user_can')->justReturn(true); + $_GET['tab'] = '../../evil'; + $_GET['lit_mcp_notice'] = 'revoked'; + $page = new SettingsPage([$this->tab('verbindung', 'Verbindung')]); + + ob_start(); + $page->render(); + $html = (string) ob_get_clean(); + + self::assertSame('verbindung', $page->currentTab()); + self::assertStringContainsString('Inhalt verbindung', $html); + self::assertStringContainsString('notice-success', $html); + } + + public function testNothingIsRenderedWithoutManageOptions(): void + { + Functions\when('current_user_can')->justReturn(false); + + ob_start(); + (new SettingsPage([$this->tab('verbindung', 'Verbindung')]))->render(); + + self::assertSame('', ob_get_clean()); + } + + public function testRegisterHooksMenuSettingsAndTabActions(): void + { + $tab = Mockery::mock(AdminTab::class); + $tab->allows('slug')->andReturn('verbindung'); + $tab->expects('registerActions')->once(); + $page = new SettingsPage([$tab]); + Functions\expect('add_action')->once()->with('admin_menu', [$page, 'addMenu']); + Functions\expect('add_action')->once()->with('admin_init', [$page, 'registerSettings']); + + $page->register(); + } +} diff --git a/tests/Unit/Admin/SiteHealthTest.php b/tests/Unit/Admin/SiteHealthTest.php new file mode 100644 index 0000000..dad4cf2 --- /dev/null +++ b/tests/Unit/Admin/SiteHealthTest.php @@ -0,0 +1,55 @@ +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)); + } + + public function testAddsADirectTest(): void + { + $health = new SiteHealth(Mockery::mock(ConnectionStatus::class)); + + $tests = $health->addTest(['direct' => []]); + + self::assertSame([$health, 'run'], $tests['direct']['lit_mcp_endpoint']['test']); + } + + public function testGoodWhenOAuthIsOnAndTheEndpointChallenges(): void + { + $status = Mockery::mock(ConnectionStatus::class); + $status->allows('oauthEnabled')->andReturn(true); + $status->allows('endpoint')->with(true)->andReturn(['ok' => true, 'message' => '401 mit WWW-Authenticate']); + + $result = (new SiteHealth($status))->run(); + + self::assertSame('good', $result['status']); + self::assertSame('lit_mcp_endpoint', $result['test']); + } + + public function testCriticalWhenOAuthIsDisabled(): void + { + $status = Mockery::mock(ConnectionStatus::class); + $status->allows('oauthEnabled')->andReturn(false); + + $result = (new SiteHealth($status))->run(); + + self::assertSame('critical', $result['status']); + self::assertStringContainsString('deaktiviert', $result['description']); + } +}