Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
281 lines
12 KiB
PHP
281 lines
12 KiB
PHP
<?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 <script></strong>', $html);
|
||
self::assertStringContainsString('redaktion<b>', $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 einen KI-Assistenten 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);
|
||
}
|
||
}
|