Files
WP-LIT-MCP/tests/Unit/Integrations/Media/RemoteFileFetcherTest.php
T

241 lines
10 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\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');
}
}
}