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,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'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user