refactor: RemoteFileFetcher aus MediaImporter herausgelöst (URL-Prüfung, Weiterleitungen, Download)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-19 18:00:41 +02:00
co-authored by Claude Sonnet 5.5
parent bba5a618ed
commit 172a703903
3 changed files with 264 additions and 76 deletions
@@ -0,0 +1,133 @@
<?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 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');
}
}