Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
241 lines
10 KiB
PHP
241 lines
10 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Media;
|
||
|
||
use Brain\Monkey\Actions;
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\Media\RemoteFileFetcher;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use WP_Error;
|
||
|
||
/**
|
||
* The URL guard and the download shared by the media import and the font upload. MediaImporterTest keeps covering
|
||
* the same guard through fromUrl(); this test pins the parts a second caller relies on: the size limit as
|
||
* parameter, null instead of an error message, the log context.
|
||
*/
|
||
final class RemoteFileFetcherTest extends TestCase
|
||
{
|
||
private string $tmp = '';
|
||
|
||
/** error_log() is a PHP internal and cannot be stubbed; it is redirected to this file. */
|
||
private string $errorLog = '';
|
||
private string|false $previousErrorLog = false;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
$this->tmp = (string) tempnam(sys_get_temp_dir(), 'lit-fetch');
|
||
$this->errorLog = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
|
||
$this->previousErrorLog = ini_set('error_log', $this->errorLog);
|
||
Functions\when('wp_parse_url')->alias('parse_url');
|
||
Functions\when('wp_http_validate_url')->returnArg();
|
||
Functions\when('wp_remote_retrieve_response_code')->alias(static fn (array $r): int => $r['response']['code']);
|
||
}
|
||
|
||
protected function tearDown(): void
|
||
{
|
||
ini_set('error_log', (string) $this->previousErrorLog);
|
||
foreach ([$this->tmp, $this->errorLog] as $file) {
|
||
if (is_file($file)) {
|
||
unlink($file);
|
||
}
|
||
}
|
||
parent::tearDown();
|
||
}
|
||
|
||
/** @param list<string> $addresses What every host name resolves to (no DNS in unit tests). */
|
||
private function fetcher(array $addresses = ['93.184.216.34']): RemoteFileFetcher
|
||
{
|
||
return new RemoteFileFetcher(static fn (string $host): array => $addresses);
|
||
}
|
||
|
||
public function testOnlyPublicHttpUrlsAreAllowed(): void
|
||
{
|
||
self::assertTrue($this->fetcher()->allows('https://fonts.example.com/inter.woff2'));
|
||
self::assertTrue($this->fetcher()->allows('HTTP://fonts.example.com/inter.woff2'));
|
||
self::assertFalse($this->fetcher()->allows('ftp://fonts.example.com/inter.woff2'));
|
||
self::assertFalse($this->fetcher()->allows('file:///etc/passwd'));
|
||
self::assertFalse($this->fetcher()->allows('http://169.254.169.254/latest/meta-data/'));
|
||
self::assertFalse($this->fetcher(['10.0.0.5'])->allows('https://intern.example.com/x.woff2'));
|
||
self::assertFalse($this->fetcher([])->allows('https://nirgends.example.com/x.woff2'));
|
||
|
||
Functions\when('wp_http_validate_url')->justReturn(false);
|
||
self::assertFalse($this->fetcher()->allows('https://fonts.example.com/inter.woff2'));
|
||
}
|
||
|
||
public function testTheBodyIsStreamedIntoTheTempFileAndCutOffOneByteAboveTheLimit(): void
|
||
{
|
||
Functions\expect('wp_safe_remote_get')->once()->with(
|
||
'https://fonts.example.com/inter.woff2',
|
||
Mockery::on(function (array $args): bool {
|
||
file_put_contents($args['filename'], 'wOF2DATA');
|
||
|
||
return $args['filename'] === $this->tmp && $args['stream'] === true && $args['timeout'] === 30
|
||
&& $args['limit_response_size'] === 2048 + 1;
|
||
})
|
||
)->andReturn(['response' => ['code' => 200], 'headers' => ['content-type' => 'font/woff2']]);
|
||
|
||
$response = $this->fetcher()->fetch('https://fonts.example.com/inter.woff2', $this->tmp, 2048, 'font upload');
|
||
|
||
self::assertSame(['code' => 200], $response['response'] ?? null);
|
||
self::assertSame('wOF2DATA', file_get_contents($this->tmp));
|
||
}
|
||
|
||
public function testARefusedUrlIsNullAndNeverRequested(): void
|
||
{
|
||
Functions\expect('wp_safe_remote_get')->never();
|
||
|
||
self::assertNull($this->fetcher(['10.0.0.5'])->fetch('https://intern.example.com/x.woff2', $this->tmp, 2048, 'x'));
|
||
self::assertNull($this->fetcher()->fetch('file:///etc/passwd', $this->tmp, 2048, 'x'));
|
||
self::assertStringContainsString('URL refused', (string) file_get_contents($this->errorLog));
|
||
}
|
||
|
||
public function testAFailedRequestIsNullAndOnlyTheLogNamesTheReasonAndTheContext(): void
|
||
{
|
||
Functions\when('wp_safe_remote_get')->justReturn(new WP_Error('http_request_failed', 'Connection refused'));
|
||
|
||
$response = $this->fetcher()->fetch(
|
||
'https://nutzer:geheim@fonts.example.com:8443/inter.woff2?token=abc',
|
||
$this->tmp,
|
||
2048,
|
||
'font upload'
|
||
);
|
||
|
||
$log = (string) file_get_contents($this->errorLog);
|
||
self::assertNull($response);
|
||
self::assertStringContainsString('font upload from https://fonts.example.com:8443/inter.woff2', $log);
|
||
self::assertStringContainsString('Connection refused', $log);
|
||
self::assertStringNotContainsString('geheim', $log);
|
||
self::assertStringNotContainsString('token=abc', $log);
|
||
}
|
||
|
||
public function testAnHttpErrorStatusIsNull(): void
|
||
{
|
||
Functions\when('wp_safe_remote_get')->justReturn(['response' => ['code' => 404], 'headers' => []]);
|
||
|
||
self::assertNull($this->fetcher()->fetch('https://fonts.example.com/fehlt.woff2', $this->tmp, 2048, 'x'));
|
||
self::assertStringContainsString('HTTP status 404', (string) file_get_contents($this->errorLog));
|
||
}
|
||
|
||
public function testTheIpv4AndRedirectHooksLiveOnlyDuringTheRequest(): void
|
||
{
|
||
$listener = null;
|
||
Actions\expectAdded('http_api_curl')->once();
|
||
Actions\expectAdded('requests-requests.before_redirect')->once()
|
||
->whenHappen(static function (callable $callback) use (&$listener): void {
|
||
$listener = $callback;
|
||
});
|
||
Functions\when('wp_safe_remote_get')->justReturn(['response' => ['code' => 200], 'headers' => []]);
|
||
|
||
$this->fetcher()->fetch('https://fonts.example.com/inter.woff2', $this->tmp, 2048, 'x');
|
||
|
||
self::assertFalse(has_action('http_api_curl'));
|
||
self::assertFalse(has_action('requests-requests.before_redirect'));
|
||
self::assertIsCallable($listener);
|
||
$listener('https://cdn.example.com/inter.woff2'); // public: no exception
|
||
$this->expectException(\WpOrg\Requests\Exception::class);
|
||
$listener('http://127.0.0.1/inter.woff2');
|
||
}
|
||
|
||
/**
|
||
* A fetcher whose cURL options are recorded; $dns is called per lookup (host => addresses).
|
||
*
|
||
* @param \Closure(string): list<string> $dns
|
||
* @return object{options: list<array{0: int, 1: mixed}>}&RemoteFileFetcher
|
||
*/
|
||
private function recordingFetcher(\Closure $dns, bool $curl = true): RemoteFileFetcher
|
||
{
|
||
return new class ($dns, $curl) extends RemoteFileFetcher {
|
||
/** @var list<array{0: int, 1: mixed}> */
|
||
public array $options = [];
|
||
|
||
public function __construct(\Closure $dns, private bool $curl)
|
||
{
|
||
parent::__construct($dns);
|
||
}
|
||
|
||
protected function curlAvailable(): bool
|
||
{
|
||
return $this->curl;
|
||
}
|
||
|
||
protected function curlSetopt($handle, int $option, mixed $value): void
|
||
{
|
||
$this->options[] = [$option, $value];
|
||
}
|
||
};
|
||
}
|
||
|
||
/** Audit L8: no cURL, no request – the IPv4 rule and the pinning are cURL options. */
|
||
public function testWithoutCurlNothingIsRequested(): void
|
||
{
|
||
Functions\expect('wp_safe_remote_get')->never();
|
||
$fetcher = $this->recordingFetcher(static fn (): array => ['93.184.216.34'], false);
|
||
|
||
self::assertNull($fetcher->fetch('https://fonts.example.com/inter.woff2', $this->tmp, 2048, 'font upload'));
|
||
self::assertStringContainsString('no cURL transport', (string) file_get_contents($this->errorLog));
|
||
}
|
||
|
||
/** @return array{0: callable, 1: callable} The http_api_curl and the redirect listener of one fetch(). */
|
||
private function listeners(RemoteFileFetcher $fetcher, string $url): array
|
||
{
|
||
$curl = $redirect = null;
|
||
Actions\expectAdded('http_api_curl')->once()->whenHappen(static function (callable $cb) use (&$curl): void {
|
||
$curl = $cb;
|
||
});
|
||
Actions\expectAdded('requests-requests.before_redirect')->once()
|
||
->whenHappen(static function (callable $cb) use (&$redirect): void {
|
||
$redirect = $cb;
|
||
});
|
||
Functions\when('wp_safe_remote_get')->justReturn(['response' => ['code' => 200], 'headers' => []]);
|
||
$fetcher->fetch($url, $this->tmp, 2048, 'x');
|
||
|
||
return [$curl, $redirect];
|
||
}
|
||
|
||
/** Audit (DNS rebinding): cURL connects to the address that was checked, IPv4 only. */
|
||
public function testTheCheckedAddressIsPinnedForTheHostAndEveryRedirectTarget(): void
|
||
{
|
||
$fetcher = $this->recordingFetcher(static fn (string $host): array => [
|
||
'fonts.example.com' => ['93.184.216.34', '93.184.216.35'], 'cdn.example.com' => ['198.51.101.7'],
|
||
][$host]);
|
||
[$curl, $redirect] = $this->listeners($fetcher, 'https://fonts.example.com/inter.woff2');
|
||
|
||
$curl(curl_init(), [], 'https://fonts.example.com/inter.woff2');
|
||
self::assertSame(
|
||
[[CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4], [CURLOPT_RESOLVE, ['fonts.example.com:443:93.184.216.34']]],
|
||
$fetcher->options
|
||
);
|
||
|
||
$fetcher->options = [];
|
||
$redirect('http://cdn.example.com:8080/inter.woff2');
|
||
$curl(curl_init(), [], 'https://fonts.example.com/inter.woff2'); // the redirect request: same original URL
|
||
self::assertSame(
|
||
[
|
||
[CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4],
|
||
[CURLOPT_RESOLVE, ['fonts.example.com:443:93.184.216.34', 'cdn.example.com:8080:198.51.101.7']],
|
||
],
|
||
$fetcher->options
|
||
);
|
||
}
|
||
|
||
public function testAHostThatTurnsPrivateAfterTheCheckIsNotConnected(): void
|
||
{
|
||
$lookups = 0;
|
||
$fetcher = $this->recordingFetcher(static function () use (&$lookups): array {
|
||
return ++$lookups === 1 ? ['93.184.216.34'] : ['127.0.0.1'];
|
||
});
|
||
[$curl] = $this->listeners($fetcher, 'https://rebind.example.com/a.woff2');
|
||
|
||
$this->expectException(\WpOrg\Requests\Exception::class);
|
||
try {
|
||
$curl(curl_init(), [], 'https://rebind.example.com/a.woff2');
|
||
} finally {
|
||
self::assertSame([[CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4]], $fetcher->options, 'no pin to the new address');
|
||
}
|
||
}
|
||
}
|