- AbilityRegistrar: wrap permission_callback in try/catch(Throwable) like execute_callback, so a throwing permission() denies (Errors::internal()) instead of skipping rest_request_after_callbacks and leaving RequestContext's flag set for a later request in the same PHP worker. - RequestContext::recordRoute() now recomputes the flag (and bearer token) unconditionally on every matched REST request instead of only setting it true for our own route, so a stale true left behind by an earlier request is reset by the next non-matching one (fail closed, defense in depth). - RequestContext::recordRoute() matches the Authorization "Bearer" scheme case-insensitively. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
150 lines
4.8 KiB
PHP
150 lines
4.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace LIT\MCP\Tests\Unit\Server;
|
|
|
|
use Brain\Monkey\Functions;
|
|
use LIT\MCP\Server\RequestContext;
|
|
use LIT\MCP\Tests\Unit\TestCase;
|
|
use stdClass;
|
|
use WP_REST_Request;
|
|
use WPMedia\MCP\OAuth\Auth\JWT;
|
|
|
|
final class RequestContextTest extends TestCase
|
|
{
|
|
private const ROUTE = '/mcp/mcp-oauth-server';
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
RequestContext::reset();
|
|
parent::tearDown();
|
|
}
|
|
|
|
public function testIsOAuthServerRequestIsFalseByDefault(): void
|
|
{
|
|
self::assertFalse(RequestContext::isOAuthServerRequest());
|
|
}
|
|
|
|
public function testRecordRouteMarksTheOAuthServerRoute(): void
|
|
{
|
|
RequestContext::recordRoute(null, [], new WP_REST_Request(self::ROUTE));
|
|
|
|
self::assertTrue(RequestContext::isOAuthServerRequest());
|
|
}
|
|
|
|
public function testRecordRouteIgnoresOtherRoutes(): void
|
|
{
|
|
RequestContext::recordRoute(null, [], new WP_REST_Request('/wp/v2/users/me'));
|
|
|
|
self::assertFalse(RequestContext::isOAuthServerRequest());
|
|
}
|
|
|
|
public function testRecordRouteResetsAStaleFlagOnALaterNonMatchingRequest(): void
|
|
{
|
|
// Simulates rest_request_after_callbacks never having fired for a
|
|
// previous OAuth server request (e.g. a fatal error mid-dispatch),
|
|
// leaving the flag orphaned as true in a persistent PHP worker.
|
|
RequestContext::reset(true);
|
|
|
|
RequestContext::recordRoute(null, [], new WP_REST_Request('/wp/v2/users/me'));
|
|
|
|
self::assertFalse(RequestContext::isOAuthServerRequest());
|
|
}
|
|
|
|
public function testRecordRouteReturnsResponseUnchanged(): void
|
|
{
|
|
$response = new stdClass();
|
|
|
|
self::assertSame($response, RequestContext::recordRoute($response, [], new WP_REST_Request(self::ROUTE)));
|
|
}
|
|
|
|
public function testClearRouteResetsTheFlagAfterOurRequest(): void
|
|
{
|
|
$request = new WP_REST_Request(self::ROUTE);
|
|
RequestContext::recordRoute(null, [], $request);
|
|
$response = new stdClass();
|
|
|
|
$result = RequestContext::clearRoute($response, [], $request);
|
|
|
|
self::assertSame($response, $result);
|
|
self::assertFalse(RequestContext::isOAuthServerRequest());
|
|
}
|
|
|
|
public function testClearRouteIgnoresNestedRequestsToOtherRoutes(): void
|
|
{
|
|
RequestContext::recordRoute(null, [], new WP_REST_Request(self::ROUTE));
|
|
|
|
RequestContext::clearRoute(null, [], new WP_REST_Request('/wp/v2/posts'));
|
|
|
|
self::assertTrue(RequestContext::isOAuthServerRequest());
|
|
}
|
|
|
|
public function testClientNameFallsBackToTheClientIdClaim(): void
|
|
{
|
|
Functions\when('get_option')->justReturn('test-secret');
|
|
$token = JWT::encode([
|
|
'sub' => '3',
|
|
'app_pass_id' => 'uuid-unknown',
|
|
'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('https://claude.ai/oauth/mcp-oauth-client-metadata', RequestContext::clientName());
|
|
}
|
|
|
|
public function testClientNameMatchesTheBearerSchemeCaseInsensitively(): void
|
|
{
|
|
Functions\when('get_option')->justReturn('test-secret');
|
|
$token = JWT::encode([
|
|
'sub' => '3',
|
|
'app_pass_id' => 'uuid-unknown',
|
|
'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('https://claude.ai/oauth/mcp-oauth-client-metadata', RequestContext::clientName());
|
|
}
|
|
|
|
public function testClientNameIsEmptyForAnInvalidToken(): void
|
|
{
|
|
Functions\when('get_option')->justReturn('test-secret');
|
|
|
|
RequestContext::recordRoute(null, [], new WP_REST_Request(self::ROUTE, ['authorization' => 'Bearer garbage']));
|
|
|
|
self::assertSame('', RequestContext::clientName());
|
|
}
|
|
|
|
public function testResetCanForceAClientName(): void
|
|
{
|
|
RequestContext::reset(true, 'Claude');
|
|
|
|
self::assertTrue(RequestContext::isOAuthServerRequest());
|
|
self::assertSame('Claude', RequestContext::clientName());
|
|
}
|
|
|
|
public function testRegisterHooksBeforeAndAfterCallbacks(): void
|
|
{
|
|
Functions\expect('add_filter')
|
|
->once()
|
|
->with('rest_request_before_callbacks', [RequestContext::class, 'recordRoute'], 10, 3);
|
|
Functions\expect('add_filter')
|
|
->once()
|
|
->with('rest_request_after_callbacks', [RequestContext::class, 'clearRoute'], 10, 3);
|
|
|
|
(new RequestContext())->register();
|
|
}
|
|
}
|