Files
WP-LIT-MCP/tests/Unit/Admin/AccessTabTest.php
T
janniclas.loosenandClaude Opus 5.5 843f45aeec feat: admin settings page UI/UX pass with WordPress-native patterns
- Header: h1 + logo in a wrapping flex row, hr.wp-header-end before the tabs,
  aria-current on the active tab; own CSS/JS enqueued only on this screen.
- Verbindung: ordered "So verbindest du Claude" steps, copy button (core
  clipboard.js + wp.a11y.speak), Site-Health-style status list, German details.
- Zugriff: roles as form-table fieldset, connections with site date format,
  empty-state row, confirm on revoke; ConnectionUsage records last use via
  WP_Application_Passwords::record_application_password_usage (once a day).
- Tools: plain-language legend, counter as inline notice, groups as core
  postboxes (postbox.js toggles, per-user closed state, summary when closed),
  fixed column widths with new "Art" column, submit above and below.
- Protokoll: site-timezone times with UTC tooltip, result icons, filters with
  labels in the list table nav, retention link to Limits.
- Limits: short labels with units and descriptions; snapshot count hidden but
  preserved.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 16:50:58 +02:00

281 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 testRevokeTreatsANonStringUuidAsMissing(): 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_');
$connections = Mockery::mock(ConnectionRepository::class);
$connections->expects('revoke')->with(3, '')->andReturn(false);
Functions\when('wp_safe_redirect')->justReturn(true);
(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();
}
/** @param mixed $roles value of the roles option */
private function stubRender(mixed $roles): void
{
Functions\when('get_option')->alias(static fn (string $key, $default = false) => match ($key) {
'lit_mcp_allowed_roles' => $roles,
'date_format' => 'd.m.Y',
'time_format' => 'H:i',
default => $default,
});
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 ($text = null, $type = 'primary', $name = 'submit', $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
)) : '';
echo '<button ' . $attrs . '>' . ($text ?? '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')->alias(static fn (string $format, int $ts): string => $format . '@' . $ts);
}
/** @param array<string, mixed> $overrides */
private function connection(array $overrides = []): array
{
return $overrides + [
'user_id' => 3,
'user_login' => 'redaktion<b>',
'uuid' => 'uuid-1',
'name' => 'Claude <script>',
'app_id' => 'https://claude.ai/x',
'created' => 1790000000,
'last_used' => null,
];
}
private function renderTab(array $connections): string
{
$repository = Mockery::mock(ConnectionRepository::class);
$repository->allows('all')->andReturn($connections);
ob_start();
(new AccessTab(new Access(), $repository))->render();
return (string) ob_get_clean();
}
public function testRenderShowsRolesAndConnections(): void
{
$this->stubRender(['editor']);
$html = $this->renderTab([$this->connection()]);
self::assertStringContainsString('value="editor" checked="checked"', $html);
self::assertStringNotContainsString('value="administrator" checked', $html);
self::assertStringContainsString('<strong>Claude &lt;script&gt;</strong>', $html);
self::assertStringContainsString('redaktion&lt;b&gt;', $html);
self::assertStringNotContainsString('<script>', $html);
self::assertStringNotContainsString('<code>', $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);
self::assertStringContainsString('confirm(', $html);
}
public function testRolesAreAFormTableFieldsetWithLegendAndDescription(): void
{
$this->stubRender(['editor']);
$html = $this->renderTab([]);
self::assertStringContainsString('<table class="form-table" role="presentation">', $html);
self::assertStringContainsString('<th scope="row">Rollen mit Zugriff</th>', $html);
self::assertStringContainsString('<fieldset><legend class="screen-reader-text"><span>Rollen mit Zugriff</span></legend>', $html);
self::assertStringContainsString('<p class="description">Nur Benutzer dieser Rollen können Claude verbinden.', $html);
}
public function testDatesUseTheSiteDateAndTimeFormat(): void
{
$this->stubRender(['editor']);
$html = $this->renderTab([
$this->connection(['uuid' => 'a', 'last_used' => null]),
$this->connection(['uuid' => 'b', 'last_used' => 1790050000]),
]);
self::assertStringContainsString('d.m.Y H:i@1790000000', $html);
self::assertStringContainsString('d.m.Y@1790050000', $html);
self::assertStringContainsString('<td class="column-last-used">–</td>', $html);
}
public function testRenderWithoutConnectionsShowsAnEmptyStateRowLinkingToTheConnectionTab(): void
{
$this->stubRender(['administrator']);
$html = $this->renderTab([]);
self::assertStringContainsString('<tr class="no-items"><td class="colspanchange" colspan="5">', $html);
self::assertStringContainsString('Noch keine Verbindung.', $html);
self::assertStringContainsString('href="https://kunde.de/wp-admin/options-general.php?page=lit-mcp&tab=verbindung"', $html);
}
}