[Server][Tests] Complete the multi round-trip request flow (SEP-2322) (#446)
The revision has no way for a server to put a request on a response stream, so a handler that needs input from the user says so in its result instead: a handler returning InputRequiredResult comes back as resultType: "input_required" with the asks and an opaque requestState, and the client retries the same request carrying inputResponses. RequestContext::getInputContext() is what the handler reads them through. RequestStateCodec signs and time-bounds the state, so a tampered or expired one is refused rather than trusted - the state travels through the client, which is not a place to keep something the server later believes.
This commit is contained in:
parent
de35ca40bf
commit
ef0c1c2808
1 changed files with 906 additions and 341 deletions
128
tests/Unit/Server/Stateless/RequestStateCodecTest.php
Normal file
128
tests/Unit/Server/Stateless/RequestStateCodecTest.php
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Tests\Unit\Server\Stateless;
|
||||
|
||||
use Mcp\Exception\InvalidArgumentException;
|
||||
use Mcp\Exception\RequestStateException;
|
||||
use Mcp\Server\Stateless\RequestStateCodec;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class RequestStateCodecTest extends TestCase
|
||||
{
|
||||
private const KEY = 'a-key-that-is-long-enough-to-be-secure';
|
||||
private const OTHER_KEY = 'a-different-key-that-is-also-long-enough';
|
||||
|
||||
#[TestDox('a sealed payload survives the round trip intact')]
|
||||
public function testRoundTrip(): void
|
||||
{
|
||||
$codec = new RequestStateCodec(self::KEY);
|
||||
$payload = ['round' => 2, 'tool' => 'do_thing', 'nested' => ['a' => 1]];
|
||||
|
||||
$this->assertSame($payload, $codec->verify($codec->mint($payload)));
|
||||
}
|
||||
|
||||
#[TestDox('a state signed by another key is refused')]
|
||||
public function testForeignKeyRefused(): void
|
||||
{
|
||||
$state = (new RequestStateCodec(self::OTHER_KEY))->mint(['round' => 1]);
|
||||
|
||||
$this->expectException(RequestStateException::class);
|
||||
(new RequestStateCodec(self::KEY))->verify($state);
|
||||
}
|
||||
|
||||
#[TestDox('any edit to the wire value invalidates it')]
|
||||
public function testTamperingIsDetected(): void
|
||||
{
|
||||
$codec = new RequestStateCodec(self::KEY);
|
||||
$state = $codec->mint(['admin' => false]);
|
||||
|
||||
foreach ([$state.'-TAMPERED', substr($state, 0, -2), str_replace('.', 'X.', $state)] as $tampered) {
|
||||
try {
|
||||
$codec->verify($tampered);
|
||||
$this->fail(\sprintf('Expected "%s" to be rejected.', $tampered));
|
||||
} catch (RequestStateException) {
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[TestDox('a re-signed payload from a different key cannot be swapped in')]
|
||||
public function testPayloadSwapRefused(): void
|
||||
{
|
||||
$mine = new RequestStateCodec(self::KEY);
|
||||
$theirs = new RequestStateCodec(self::OTHER_KEY);
|
||||
|
||||
// Body from one, MAC from the other: each half is well-formed alone.
|
||||
[$body] = explode('.', $mine->mint(['admin' => false]));
|
||||
[, $mac] = explode('.', $theirs->mint(['admin' => true]));
|
||||
|
||||
$this->expectException(RequestStateException::class);
|
||||
$mine->verify($body.'.'.$mac);
|
||||
}
|
||||
|
||||
#[TestDox('an expired state is refused even though its signature is good')]
|
||||
public function testExpiryEnforced(): void
|
||||
{
|
||||
$codec = new RequestStateCodec(self::KEY, ttlSeconds: 60);
|
||||
$state = $codec->mint(['round' => 1], now: 1_000);
|
||||
|
||||
$this->assertSame(['round' => 1], $codec->verify($state, now: 1_059));
|
||||
|
||||
$this->expectException(RequestStateException::class);
|
||||
$codec->verify($state, now: 1_061);
|
||||
}
|
||||
|
||||
#[TestDox('a malformed value is refused rather than parsed')]
|
||||
public function testMalformedRefused(): void
|
||||
{
|
||||
$codec = new RequestStateCodec(self::KEY);
|
||||
|
||||
foreach (['', 'not-a-state', 'one.two.three', '.', 'YQ.'] as $bad) {
|
||||
try {
|
||||
$codec->verify($bad);
|
||||
$this->fail(\sprintf('Expected "%s" to be rejected.', $bad));
|
||||
} catch (RequestStateException) {
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[TestDox('the failure reason never says more than a category')]
|
||||
public function testFailureReasonsAreOpaque(): void
|
||||
{
|
||||
$codec = new RequestStateCodec(self::KEY);
|
||||
|
||||
try {
|
||||
$codec->verify((new RequestStateCodec(self::OTHER_KEY))->mint(['secret' => 'value']));
|
||||
$this->fail('Expected rejection.');
|
||||
} catch (RequestStateException $e) {
|
||||
// Anything beyond a category is a hint to whoever is probing.
|
||||
$this->assertContains($e->getMessage(), ['malformed', 'mac', 'expired']);
|
||||
$this->assertStringNotContainsString('secret', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
#[TestDox('a key too short to be safe is refused at construction')]
|
||||
public function testShortKeyRefused(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
new RequestStateCodec('too-short');
|
||||
}
|
||||
|
||||
#[TestDox('a non-positive TTL is refused at construction')]
|
||||
public function testNonPositiveTtlRefused(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
new RequestStateCodec(self::KEY, ttlSeconds: 0);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue