feat: access tab with allowed roles and connection revocation
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,211 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Admin;
|
||||||
|
|
||||||
|
use LIT\MCP\Access;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tab "Zugriff": roles allowed to use LIT MCP and the active OAuth connections
|
||||||
|
* with revocation (spec §8.4).
|
||||||
|
*/
|
||||||
|
final class AccessTab implements AdminTab
|
||||||
|
{
|
||||||
|
public const GROUP = 'lit_mcp_access';
|
||||||
|
public const REVOKE_ACTION = 'lit_mcp_revoke';
|
||||||
|
|
||||||
|
/** @var callable(): void */
|
||||||
|
private $terminate;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private Access $access,
|
||||||
|
private ConnectionRepository $connections,
|
||||||
|
?callable $terminate = null
|
||||||
|
) {
|
||||||
|
$this->terminate = $terminate ?? static function (): void {
|
||||||
|
exit;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function slug(): string
|
||||||
|
{
|
||||||
|
return 'zugriff';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return __('Zugriff', 'wp-lit-mcp');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registerSettings(): void
|
||||||
|
{
|
||||||
|
register_setting(self::GROUP, Access::OPTION, [
|
||||||
|
'type' => 'array',
|
||||||
|
'sanitize_callback' => [self::class, 'sanitizeRoles'],
|
||||||
|
'default' => ['administrator'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registerActions(): void
|
||||||
|
{
|
||||||
|
add_action('admin_post_' . self::REVOKE_ACTION, [$this, 'handleRevoke']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps only existing role slugs. Runs twice on the very first save (update_option
|
||||||
|
* falls through to add_option, which sanitizes again), so each warning is added
|
||||||
|
* only once. The settings page itself needs manage_options, not lit_mcp_use, so
|
||||||
|
* removing the current user's own role is allowed – but warned about.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function sanitizeRoles(mixed $input): array
|
||||||
|
{
|
||||||
|
$existing = array_keys((array) wp_roles()->roles);
|
||||||
|
$roles = array_values(array_unique(array_intersect(
|
||||||
|
array_filter(is_array($input) ? $input : [], 'is_string'),
|
||||||
|
$existing
|
||||||
|
)));
|
||||||
|
|
||||||
|
if ($roles === []) {
|
||||||
|
self::warnOnce(
|
||||||
|
'lit_mcp_no_roles',
|
||||||
|
__('Keine Rolle freigegeben – niemand kann LIT MCP nutzen.', 'wp-lit-mcp')
|
||||||
|
);
|
||||||
|
} elseif (self::currentUserLosesAccess($roles)) {
|
||||||
|
self::warnOnce(
|
||||||
|
'lit_mcp_own_role',
|
||||||
|
__(
|
||||||
|
'Keine Ihrer Rollen ist mehr freigegeben – Sie selbst können LIT MCP nicht mehr nutzen.',
|
||||||
|
'wp-lit-mcp'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $roles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): void
|
||||||
|
{
|
||||||
|
$allowed = $this->access->allowedRoles();
|
||||||
|
|
||||||
|
echo '<h2>' . esc_html__('Freigegebene Rollen', 'wp-lit-mcp') . '</h2>';
|
||||||
|
echo '<p>' . esc_html__(
|
||||||
|
'Nur Benutzer dieser Rollen können Tools nutzen; zusätzlich gelten ihre normalen WordPress-Rechte.',
|
||||||
|
'wp-lit-mcp'
|
||||||
|
) . '</p>';
|
||||||
|
echo '<form method="post" action="options.php">';
|
||||||
|
settings_fields(self::GROUP);
|
||||||
|
// Makes an all-unchecked submission arrive as [''] instead of dropping the field.
|
||||||
|
printf('<input type="hidden" name="%s[]" value="">', esc_attr(Access::OPTION));
|
||||||
|
foreach (wp_roles()->get_names() as $role => $name) {
|
||||||
|
printf(
|
||||||
|
'<label><input type="checkbox" name="%s[]" value="%s"%s> %s</label><br>',
|
||||||
|
esc_attr(Access::OPTION),
|
||||||
|
esc_attr((string) $role),
|
||||||
|
checked(in_array($role, $allowed, true), true, false),
|
||||||
|
esc_html(translate_user_role((string) $name))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
submit_button();
|
||||||
|
echo '</form>';
|
||||||
|
|
||||||
|
$this->renderConnections();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handleRevoke(): void
|
||||||
|
{
|
||||||
|
if (! current_user_can('manage_options')) {
|
||||||
|
wp_die(esc_html__('Keine Berechtigung.', 'wp-lit-mcp'), '', ['response' => 403]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// phpcs:disable WordPress.Security.NonceVerification.Missing -- verified right below.
|
||||||
|
$uuid = isset($_POST['uuid']) ? sanitize_text_field(wp_unslash((string) $_POST['uuid'])) : '';
|
||||||
|
$userId = isset($_POST['user_id']) ? absint($_POST['user_id']) : 0;
|
||||||
|
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||||
|
check_admin_referer(self::REVOKE_ACTION . '_' . $uuid);
|
||||||
|
|
||||||
|
$revoked = $this->connections->revoke($userId, $uuid);
|
||||||
|
$notice = $revoked ? 'revoked' : 'revoke_failed';
|
||||||
|
wp_safe_redirect(SettingsPage::url($this->slug(), ['lit_mcp_notice' => $notice]));
|
||||||
|
($this->terminate)();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function warnOnce(string $code, string $message): void
|
||||||
|
{
|
||||||
|
foreach ((array) get_settings_errors(Access::OPTION) as $error) {
|
||||||
|
if (is_array($error) && ($error['code'] ?? '') === $code) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
add_settings_error(Access::OPTION, $code, $message, 'warning');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<string> $roles */
|
||||||
|
private static function currentUserLosesAccess(array $roles): bool
|
||||||
|
{
|
||||||
|
// Multisite super admins pass every capability check regardless of role.
|
||||||
|
if (is_multisite() && is_super_admin()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = wp_get_current_user();
|
||||||
|
|
||||||
|
return array_intersect((array) $user->roles, $roles) === [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderConnections(): void
|
||||||
|
{
|
||||||
|
echo '<h2>' . esc_html__('Aktive Verbindungen', 'wp-lit-mcp') . '</h2>';
|
||||||
|
$connections = $this->connections->all();
|
||||||
|
if ($connections === []) {
|
||||||
|
echo '<p>' . esc_html__('Keine aktiven Verbindungen.', 'wp-lit-mcp') . '</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo '<table class="widefat striped"><thead><tr>';
|
||||||
|
$headings = [
|
||||||
|
__('Benutzer', 'wp-lit-mcp'),
|
||||||
|
__('Client', 'wp-lit-mcp'),
|
||||||
|
__('Erstellt', 'wp-lit-mcp'),
|
||||||
|
__('Zuletzt genutzt', 'wp-lit-mcp'),
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
foreach ($headings as $heading) {
|
||||||
|
echo '<th>' . esc_html($heading) . '</th>';
|
||||||
|
}
|
||||||
|
echo '</tr></thead><tbody>';
|
||||||
|
|
||||||
|
foreach ($connections as $connection) {
|
||||||
|
$this->renderConnectionRow($connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo '</tbody></table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{user_id: int, user_login: string, uuid: string, name: string,
|
||||||
|
* app_id: string, created: int, last_used: ?int} $connection
|
||||||
|
*/
|
||||||
|
private function renderConnectionRow(array $connection): void
|
||||||
|
{
|
||||||
|
echo '<tr>';
|
||||||
|
echo '<td>' . esc_html($connection['user_login']) . '</td>';
|
||||||
|
printf('<td>%s<br><code>%s</code></td>', esc_html($connection['name']), esc_html($connection['app_id']));
|
||||||
|
echo '<td>' . esc_html((string) wp_date('Y-m-d H:i', $connection['created'])) . '</td>';
|
||||||
|
$lastUsed = $connection['last_used'] === null
|
||||||
|
? '–'
|
||||||
|
: (string) wp_date('Y-m-d H:i', $connection['last_used']);
|
||||||
|
echo '<td>' . esc_html($lastUsed) . '</td>';
|
||||||
|
echo '<td>';
|
||||||
|
printf('<form method="post" action="%s">', esc_url(admin_url('admin-post.php')));
|
||||||
|
printf('<input type="hidden" name="action" value="%s">', esc_attr(self::REVOKE_ACTION));
|
||||||
|
printf('<input type="hidden" name="user_id" value="%s">', esc_attr((string) $connection['user_id']));
|
||||||
|
printf('<input type="hidden" name="uuid" value="%s">', esc_attr($connection['uuid']));
|
||||||
|
wp_nonce_field(self::REVOKE_ACTION . '_' . $connection['uuid']);
|
||||||
|
submit_button(__('Widerrufen', 'wp-lit-mcp'), 'delete small', 'submit', false);
|
||||||
|
echo '</form></td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Admin;
|
||||||
|
|
||||||
|
use WPMedia\MCP\OAuth\Auth\TokenEndpoint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth connections created by mcp-oauth (v1.1.2, TokenEndpoint): for every grant it
|
||||||
|
* creates one application password (name = client name, app_id = the CIMD client_id,
|
||||||
|
* always an https URL) and stores the refresh-token marker user meta
|
||||||
|
* `mcp_refresh_jti_<uuid>`. An application password counts as an LIT MCP connection
|
||||||
|
* only if BOTH hold: the marker meta exists and its app_id is an https URL. Any other
|
||||||
|
* application password of the user (other tools, manual ones) is neither listed nor
|
||||||
|
* revoked.
|
||||||
|
*
|
||||||
|
* Multisite: only users who are members of the current site are managed, and a
|
||||||
|
* non-super-admin never sees or revokes a super admin's connections.
|
||||||
|
*
|
||||||
|
* Not final so the access tab can be tested with a mock.
|
||||||
|
*/
|
||||||
|
class ConnectionRepository
|
||||||
|
{
|
||||||
|
public function __construct(private ?\wpdb $db = null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{user_id: int, user_login: string, uuid: string, name: string,
|
||||||
|
* app_id: string, created: int, last_used: ?int}>
|
||||||
|
*/
|
||||||
|
public function all(): array
|
||||||
|
{
|
||||||
|
$wpdb = $this->db ?? $GLOBALS['wpdb'];
|
||||||
|
$prefix = TokenEndpoint::REFRESH_JTI_META_PREFIX;
|
||||||
|
$rows = $wpdb->get_results($wpdb->prepare(
|
||||||
|
"SELECT user_id, meta_key FROM {$wpdb->usermeta} WHERE meta_key LIKE %s ORDER BY user_id, umeta_id",
|
||||||
|
$wpdb->esc_like($prefix) . '%'
|
||||||
|
));
|
||||||
|
|
||||||
|
$connections = [];
|
||||||
|
foreach ((array) $rows as $row) {
|
||||||
|
$userId = (int) $row->user_id;
|
||||||
|
$uuid = substr((string) $row->meta_key, strlen($prefix));
|
||||||
|
if ($uuid === '' || ! $this->manageable($userId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$item = \WP_Application_Passwords::get_user_application_password($userId, $uuid);
|
||||||
|
if (! self::isMcpOauthPassword($item)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = get_userdata($userId);
|
||||||
|
$connections[] = [
|
||||||
|
'user_id' => $userId,
|
||||||
|
'user_login' => is_object($user) ? (string) $user->user_login : '#' . $userId,
|
||||||
|
'uuid' => $uuid,
|
||||||
|
'name' => (string) ($item['name'] ?? ''),
|
||||||
|
'app_id' => (string) $item['app_id'],
|
||||||
|
'created' => (int) ($item['created'] ?? 0),
|
||||||
|
'last_used' => isset($item['last_used']) ? (int) $item['last_used'] : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $connections;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function revoke(int $userId, string $uuid): bool
|
||||||
|
{
|
||||||
|
if ($userId <= 0 || $uuid === '' || ! $this->manageable($userId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$marker = (string) get_user_meta($userId, TokenEndpoint::REFRESH_JTI_META_PREFIX . $uuid, true);
|
||||||
|
if ($marker === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$item = \WP_Application_Passwords::get_user_application_password($userId, $uuid);
|
||||||
|
if (! self::isMcpOauthPassword($item)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fires wp_delete_application_password, on which mcp-oauth purges its marker meta.
|
||||||
|
return \WP_Application_Passwords::delete_application_password($userId, $uuid) === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the current administrator may see and revoke this user's connections. */
|
||||||
|
private function manageable(int $userId): bool
|
||||||
|
{
|
||||||
|
if (! is_multisite()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_super_admin($userId) && ! is_super_admin()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_user_member_of_blog($userId, get_current_blog_id());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function isMcpOauthPassword(mixed $item): bool
|
||||||
|
{
|
||||||
|
return is_array($item)
|
||||||
|
&& is_string($item['app_id'] ?? null)
|
||||||
|
&& str_starts_with($item['app_id'], 'https://');
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-1
@@ -6,7 +6,9 @@ namespace LIT\MCP;
|
|||||||
|
|
||||||
use LIT\MCP\Abilities\AbilityRegistrar;
|
use LIT\MCP\Abilities\AbilityRegistrar;
|
||||||
use LIT\MCP\Abilities\ExposurePolicy;
|
use LIT\MCP\Abilities\ExposurePolicy;
|
||||||
|
use LIT\MCP\Admin\AccessTab;
|
||||||
use LIT\MCP\Admin\AdminNotices;
|
use LIT\MCP\Admin\AdminNotices;
|
||||||
|
use LIT\MCP\Admin\ConnectionRepository;
|
||||||
use LIT\MCP\Admin\ConnectionStatus;
|
use LIT\MCP\Admin\ConnectionStatus;
|
||||||
use LIT\MCP\Admin\ConnectionTab;
|
use LIT\MCP\Admin\ConnectionTab;
|
||||||
use LIT\MCP\Admin\SettingsPage;
|
use LIT\MCP\Admin\SettingsPage;
|
||||||
@@ -47,7 +49,8 @@ final class Plugin
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
(new Access())->register();
|
$access = new Access();
|
||||||
|
$access->register();
|
||||||
(new RequestContext())->register();
|
(new RequestContext())->register();
|
||||||
(new VisibilityFilter())->register();
|
(new VisibilityFilter())->register();
|
||||||
|
|
||||||
@@ -78,6 +81,7 @@ final class Plugin
|
|||||||
(new SiteHealth($status))->register();
|
(new SiteHealth($status))->register();
|
||||||
self::$settingsPage = new SettingsPage([
|
self::$settingsPage = new SettingsPage([
|
||||||
new ConnectionTab($status),
|
new ConnectionTab($status),
|
||||||
|
new AccessTab($access, new ConnectionRepository()),
|
||||||
]);
|
]);
|
||||||
if (is_admin()) {
|
if (is_admin()) {
|
||||||
self::$settingsPage->register();
|
self::$settingsPage->register();
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
if (! class_exists('WP_Application_Passwords')) {
|
||||||
|
/**
|
||||||
|
* In-memory stand-in for WordPress' application password store.
|
||||||
|
*/
|
||||||
|
class WP_Application_Passwords
|
||||||
|
{
|
||||||
|
/** @var array<int, array<string, array<string, mixed>>> user ID => uuid => item */
|
||||||
|
public static array $items = [];
|
||||||
|
|
||||||
|
public static function get_user_application_password($user_id, $uuid)
|
||||||
|
{
|
||||||
|
return self::$items[(int) $user_id][(string) $uuid] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function get_user_application_passwords($user_id)
|
||||||
|
{
|
||||||
|
return array_values(self::$items[(int) $user_id] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function delete_application_password($user_id, $uuid)
|
||||||
|
{
|
||||||
|
if (! isset(self::$items[(int) $user_id][(string) $uuid])) {
|
||||||
|
return new WP_Error('application_password_not_found', 'Not found.');
|
||||||
|
}
|
||||||
|
unset(self::$items[(int) $user_id][(string) $uuid]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Admin;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use LIT\MCP\Access;
|
||||||
|
use LIT\MCP\Admin\AccessTab;
|
||||||
|
use LIT\MCP\Admin\ConnectionRepository;
|
||||||
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
|
use Mockery;
|
||||||
|
use RuntimeException;
|
||||||
|
use WP_User;
|
||||||
|
|
||||||
|
final class AccessTabTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
Functions\stubEscapeFunctions();
|
||||||
|
Functions\stubTranslationFunctions();
|
||||||
|
$roles = Mockery::mock();
|
||||||
|
$roles->roles = ['administrator' => [], 'editor' => []];
|
||||||
|
$roles->allows('get_names')->andReturn(['administrator' => 'Administrator', 'editor' => 'Redakteur']);
|
||||||
|
Functions\when('wp_roles')->justReturn($roles);
|
||||||
|
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\when('get_settings_errors')->justReturn([]);
|
||||||
|
Functions\when('wp_get_current_user')->justReturn(new WP_User(1, ['editor']));
|
||||||
|
Functions\when('is_multisite')->justReturn(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$_POST = [];
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRegistersTheRolesOptionWithSanitizer(): void
|
||||||
|
{
|
||||||
|
Functions\expect('register_setting')->once()->with(
|
||||||
|
'lit_mcp_access',
|
||||||
|
'lit_mcp_allowed_roles',
|
||||||
|
Mockery::on(static fn (array $args): bool => $args['sanitize_callback'] === [AccessTab::class, 'sanitizeRoles'])
|
||||||
|
);
|
||||||
|
|
||||||
|
(new AccessTab(new Access(), Mockery::mock(ConnectionRepository::class)))->registerSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRegistersTheRevokeHandler(): void
|
||||||
|
{
|
||||||
|
$tab = new AccessTab(new Access(), Mockery::mock(ConnectionRepository::class));
|
||||||
|
Functions\expect('add_action')->once()->with('admin_post_lit_mcp_revoke', [$tab, 'handleRevoke']);
|
||||||
|
|
||||||
|
$tab->registerActions();
|
||||||
|
self::assertSame('zugriff', $tab->slug());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSanitizeKeepsExistingRolesOnlyAndWarnsWhenEmpty(): void
|
||||||
|
{
|
||||||
|
Functions\expect('add_settings_error')->once()->with(
|
||||||
|
'lit_mcp_allowed_roles',
|
||||||
|
'lit_mcp_no_roles',
|
||||||
|
Mockery::type('string'),
|
||||||
|
'warning'
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(['editor'], AccessTab::sanitizeRoles(['editor', 'nope', '', 5, 'editor']));
|
||||||
|
self::assertSame([], AccessTab::sanitizeRoles(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSanitizeDoesNotRepeatAWarningOnTheSecondPassOfAFirstSave(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_settings_errors')->justReturn([
|
||||||
|
['setting' => 'lit_mcp_allowed_roles', 'code' => 'lit_mcp_no_roles', 'message' => 'x', 'type' => 'warning'],
|
||||||
|
]);
|
||||||
|
Functions\expect('add_settings_error')->never();
|
||||||
|
|
||||||
|
self::assertSame([], AccessTab::sanitizeRoles(['']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSanitizeWarnsWhenTheCurrentUserLosesMcpAccess(): void
|
||||||
|
{
|
||||||
|
Functions\when('wp_get_current_user')->justReturn(new WP_User(1, ['administrator']));
|
||||||
|
Functions\expect('add_settings_error')->once()->with(
|
||||||
|
'lit_mcp_allowed_roles',
|
||||||
|
'lit_mcp_own_role',
|
||||||
|
Mockery::type('string'),
|
||||||
|
'warning'
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(['editor'], AccessTab::sanitizeRoles(['editor']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSanitizeDoesNotWarnWhenTheCurrentUserKeepsAccess(): void
|
||||||
|
{
|
||||||
|
Functions\when('wp_get_current_user')->justReturn(new WP_User(1, ['administrator']));
|
||||||
|
Functions\expect('add_settings_error')->never();
|
||||||
|
|
||||||
|
self::assertSame(['administrator', 'editor'], AccessTab::sanitizeRoles(['administrator', 'editor']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSanitizeDoesNotWarnMultisiteSuperAdmins(): void
|
||||||
|
{
|
||||||
|
Functions\when('wp_get_current_user')->justReturn(new WP_User(1, []));
|
||||||
|
Functions\when('is_multisite')->justReturn(true);
|
||||||
|
Functions\when('is_super_admin')->justReturn(true);
|
||||||
|
Functions\expect('add_settings_error')->never();
|
||||||
|
|
||||||
|
self::assertSame(['editor'], AccessTab::sanitizeRoles(['editor']));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRevokeChecksNonceAndRedirectsWithNotice(): void
|
||||||
|
{
|
||||||
|
$_POST = ['user_id' => '3', 'uuid' => 'uuid-1'];
|
||||||
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
|
Functions\expect('check_admin_referer')->once()->with('lit_mcp_revoke_uuid-1');
|
||||||
|
$connections = Mockery::mock(ConnectionRepository::class);
|
||||||
|
$connections->expects('revoke')->with(3, 'uuid-1')->andReturn(true);
|
||||||
|
Functions\expect('wp_safe_redirect')->once()->with(Mockery::on(
|
||||||
|
static fn (string $url): bool => str_contains($url, 'tab=zugriff') && str_contains($url, 'lit_mcp_notice=revoked')
|
||||||
|
));
|
||||||
|
$terminated = false;
|
||||||
|
|
||||||
|
(new AccessTab(new Access(), $connections, static function () use (&$terminated): void {
|
||||||
|
$terminated = true;
|
||||||
|
}))->handleRevoke();
|
||||||
|
|
||||||
|
self::assertTrue($terminated);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRevokeFailureRedirectsWithErrorNotice(): void
|
||||||
|
{
|
||||||
|
$_POST = ['user_id' => '3', 'uuid' => 'uuid-other'];
|
||||||
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
|
Functions\when('check_admin_referer')->justReturn(1);
|
||||||
|
$connections = Mockery::mock(ConnectionRepository::class);
|
||||||
|
$connections->expects('revoke')->with(3, 'uuid-other')->andReturn(false);
|
||||||
|
Functions\expect('wp_safe_redirect')->once()->with(Mockery::on(
|
||||||
|
static fn (string $url): bool => str_contains($url, 'lit_mcp_notice=revoke_failed')
|
||||||
|
));
|
||||||
|
|
||||||
|
(new AccessTab(new Access(), $connections, static function (): void {
|
||||||
|
}))->handleRevoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRevokeRequiresManageOptions(): void
|
||||||
|
{
|
||||||
|
$_POST = ['user_id' => '3', 'uuid' => 'uuid-1'];
|
||||||
|
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();
|
||||||
|
$connections = Mockery::mock(ConnectionRepository::class);
|
||||||
|
$connections->expects('revoke')->never();
|
||||||
|
|
||||||
|
$this->expectException(RuntimeException::class);
|
||||||
|
(new AccessTab(new Access(), $connections))->handleRevoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRenderShowsRolesAndConnections(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_option')->justReturn(['editor']);
|
||||||
|
Functions\when('translate_user_role')->returnArg();
|
||||||
|
Functions\when('checked')->alias(static fn ($a, $b = true, $echo = true): string => $a === $b ? ' checked="checked"' : '');
|
||||||
|
Functions\when('settings_fields')->justReturn(null);
|
||||||
|
Functions\when('submit_button')->alias(static function (): void {
|
||||||
|
echo '<button>Speichern</button>';
|
||||||
|
});
|
||||||
|
Functions\when('wp_nonce_field')->alias(static function (string $action): void {
|
||||||
|
echo '<input type="hidden" name="_wpnonce" value="nonce-' . $action . '">';
|
||||||
|
});
|
||||||
|
Functions\when('wp_date')->justReturn('25.09.2026 10:00');
|
||||||
|
$connections = Mockery::mock(ConnectionRepository::class);
|
||||||
|
$connections->allows('all')->andReturn([[
|
||||||
|
'user_id' => 3,
|
||||||
|
'user_login' => 'redaktion<b>',
|
||||||
|
'uuid' => 'uuid-1',
|
||||||
|
'name' => 'Claude <script>',
|
||||||
|
'app_id' => 'https://claude.ai/x',
|
||||||
|
'created' => 1790000000,
|
||||||
|
'last_used' => null,
|
||||||
|
]]);
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
(new AccessTab(new Access(), $connections))->render();
|
||||||
|
$html = (string) ob_get_clean();
|
||||||
|
|
||||||
|
self::assertStringContainsString('value="editor" checked="checked"', $html);
|
||||||
|
self::assertStringNotContainsString('value="administrator" checked', $html);
|
||||||
|
self::assertStringContainsString('Claude <script>', $html);
|
||||||
|
self::assertStringContainsString('redaktion<b>', $html);
|
||||||
|
self::assertStringNotContainsString('<script>', $html);
|
||||||
|
self::assertStringContainsString('value="uuid-1"', $html);
|
||||||
|
self::assertStringContainsString('name="user_id" value="3"', $html);
|
||||||
|
self::assertStringContainsString('value="lit_mcp_revoke"', $html);
|
||||||
|
self::assertStringContainsString('nonce-lit_mcp_revoke_uuid-1', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRenderWithoutConnections(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_option')->justReturn(['administrator']);
|
||||||
|
Functions\when('translate_user_role')->returnArg();
|
||||||
|
Functions\when('checked')->justReturn('');
|
||||||
|
Functions\when('settings_fields')->justReturn(null);
|
||||||
|
Functions\when('submit_button')->justReturn(null);
|
||||||
|
$connections = Mockery::mock(ConnectionRepository::class);
|
||||||
|
$connections->allows('all')->andReturn([]);
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
(new AccessTab(new Access(), $connections))->render();
|
||||||
|
$html = (string) ob_get_clean();
|
||||||
|
|
||||||
|
self::assertStringContainsString('Keine aktiven Verbindungen.', $html);
|
||||||
|
self::assertStringNotContainsString('<table', $html);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Admin;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use LIT\MCP\Admin\ConnectionRepository;
|
||||||
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
|
use Mockery;
|
||||||
|
use WP_Application_Passwords;
|
||||||
|
|
||||||
|
final class ConnectionRepositoryTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
WP_Application_Passwords::$items = [
|
||||||
|
3 => [
|
||||||
|
'uuid-1' => ['uuid' => 'uuid-1', 'name' => 'Claude', 'app_id' => 'https://claude.ai/x', 'created' => 1790000000, 'last_used' => null],
|
||||||
|
'uuid-other' => ['uuid' => 'uuid-other', 'name' => 'Backup-Tool', 'app_id' => '', 'created' => 1, 'last_used' => null],
|
||||||
|
'uuid-foreign' => ['uuid' => 'uuid-foreign', 'name' => 'Fremd', 'app_id' => 'backup-tool', 'created' => 2, 'last_used' => 5],
|
||||||
|
],
|
||||||
|
4 => [
|
||||||
|
'uuid-4' => ['uuid' => 'uuid-4', 'name' => 'Claude', 'app_id' => 'https://claude.ai/x', 'created' => 3, 'last_used' => 7],
|
||||||
|
],
|
||||||
|
5 => [
|
||||||
|
'uuid-5' => ['uuid' => 'uuid-5', 'name' => 'Claude', 'app_id' => 'https://claude.ai/x', 'created' => 4, 'last_used' => null],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
Functions\when('is_multisite')->justReturn(false);
|
||||||
|
Functions\when('get_userdata')->alias(
|
||||||
|
static fn (int $id): object => (object) ['user_login' => 'user' . $id]
|
||||||
|
);
|
||||||
|
Functions\when('get_user_meta')->alias(
|
||||||
|
static fn (int $user, string $key): string => in_array(
|
||||||
|
$user . ':' . $key,
|
||||||
|
['3:mcp_refresh_jti_uuid-1', '3:mcp_refresh_jti_uuid-foreign', '4:mcp_refresh_jti_uuid-4', '5:mcp_refresh_jti_uuid-5'],
|
||||||
|
true
|
||||||
|
) ? 'jti' : ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
WP_Application_Passwords::$items = [];
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<array{0: string, 1: string}> $rows user ID, meta key */
|
||||||
|
private function db(array $rows): \wpdb
|
||||||
|
{
|
||||||
|
$db = Mockery::mock(\wpdb::class)->makePartial();
|
||||||
|
$db->expects('get_results')
|
||||||
|
->with(Mockery::on(static fn (string $sql): bool => str_contains($sql, 'FROM wp_usermeta')
|
||||||
|
&& str_contains($sql, "LIKE 'mcp\\_refresh\\_jti\\_%'")))
|
||||||
|
->andReturn(array_map(
|
||||||
|
static fn (array $row): object => (object) ['user_id' => $row[0], 'meta_key' => $row[1]],
|
||||||
|
$rows
|
||||||
|
));
|
||||||
|
|
||||||
|
return $db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testListsOnlyMcpOauthConnections(): void
|
||||||
|
{
|
||||||
|
$db = $this->db([
|
||||||
|
['3', 'mcp_refresh_jti_uuid-1'],
|
||||||
|
['3', 'mcp_refresh_jti_uuid-gone'],
|
||||||
|
['3', 'mcp_refresh_jti_uuid-foreign'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$connections = (new ConnectionRepository($db))->all();
|
||||||
|
|
||||||
|
self::assertCount(1, $connections);
|
||||||
|
self::assertSame(3, $connections[0]['user_id']);
|
||||||
|
self::assertSame('user3', $connections[0]['user_login']);
|
||||||
|
self::assertSame('Claude', $connections[0]['name']);
|
||||||
|
self::assertSame('uuid-1', $connections[0]['uuid']);
|
||||||
|
self::assertSame('https://claude.ai/x', $connections[0]['app_id']);
|
||||||
|
self::assertSame(1790000000, $connections[0]['created']);
|
||||||
|
self::assertNull($connections[0]['last_used']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMultisiteListsOnlyMembersAndHidesSuperAdminsFromOtherAdmins(): void
|
||||||
|
{
|
||||||
|
Functions\when('is_multisite')->justReturn(true);
|
||||||
|
Functions\when('get_current_blog_id')->justReturn(2);
|
||||||
|
Functions\when('is_user_member_of_blog')->alias(static fn (int $user, int $blog): bool => $blog === 2 && $user !== 4);
|
||||||
|
Functions\when('is_super_admin')->alias(static fn (?int $user = null): bool => $user === 5);
|
||||||
|
$db = $this->db([
|
||||||
|
['3', 'mcp_refresh_jti_uuid-1'],
|
||||||
|
['4', 'mcp_refresh_jti_uuid-4'],
|
||||||
|
['5', 'mcp_refresh_jti_uuid-5'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$connections = (new ConnectionRepository($db))->all();
|
||||||
|
|
||||||
|
self::assertSame(['uuid-1'], array_column($connections, 'uuid'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMultisiteSuperAdminSeesOtherSuperAdminsOfTheSite(): void
|
||||||
|
{
|
||||||
|
Functions\when('is_multisite')->justReturn(true);
|
||||||
|
Functions\when('get_current_blog_id')->justReturn(2);
|
||||||
|
Functions\when('is_user_member_of_blog')->alias(static fn (int $user, int $blog): bool => $user !== 4);
|
||||||
|
Functions\when('is_super_admin')->alias(static fn (?int $user = null): bool => $user === null || $user === 5);
|
||||||
|
$db = $this->db([
|
||||||
|
['3', 'mcp_refresh_jti_uuid-1'],
|
||||||
|
['4', 'mcp_refresh_jti_uuid-4'],
|
||||||
|
['5', 'mcp_refresh_jti_uuid-5'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$connections = (new ConnectionRepository($db))->all();
|
||||||
|
|
||||||
|
self::assertSame(['uuid-1', 'uuid-5'], array_column($connections, 'uuid'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRevokesOnlyMcpOauthConnections(): void
|
||||||
|
{
|
||||||
|
$repository = new ConnectionRepository(Mockery::mock(\wpdb::class)->makePartial());
|
||||||
|
|
||||||
|
self::assertFalse($repository->revoke(3, 'uuid-other'));
|
||||||
|
self::assertArrayHasKey('uuid-other', WP_Application_Passwords::$items[3]);
|
||||||
|
self::assertFalse($repository->revoke(3, 'uuid-foreign'), 'marker meta alone is not enough');
|
||||||
|
self::assertArrayHasKey('uuid-foreign', WP_Application_Passwords::$items[3]);
|
||||||
|
self::assertFalse($repository->revoke(3, 'uuid-gone'));
|
||||||
|
self::assertFalse($repository->revoke(0, 'uuid-1'));
|
||||||
|
self::assertFalse($repository->revoke(3, ''));
|
||||||
|
self::assertTrue($repository->revoke(3, 'uuid-1'));
|
||||||
|
self::assertArrayNotHasKey('uuid-1', WP_Application_Passwords::$items[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMultisiteRevokeRefusesNonMembersAndSuperAdmins(): void
|
||||||
|
{
|
||||||
|
Functions\when('is_multisite')->justReturn(true);
|
||||||
|
Functions\when('get_current_blog_id')->justReturn(2);
|
||||||
|
Functions\when('is_user_member_of_blog')->alias(static fn (int $user, int $blog): bool => $user !== 4);
|
||||||
|
Functions\when('is_super_admin')->alias(static fn (?int $user = null): bool => $user === 5);
|
||||||
|
$repository = new ConnectionRepository(Mockery::mock(\wpdb::class)->makePartial());
|
||||||
|
|
||||||
|
self::assertFalse($repository->revoke(4, 'uuid-4'));
|
||||||
|
self::assertFalse($repository->revoke(5, 'uuid-5'));
|
||||||
|
self::assertArrayHasKey('uuid-4', WP_Application_Passwords::$items[4]);
|
||||||
|
self::assertArrayHasKey('uuid-5', WP_Application_Passwords::$items[5]);
|
||||||
|
self::assertTrue($repository->revoke(3, 'uuid-1'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ final class RequestContextTest extends TestCase
|
|||||||
protected function tearDown(): void
|
protected function tearDown(): void
|
||||||
{
|
{
|
||||||
RequestContext::reset();
|
RequestContext::reset();
|
||||||
|
\WP_Application_Passwords::$items = [];
|
||||||
parent::tearDown();
|
parent::tearDown();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +100,28 @@ final class RequestContextTest extends TestCase
|
|||||||
self::assertSame('https://claude.ai/oauth/mcp-oauth-client-metadata', RequestContext::clientName());
|
self::assertSame('https://claude.ai/oauth/mcp-oauth-client-metadata', RequestContext::clientName());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testClientNameUsesTheApplicationPasswordName(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_option')->justReturn('test-secret');
|
||||||
|
\WP_Application_Passwords::$items = [
|
||||||
|
3 => ['uuid-1' => ['uuid' => 'uuid-1', 'name' => 'Claude', 'app_id' => 'https://claude.ai/x']],
|
||||||
|
];
|
||||||
|
$token = JWT::encode([
|
||||||
|
'sub' => '3',
|
||||||
|
'app_pass_id' => 'uuid-1',
|
||||||
|
'client_id' => 'https://claude.ai/oauth/mcp-oauth-client-metadata',
|
||||||
|
'exp' => time() + 60,
|
||||||
|
], 'test-secret');
|
||||||
|
|
||||||
|
RequestContext::recordRoute(
|
||||||
|
null,
|
||||||
|
[],
|
||||||
|
new WP_REST_Request(self::ROUTE, ['authorization' => 'Bearer ' . $token])
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame('Claude', RequestContext::clientName());
|
||||||
|
}
|
||||||
|
|
||||||
public function testClientNameMatchesTheBearerSchemeCaseInsensitively(): void
|
public function testClientNameMatchesTheBearerSchemeCaseInsensitively(): void
|
||||||
{
|
{
|
||||||
Functions\when('get_option')->justReturn('test-secret');
|
Functions\when('get_option')->justReturn('test-secret');
|
||||||
|
|||||||
@@ -13,3 +13,4 @@ require_once __DIR__ . '/Stubs/WpRestRequest.php';
|
|||||||
require_once __DIR__ . '/Stubs/Wpdb.php';
|
require_once __DIR__ . '/Stubs/Wpdb.php';
|
||||||
require_once __DIR__ . '/Stubs/WpPost.php';
|
require_once __DIR__ . '/Stubs/WpPost.php';
|
||||||
require_once __DIR__ . '/Stubs/WpTerm.php';
|
require_once __DIR__ . '/Stubs/WpTerm.php';
|
||||||
|
require_once __DIR__ . '/Stubs/WpApplicationPasswords.php';
|
||||||
|
|||||||
Reference in New Issue
Block a user