diff options
Diffstat (limited to 'tests/lib/Http')
-rw-r--r-- | tests/lib/Http/Client/ClientServiceTest.php | 127 | ||||
-rw-r--r-- | tests/lib/Http/Client/ClientTest.php | 592 | ||||
-rw-r--r-- | tests/lib/Http/Client/DnsPinMiddlewareTest.php | 547 | ||||
-rw-r--r-- | tests/lib/Http/Client/NegativeDnsCacheTest.php | 55 | ||||
-rw-r--r-- | tests/lib/Http/Client/ResponseTest.php | 59 | ||||
-rw-r--r-- | tests/lib/Http/WellKnown/GenericResponseTest.php | 24 | ||||
-rw-r--r-- | tests/lib/Http/WellKnown/JrdResponseTest.php | 92 | ||||
-rw-r--r-- | tests/lib/Http/WellKnown/RequestManagerTest.php | 154 |
8 files changed, 1650 insertions, 0 deletions
diff --git a/tests/lib/Http/Client/ClientServiceTest.php b/tests/lib/Http/Client/ClientServiceTest.php new file mode 100644 index 00000000000..fd5b155ca69 --- /dev/null +++ b/tests/lib/Http/Client/ClientServiceTest.php @@ -0,0 +1,127 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace Test\Http\Client; + +use GuzzleHttp\Client as GuzzleClient; +use GuzzleHttp\Handler\CurlHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Middleware; +use OC\Http\Client\Client; +use OC\Http\Client\ClientService; +use OC\Http\Client\DnsPinMiddleware; +use OCP\Diagnostics\IEventLogger; +use OCP\ICertificateManager; +use OCP\IConfig; +use OCP\Security\IRemoteHostValidator; +use Psr\Http\Message\RequestInterface; +use Psr\Log\LoggerInterface; + +/** + * Class ClientServiceTest + */ +class ClientServiceTest extends \Test\TestCase { + public function testNewClient(): void { + /** @var IConfig $config */ + $config = $this->createMock(IConfig::class); + $config->method('getSystemValueBool') + ->with('dns_pinning', true) + ->willReturn(true); + /** @var ICertificateManager $certificateManager */ + $certificateManager = $this->createMock(ICertificateManager::class); + $dnsPinMiddleware = $this->createMock(DnsPinMiddleware::class); + $dnsPinMiddleware + ->expects($this->atLeastOnce()) + ->method('addDnsPinning') + ->willReturn(function (): void { + }); + $remoteHostValidator = $this->createMock(IRemoteHostValidator::class); + $eventLogger = $this->createMock(IEventLogger::class); + $logger = $this->createMock(LoggerInterface::class); + + $clientService = new ClientService( + $config, + $certificateManager, + $dnsPinMiddleware, + $remoteHostValidator, + $eventLogger, + $logger, + ); + + $handler = new CurlHandler(); + $stack = HandlerStack::create($handler); + $stack->push($dnsPinMiddleware->addDnsPinning()); + $stack->push(Middleware::tap(function (RequestInterface $request) use ($eventLogger): void { + $eventLogger->start('http:request', $request->getMethod() . ' request to ' . $request->getRequestTarget()); + }, function () use ($eventLogger): void { + $eventLogger->end('http:request'); + }), 'event logger'); + $guzzleClient = new GuzzleClient(['handler' => $stack]); + + $this->assertEquals( + new Client( + $config, + $certificateManager, + $guzzleClient, + $remoteHostValidator, + $logger, + ), + $clientService->newClient() + ); + } + + public function testDisableDnsPinning(): void { + /** @var IConfig $config */ + $config = $this->createMock(IConfig::class); + $config->method('getSystemValueBool') + ->with('dns_pinning', true) + ->willReturn(false); + /** @var ICertificateManager $certificateManager */ + $certificateManager = $this->createMock(ICertificateManager::class); + $dnsPinMiddleware = $this->createMock(DnsPinMiddleware::class); + $dnsPinMiddleware + ->expects($this->never()) + ->method('addDnsPinning') + ->willReturn(function (): void { + }); + $remoteHostValidator = $this->createMock(IRemoteHostValidator::class); + $eventLogger = $this->createMock(IEventLogger::class); + $logger = $this->createMock(LoggerInterface::class); + + $clientService = new ClientService( + $config, + $certificateManager, + $dnsPinMiddleware, + $remoteHostValidator, + $eventLogger, + $logger, + ); + + $handler = new CurlHandler(); + $stack = HandlerStack::create($handler); + $stack->push(Middleware::tap(function (RequestInterface $request) use ($eventLogger): void { + $eventLogger->start('http:request', $request->getMethod() . ' request to ' . $request->getRequestTarget()); + }, function () use ($eventLogger): void { + $eventLogger->end('http:request'); + }), 'event logger'); + $guzzleClient = new GuzzleClient(['handler' => $stack]); + + $this->assertEquals( + new Client( + $config, + $certificateManager, + $guzzleClient, + $remoteHostValidator, + $logger, + ), + $clientService->newClient() + ); + } +} diff --git a/tests/lib/Http/Client/ClientTest.php b/tests/lib/Http/Client/ClientTest.php new file mode 100644 index 00000000000..e76b66b52d7 --- /dev/null +++ b/tests/lib/Http/Client/ClientTest.php @@ -0,0 +1,592 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace Test\Http\Client; + +use GuzzleHttp\Psr7\Response; +use OC\Http\Client\Client; +use OC\Security\CertificateManager; +use OCP\Http\Client\LocalServerException; +use OCP\ICertificateManager; +use OCP\IConfig; +use OCP\Security\IRemoteHostValidator; +use PHPUnit\Framework\MockObject\MockObject; +use Psr\Log\LoggerInterface; +use function parse_url; + +/** + * Class ClientTest + */ +class ClientTest extends \Test\TestCase { + /** @var \GuzzleHttp\Client|MockObject */ + private $guzzleClient; + /** @var CertificateManager|MockObject */ + private $certificateManager; + /** @var Client */ + private $client; + /** @var IConfig|MockObject */ + private $config; + /** @var IRemoteHostValidator|MockObject */ + private IRemoteHostValidator $remoteHostValidator; + private LoggerInterface $logger; + /** @var array */ + private $defaultRequestOptions; + + protected function setUp(): void { + parent::setUp(); + $this->config = $this->createMock(IConfig::class); + $this->guzzleClient = $this->createMock(\GuzzleHttp\Client::class); + $this->certificateManager = $this->createMock(ICertificateManager::class); + $this->remoteHostValidator = $this->createMock(IRemoteHostValidator::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->client = new Client( + $this->config, + $this->certificateManager, + $this->guzzleClient, + $this->remoteHostValidator, + $this->logger, + ); + } + + public function testGetProxyUri(): void { + $this->config + ->method('getSystemValueString') + ->with('proxy', '') + ->willReturn(''); + $this->assertNull(self::invokePrivate($this->client, 'getProxyUri')); + } + + public function testGetProxyUriProxyHostEmptyPassword(): void { + $this->config + ->method('getSystemValue') + ->willReturnMap([ + ['proxyexclude', [], []], + ]); + + $this->config + ->method('getSystemValueString') + ->willReturnMap([ + ['proxy', '', 'foo'], + ['proxyuserpwd', '', ''], + ]); + + $this->assertEquals([ + 'http' => 'foo', + 'https' => 'foo' + ], self::invokePrivate($this->client, 'getProxyUri')); + } + + public function testGetProxyUriProxyHostWithPassword(): void { + $this->config + ->expects($this->once()) + ->method('getSystemValue') + ->with('proxyexclude', []) + ->willReturn([]); + $this->config + ->expects($this->exactly(2)) + ->method('getSystemValueString') + ->willReturnMap([ + ['proxy', '', 'foo'], + ['proxyuserpwd', '', 'username:password'], + ]); + $this->assertEquals([ + 'http' => 'username:password@foo', + 'https' => 'username:password@foo' + ], self::invokePrivate($this->client, 'getProxyUri')); + } + + public function testGetProxyUriProxyHostWithPasswordAndExclude(): void { + $this->config + ->expects($this->once()) + ->method('getSystemValue') + ->with('proxyexclude', []) + ->willReturn(['bar']); + $this->config + ->expects($this->exactly(2)) + ->method('getSystemValueString') + ->willReturnMap([ + ['proxy', '', 'foo'], + ['proxyuserpwd', '', 'username:password'], + ]); + $this->assertEquals([ + 'http' => 'username:password@foo', + 'https' => 'username:password@foo', + 'no' => ['bar'] + ], self::invokePrivate($this->client, 'getProxyUri')); + } + + public function testPreventLocalAddressThrowOnInvalidUri(): void { + $this->expectException(LocalServerException::class); + $this->expectExceptionMessage('Could not detect any host'); + + self::invokePrivate($this->client, 'preventLocalAddress', ['!@#$', []]); + } + + public static function dataPreventLocalAddress(): array { + return [ + ['https://localhost/foo.bar'], + ['https://localHost/foo.bar'], + ['https://random-host/foo.bar'], + ['https://[::1]/bla.blub'], + ['https://[::]/bla.blub'], + ['https://192.168.0.1'], + ['https://172.16.42.1'], + ['https://[fdf8:f53b:82e4::53]/secret.ics'], + ['https://[fe80::200:5aee:feaa:20a2]/secret.ics'], + ['https://[0:0:0:0:0:0:10.0.0.1]/secret.ics'], + ['https://[0:0:0:0:0:ffff:127.0.0.0]/secret.ics'], + ['https://10.0.0.1'], + ['https://another-host.local'], + ['https://service.localhost'], + ['https://normal.host.com'], + ['https://com.one-.nextcloud-one.com'], + ]; + } + + /** + * @param string $uri + */ + #[\PHPUnit\Framework\Attributes\DataProvider('dataPreventLocalAddress')] + public function testPreventLocalAddressDisabledByGlobalConfig(string $uri): void { + $this->config->expects($this->once()) + ->method('getSystemValueBool') + ->with('allow_local_remote_servers', false) + ->willReturn(true); + + self::invokePrivate($this->client, 'preventLocalAddress', [$uri, []]); + } + + /** + * @param string $uri + */ + #[\PHPUnit\Framework\Attributes\DataProvider('dataPreventLocalAddress')] + public function testPreventLocalAddressDisabledByOption(string $uri): void { + $this->config->expects($this->never()) + ->method('getSystemValueBool'); + + self::invokePrivate($this->client, 'preventLocalAddress', [$uri, [ + 'nextcloud' => ['allow_local_address' => true], + ]]); + } + + /** + * @param string $uri + */ + #[\PHPUnit\Framework\Attributes\DataProvider('dataPreventLocalAddress')] + public function testPreventLocalAddressOnGet(string $uri): void { + $host = parse_url($uri, PHP_URL_HOST); + $this->expectException(LocalServerException::class); + $this->remoteHostValidator + ->method('isValid') + ->with($host) + ->willReturn(false); + + $this->client->get($uri); + } + + /** + * @param string $uri + */ + #[\PHPUnit\Framework\Attributes\DataProvider('dataPreventLocalAddress')] + public function testPreventLocalAddressOnHead(string $uri): void { + $host = parse_url($uri, PHP_URL_HOST); + $this->expectException(LocalServerException::class); + $this->remoteHostValidator + ->method('isValid') + ->with($host) + ->willReturn(false); + + $this->client->head($uri); + } + + /** + * @param string $uri + */ + #[\PHPUnit\Framework\Attributes\DataProvider('dataPreventLocalAddress')] + public function testPreventLocalAddressOnPost(string $uri): void { + $host = parse_url($uri, PHP_URL_HOST); + $this->expectException(LocalServerException::class); + $this->remoteHostValidator + ->method('isValid') + ->with($host) + ->willReturn(false); + + $this->client->post($uri); + } + + /** + * @param string $uri + */ + #[\PHPUnit\Framework\Attributes\DataProvider('dataPreventLocalAddress')] + public function testPreventLocalAddressOnPut(string $uri): void { + $host = parse_url($uri, PHP_URL_HOST); + $this->expectException(LocalServerException::class); + $this->remoteHostValidator + ->method('isValid') + ->with($host) + ->willReturn(false); + + $this->client->put($uri); + } + + /** + * @param string $uri + */ + #[\PHPUnit\Framework\Attributes\DataProvider('dataPreventLocalAddress')] + public function testPreventLocalAddressOnDelete(string $uri): void { + $host = parse_url($uri, PHP_URL_HOST); + $this->expectException(LocalServerException::class); + $this->remoteHostValidator + ->method('isValid') + ->with($host) + ->willReturn(false); + + $this->client->delete($uri); + } + + private function setUpDefaultRequestOptions(): void { + $this->config + ->method('getSystemValue') + ->willReturnMap([ + ['proxyexclude', [], []], + ]); + $this->config + ->method('getSystemValueString') + ->willReturnMap([ + ['proxy', '', 'foo'], + ['proxyuserpwd', '', ''], + ]); + $this->config + ->method('getSystemValueBool') + ->willReturnMap([ + ['installed', false, true], + ['allow_local_remote_servers', false, true], + ]); + + $this->certificateManager + ->expects($this->once()) + ->method('getAbsoluteBundlePath') + ->with() + ->willReturn('/my/path.crt'); + + $this->defaultRequestOptions = [ + 'verify' => '/my/path.crt', + 'proxy' => [ + 'http' => 'foo', + 'https' => 'foo' + ], + 'headers' => [ + 'User-Agent' => 'Nextcloud Server Crawler', + 'Accept-Encoding' => 'gzip', + ], + 'timeout' => 30, + 'nextcloud' => [ + 'allow_local_address' => true, + ], + ]; + } + + public function testGet(): void { + $this->setUpDefaultRequestOptions(); + + $this->guzzleClient->method('request') + ->with('get', 'http://localhost/', $this->defaultRequestOptions) + ->willReturn(new Response(418)); + $this->assertEquals(418, $this->client->get('http://localhost/', [])->getStatusCode()); + } + + public function testGetWithOptions(): void { + $this->setUpDefaultRequestOptions(); + + $options = array_merge($this->defaultRequestOptions, [ + 'verify' => false, + 'proxy' => [ + 'http' => 'bar', + 'https' => 'bar' + ], + ]); + + $this->guzzleClient->method('request') + ->with('get', 'http://localhost/', $options) + ->willReturn(new Response(418)); + $this->assertEquals(418, $this->client->get('http://localhost/', $options)->getStatusCode()); + } + + public function testPost(): void { + $this->setUpDefaultRequestOptions(); + + $this->guzzleClient->method('request') + ->with('post', 'http://localhost/', $this->defaultRequestOptions) + ->willReturn(new Response(418)); + $this->assertEquals(418, $this->client->post('http://localhost/', [])->getStatusCode()); + } + + public function testPostWithOptions(): void { + $this->setUpDefaultRequestOptions(); + + $options = array_merge($this->defaultRequestOptions, [ + 'verify' => false, + 'proxy' => [ + 'http' => 'bar', + 'https' => 'bar' + ], + ]); + + $this->guzzleClient->method('request') + ->with('post', 'http://localhost/', $options) + ->willReturn(new Response(418)); + $this->assertEquals(418, $this->client->post('http://localhost/', $options)->getStatusCode()); + } + + public function testPut(): void { + $this->setUpDefaultRequestOptions(); + + $this->guzzleClient->method('request') + ->with('put', 'http://localhost/', $this->defaultRequestOptions) + ->willReturn(new Response(418)); + $this->assertEquals(418, $this->client->put('http://localhost/', [])->getStatusCode()); + } + + public function testPutWithOptions(): void { + $this->setUpDefaultRequestOptions(); + + $options = array_merge($this->defaultRequestOptions, [ + 'verify' => false, + 'proxy' => [ + 'http' => 'bar', + 'https' => 'bar' + ], + ]); + + $this->guzzleClient->method('request') + ->with('put', 'http://localhost/', $options) + ->willReturn(new Response(418)); + $this->assertEquals(418, $this->client->put('http://localhost/', $options)->getStatusCode()); + } + + public function testDelete(): void { + $this->setUpDefaultRequestOptions(); + + $this->guzzleClient->method('request') + ->with('delete', 'http://localhost/', $this->defaultRequestOptions) + ->willReturn(new Response(418)); + $this->assertEquals(418, $this->client->delete('http://localhost/', [])->getStatusCode()); + } + + public function testDeleteWithOptions(): void { + $this->setUpDefaultRequestOptions(); + + $options = array_merge($this->defaultRequestOptions, [ + 'verify' => false, + 'proxy' => [ + 'http' => 'bar', + 'https' => 'bar' + ], + ]); + + $this->guzzleClient->method('request') + ->with('delete', 'http://localhost/', $options) + ->willReturn(new Response(418)); + $this->assertEquals(418, $this->client->delete('http://localhost/', $options)->getStatusCode()); + } + + public function testOptions(): void { + $this->setUpDefaultRequestOptions(); + + $this->guzzleClient->method('request') + ->with('options', 'http://localhost/', $this->defaultRequestOptions) + ->willReturn(new Response(418)); + $this->assertEquals(418, $this->client->options('http://localhost/', [])->getStatusCode()); + } + + public function testOptionsWithOptions(): void { + $this->setUpDefaultRequestOptions(); + + $options = array_merge($this->defaultRequestOptions, [ + 'verify' => false, + 'proxy' => [ + 'http' => 'bar', + 'https' => 'bar' + ], + ]); + + $this->guzzleClient->method('request') + ->with('options', 'http://localhost/', $options) + ->willReturn(new Response(418)); + $this->assertEquals(418, $this->client->options('http://localhost/', $options)->getStatusCode()); + } + + public function testHead(): void { + $this->setUpDefaultRequestOptions(); + + $this->guzzleClient->method('request') + ->with('head', 'http://localhost/', $this->defaultRequestOptions) + ->willReturn(new Response(418)); + $this->assertEquals(418, $this->client->head('http://localhost/', [])->getStatusCode()); + } + + public function testHeadWithOptions(): void { + $this->setUpDefaultRequestOptions(); + + $options = array_merge($this->defaultRequestOptions, [ + 'verify' => false, + 'proxy' => [ + 'http' => 'bar', + 'https' => 'bar' + ], + ]); + + $this->guzzleClient->method('request') + ->with('head', 'http://localhost/', $options) + ->willReturn(new Response(418)); + $this->assertEquals(418, $this->client->head('http://localhost/', $options)->getStatusCode()); + } + + public function testSetDefaultOptionsWithNotInstalled(): void { + $this->config + ->expects($this->exactly(2)) + ->method('getSystemValueBool') + ->willReturnMap([ + ['installed', false, false], + ['allow_local_remote_servers', false, false], + ]); + $this->config + ->expects($this->once()) + ->method('getSystemValueString') + ->with('proxy', '') + ->willReturn(''); + $this->certificateManager + ->expects($this->never()) + ->method('listCertificates'); + + $this->assertEquals([ + 'verify' => \OC::$SERVERROOT . '/resources/config/ca-bundle.crt', + 'headers' => [ + 'User-Agent' => 'Nextcloud Server Crawler', + 'Accept-Encoding' => 'gzip', + ], + 'timeout' => 30, + 'nextcloud' => [ + 'allow_local_address' => false, + ], + 'allow_redirects' => [ + 'on_redirect' => function ( + \Psr\Http\Message\RequestInterface $request, + \Psr\Http\Message\ResponseInterface $response, + \Psr\Http\Message\UriInterface $uri, + ): void { + }, + ], + ], self::invokePrivate($this->client, 'buildRequestOptions', [[]])); + } + + public function testSetDefaultOptionsWithProxy(): void { + $this->config + ->expects($this->exactly(2)) + ->method('getSystemValueBool') + ->willReturnMap([ + ['installed', false, true], + ['allow_local_remote_servers', false, false], + ]); + $this->config + ->expects($this->once()) + ->method('getSystemValue') + ->with('proxyexclude', []) + ->willReturn([]); + $this->config + ->expects($this->exactly(2)) + ->method('getSystemValueString') + ->willReturnMap([ + ['proxy', '', 'foo'], + ['proxyuserpwd', '', ''], + ]); + $this->certificateManager + ->expects($this->once()) + ->method('getAbsoluteBundlePath') + ->with() + ->willReturn('/my/path.crt'); + + $this->assertEquals([ + 'verify' => '/my/path.crt', + 'proxy' => [ + 'http' => 'foo', + 'https' => 'foo' + ], + 'headers' => [ + 'User-Agent' => 'Nextcloud Server Crawler', + 'Accept-Encoding' => 'gzip', + ], + 'timeout' => 30, + 'nextcloud' => [ + 'allow_local_address' => false, + ], + 'allow_redirects' => [ + 'on_redirect' => function ( + \Psr\Http\Message\RequestInterface $request, + \Psr\Http\Message\ResponseInterface $response, + \Psr\Http\Message\UriInterface $uri, + ): void { + }, + ], + ], self::invokePrivate($this->client, 'buildRequestOptions', [[]])); + } + + public function testSetDefaultOptionsWithProxyAndExclude(): void { + $this->config + ->expects($this->exactly(2)) + ->method('getSystemValueBool') + ->willReturnMap([ + ['installed', false, true], + ['allow_local_remote_servers', false, false], + ]); + $this->config + ->expects($this->once()) + ->method('getSystemValue') + ->with('proxyexclude', []) + ->willReturn(['bar']); + $this->config + ->expects($this->exactly(2)) + ->method('getSystemValueString') + ->willReturnMap([ + ['proxy', '', 'foo'], + ['proxyuserpwd', '', ''], + ]); + $this->certificateManager + ->expects($this->once()) + ->method('getAbsoluteBundlePath') + ->with() + ->willReturn('/my/path.crt'); + + $this->assertEquals([ + 'verify' => '/my/path.crt', + 'proxy' => [ + 'http' => 'foo', + 'https' => 'foo', + 'no' => ['bar'] + ], + 'headers' => [ + 'User-Agent' => 'Nextcloud Server Crawler', + 'Accept-Encoding' => 'gzip', + ], + 'timeout' => 30, + 'nextcloud' => [ + 'allow_local_address' => false, + ], + 'allow_redirects' => [ + 'on_redirect' => function ( + \Psr\Http\Message\RequestInterface $request, + \Psr\Http\Message\ResponseInterface $response, + \Psr\Http\Message\UriInterface $uri, + ): void { + }, + ], + ], self::invokePrivate($this->client, 'buildRequestOptions', [[]])); + } +} diff --git a/tests/lib/Http/Client/DnsPinMiddlewareTest.php b/tests/lib/Http/Client/DnsPinMiddlewareTest.php new file mode 100644 index 00000000000..9c0aa198cd8 --- /dev/null +++ b/tests/lib/Http/Client/DnsPinMiddlewareTest.php @@ -0,0 +1,547 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace lib\Http\Client; + +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Psr7\Request; +use GuzzleHttp\Psr7\Response; +use OC\Http\Client\DnsPinMiddleware; +use OC\Http\Client\NegativeDnsCache; +use OC\Memcache\NullCache; +use OC\Net\IpAddressClassifier; +use OCP\Http\Client\LocalServerException; +use OCP\ICacheFactory; +use Psr\Http\Message\RequestInterface; +use Test\TestCase; + +class DnsPinMiddlewareTest extends TestCase { + private DnsPinMiddleware $dnsPinMiddleware; + + protected function setUp(): void { + parent::setUp(); + + $cacheFactory = $this->createMock(ICacheFactory::class); + $cacheFactory + ->method('createLocal') + ->willReturn(new NullCache()); + + $ipAddressClassifier = new IpAddressClassifier(); + $negativeDnsCache = new NegativeDnsCache($cacheFactory); + + $this->dnsPinMiddleware = $this->getMockBuilder(DnsPinMiddleware::class) + ->setConstructorArgs([$negativeDnsCache, $ipAddressClassifier]) + ->onlyMethods(['dnsGetRecord']) + ->getMock(); + } + + public function testPopulateDnsCacheIPv4(): void { + $mockHandler = new MockHandler([ + static function (RequestInterface $request, array $options) { + self::arrayHasKey('curl', $options); + self::arrayHasKey(CURLOPT_RESOLVE, $options['curl']); + self::assertEquals([ + 'www.example.com:80:1.1.1.1', + 'www.example.com:443:1.1.1.1' + ], $options['curl'][CURLOPT_RESOLVE]); + return new Response(200); + }, + ]); + + $this->dnsPinMiddleware + ->method('dnsGetRecord') + ->willReturnCallback(function (string $hostname, int $type) { + // example.com SOA + if ($hostname === 'example.com') { + return match ($type) { + DNS_SOA => [ + [ + 'host' => 'example.com', + 'class' => 'IN', + 'ttl' => 7079, + 'type' => 'SOA', + 'minimum-ttl' => 3600, + ] + ], + }; + } + + // example.com A, AAAA, CNAME + if ($hostname === 'www.example.com') { + return match ($type) { + DNS_A => [], + DNS_AAAA => [], + DNS_CNAME => [ + [ + 'host' => 'www.example.com', + 'class' => 'IN', + 'ttl' => 1800, + 'type' => 'A', + 'target' => 'www.example.net' + ] + ], + }; + } + + // example.net SOA + if ($hostname === 'example.net') { + return match ($type) { + DNS_SOA => [ + [ + 'host' => 'example.net', + 'class' => 'IN', + 'ttl' => 7079, + 'type' => 'SOA', + 'minimum-ttl' => 3600, + ] + ], + }; + } + + // example.net A, AAAA, CNAME + if ($hostname === 'www.example.net') { + return match ($type) { + DNS_A => [ + [ + 'host' => 'www.example.net', + 'class' => 'IN', + 'ttl' => 1800, + 'type' => 'A', + 'ip' => '1.1.1.1' + ] + ], + DNS_AAAA => [], + DNS_CNAME => [], + }; + } + + return false; + }); + + $stack = new HandlerStack($mockHandler); + $stack->push($this->dnsPinMiddleware->addDnsPinning()); + $handler = $stack->resolve(); + + $handler( + new Request('GET', 'https://www.example.com'), + ['nextcloud' => ['allow_local_address' => false]] + ); + } + + public function testPopulateDnsCacheIPv6(): void { + $mockHandler = new MockHandler([ + static function (RequestInterface $request, array $options) { + self::arrayHasKey('curl', $options); + self::arrayHasKey(CURLOPT_RESOLVE, $options['curl']); + self::assertEquals([ + 'www.example.com:80:1.1.1.1,1.0.0.1,2606:4700:4700::1111,2606:4700:4700::1001', + 'www.example.com:443:1.1.1.1,1.0.0.1,2606:4700:4700::1111,2606:4700:4700::1001' + ], $options['curl'][CURLOPT_RESOLVE]); + return new Response(200); + }, + ]); + + $this->dnsPinMiddleware + ->method('dnsGetRecord') + ->willReturnCallback(function (string $hostname, int $type) { + // example.com SOA + if ($hostname === 'example.com') { + return match ($type) { + DNS_SOA => [ + [ + 'host' => 'example.com', + 'class' => 'IN', + 'ttl' => 7079, + 'type' => 'SOA', + 'minimum-ttl' => 3600, + ] + ], + }; + } + + // example.com A, AAAA, CNAME + if ($hostname === 'www.example.com') { + return match ($type) { + DNS_A => [], + DNS_AAAA => [], + DNS_CNAME => [ + [ + 'host' => 'www.example.com', + 'class' => 'IN', + 'ttl' => 1800, + 'type' => 'A', + 'target' => 'www.example.net' + ] + ], + }; + } + + // example.net SOA + if ($hostname === 'example.net') { + return match ($type) { + DNS_SOA => [ + [ + 'host' => 'example.net', + 'class' => 'IN', + 'ttl' => 7079, + 'type' => 'SOA', + 'minimum-ttl' => 3600, + ] + ], + }; + } + + // example.net A, AAAA, CNAME + if ($hostname === 'www.example.net') { + return match ($type) { + DNS_A => [ + [ + 'host' => 'www.example.net', + 'class' => 'IN', + 'ttl' => 1800, + 'type' => 'A', + 'ip' => '1.1.1.1' + ], + [ + 'host' => 'www.example.net', + 'class' => 'IN', + 'ttl' => 1800, + 'type' => 'A', + 'ip' => '1.0.0.1' + ], + ], + DNS_AAAA => [ + [ + 'host' => 'www.example.net', + 'class' => 'IN', + 'ttl' => 1800, + 'type' => 'AAAA', + 'ip' => '2606:4700:4700::1111' + ], + [ + 'host' => 'www.example.net', + 'class' => 'IN', + 'ttl' => 1800, + 'type' => 'AAAA', + 'ip' => '2606:4700:4700::1001' + ], + ], + DNS_CNAME => [], + }; + } + + return false; + }); + + $stack = new HandlerStack($mockHandler); + $stack->push($this->dnsPinMiddleware->addDnsPinning()); + $handler = $stack->resolve(); + + $handler( + new Request('GET', 'https://www.example.com'), + ['nextcloud' => ['allow_local_address' => false]] + ); + } + + public function testAllowLocalAddress(): void { + $mockHandler = new MockHandler([ + static function (RequestInterface $request, array $options) { + self::assertArrayNotHasKey('curl', $options); + return new Response(200); + }, + ]); + + $stack = new HandlerStack($mockHandler); + $stack->push($this->dnsPinMiddleware->addDnsPinning()); + $handler = $stack->resolve(); + + $handler( + new Request('GET', 'https://www.example.com'), + ['nextcloud' => ['allow_local_address' => true]] + ); + } + + public function testRejectIPv4(): void { + $this->expectException(LocalServerException::class); + $this->expectExceptionMessage('violates local access rules'); + + $mockHandler = new MockHandler([ + static function (RequestInterface $request, array $options): void { + // The handler should not be called + }, + ]); + + $this->dnsPinMiddleware + ->method('dnsGetRecord') + ->willReturnCallback(function (string $hostname, int $type) { + return match ($type) { + DNS_SOA => [ + [ + 'host' => 'example.com', + 'class' => 'IN', + 'ttl' => 7079, + 'type' => 'SOA', + 'minimum-ttl' => 3600, + ] + ], + DNS_A => [ + [ + 'host' => 'example.com', + 'class' => 'IN', + 'ttl' => 1800, + 'type' => 'A', + 'ip' => '192.168.0.1' + ] + ], + DNS_AAAA => [], + DNS_CNAME => [], + }; + }); + + $stack = new HandlerStack($mockHandler); + $stack->push($this->dnsPinMiddleware->addDnsPinning()); + $handler = $stack->resolve(); + + $handler( + new Request('GET', 'https://www.example.com'), + ['nextcloud' => ['allow_local_address' => false]] + ); + } + + public function testRejectIPv6(): void { + $this->expectException(LocalServerException::class); + $this->expectExceptionMessage('violates local access rules'); + + $mockHandler = new MockHandler([ + static function (RequestInterface $request, array $options): void { + // The handler should not be called + }, + ]); + + $this->dnsPinMiddleware + ->method('dnsGetRecord') + ->willReturnCallback(function (string $hostname, int $type) { + return match ($type) { + DNS_SOA => [ + [ + 'host' => 'example.com', + 'class' => 'IN', + 'ttl' => 7079, + 'type' => 'SOA', + 'minimum-ttl' => 3600, + ] + ], + DNS_A => [], + DNS_AAAA => [ + [ + 'host' => 'ipv6.example.com', + 'class' => 'IN', + 'ttl' => 1800, + 'type' => 'AAAA', + 'ipv6' => 'fd12:3456:789a:1::1' + ] + ], + DNS_CNAME => [], + }; + }); + + $stack = new HandlerStack($mockHandler); + $stack->push($this->dnsPinMiddleware->addDnsPinning()); + $handler = $stack->resolve(); + + $handler( + new Request('GET', 'https://ipv6.example.com'), + ['nextcloud' => ['allow_local_address' => false]] + ); + } + + public function testRejectCanonicalName(): void { + $this->expectException(LocalServerException::class); + $this->expectExceptionMessage('violates local access rules'); + + $mockHandler = new MockHandler([ + static function (RequestInterface $request, array $options): void { + // The handler should not be called + }, + ]); + + $this->dnsPinMiddleware + ->method('dnsGetRecord') + ->willReturnCallback(function (string $hostname, int $type) { + // example.com SOA + if ($hostname === 'example.com') { + return match ($type) { + DNS_SOA => [ + [ + 'host' => 'example.com', + 'class' => 'IN', + 'ttl' => 7079, + 'type' => 'SOA', + 'minimum-ttl' => 3600, + ] + ], + }; + } + + // example.com A, AAAA, CNAME + if ($hostname === 'www.example.com') { + return match ($type) { + DNS_A => [], + DNS_AAAA => [], + DNS_CNAME => [ + [ + 'host' => 'www.example.com', + 'class' => 'IN', + 'ttl' => 1800, + 'type' => 'A', + 'target' => 'www.example.net' + ] + ], + }; + } + + // example.net SOA + if ($hostname === 'example.net') { + return match ($type) { + DNS_SOA => [ + [ + 'host' => 'example.net', + 'class' => 'IN', + 'ttl' => 7079, + 'type' => 'SOA', + 'minimum-ttl' => 3600, + ] + ], + }; + } + + // example.net A, AAAA, CNAME + if ($hostname === 'www.example.net') { + return match ($type) { + DNS_A => [ + [ + 'host' => 'www.example.net', + 'class' => 'IN', + 'ttl' => 1800, + 'type' => 'A', + 'ip' => '192.168.0.2' + ] + ], + DNS_AAAA => [], + DNS_CNAME => [], + }; + } + + return false; + }); + + $stack = new HandlerStack($mockHandler); + $stack->push($this->dnsPinMiddleware->addDnsPinning()); + $handler = $stack->resolve(); + + $handler( + new Request('GET', 'https://www.example.com'), + ['nextcloud' => ['allow_local_address' => false]] + ); + } + + public function testRejectFaultyResponse(): void { + $this->expectException(LocalServerException::class); + $this->expectExceptionMessage('No DNS record found for www.example.com'); + + $mockHandler = new MockHandler([ + static function (RequestInterface $request, array $options): void { + // The handler should not be called + }, + ]); + + $this->dnsPinMiddleware + ->method('dnsGetRecord') + ->willReturnCallback(function (string $hostname, int $type) { + return false; + }); + + $stack = new HandlerStack($mockHandler); + $stack->push($this->dnsPinMiddleware->addDnsPinning()); + $handler = $stack->resolve(); + + $handler( + new Request('GET', 'https://www.example.com'), + ['nextcloud' => ['allow_local_address' => false]] + ); + } + + public function testIgnoreSubdomainForSoaQuery(): void { + $mockHandler = new MockHandler([ + static function (RequestInterface $request, array $options): void { + // The handler should not be called + }, + ]); + + $dnsQueries = []; + + $this->dnsPinMiddleware + ->method('dnsGetRecord') + ->willReturnCallback(function (string $hostname, int $type) use (&$dnsQueries) { + // log query + $dnsQueries[] = $hostname . $type; + + // example.com SOA + if ($hostname === 'example.com') { + return match ($type) { + DNS_SOA => [ + [ + 'host' => 'example.com', + 'class' => 'IN', + 'ttl' => 7079, + 'type' => 'SOA', + 'minimum-ttl' => 3600, + ] + ], + }; + } + + // example.net A, AAAA, CNAME + if ($hostname === 'subsubdomain.subdomain.example.com') { + return match ($type) { + DNS_A => [ + [ + 'host' => 'subsubdomain.subdomain.example.com', + 'class' => 'IN', + 'ttl' => 1800, + 'type' => 'A', + 'ip' => '1.1.1.1' + ] + ], + DNS_AAAA => [], + DNS_CNAME => [], + }; + } + + return false; + }); + + $stack = new HandlerStack($mockHandler); + $stack->push($this->dnsPinMiddleware->addDnsPinning()); + $handler = $stack->resolve(); + + $handler( + new Request('GET', 'https://subsubdomain.subdomain.example.com'), + ['nextcloud' => ['allow_local_address' => false]] + ); + + $this->assertCount(3, $dnsQueries); + $this->assertContains('example.com' . DNS_SOA, $dnsQueries); + $this->assertContains('subsubdomain.subdomain.example.com' . DNS_A, $dnsQueries); + $this->assertContains('subsubdomain.subdomain.example.com' . DNS_AAAA, $dnsQueries); + // CNAME should not be queried if A or AAAA succeeded already + $this->assertNotContains('subsubdomain.subdomain.example.com' . DNS_CNAME, $dnsQueries); + } +} diff --git a/tests/lib/Http/Client/NegativeDnsCacheTest.php b/tests/lib/Http/Client/NegativeDnsCacheTest.php new file mode 100644 index 00000000000..eb0f86f5c7a --- /dev/null +++ b/tests/lib/Http/Client/NegativeDnsCacheTest.php @@ -0,0 +1,55 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace Test\Http\Client; + +use OC\Http\Client\NegativeDnsCache; +use OCP\ICache; +use OCP\ICacheFactory; + +class NegativeDnsCacheTest extends \Test\TestCase { + /** @var ICache */ + private $cache; + /** @var ICacheFactory */ + private $cacheFactory; + /** @var NegativeDnsCache */ + private $negativeDnsCache; + + protected function setUp(): void { + parent::setUp(); + + $this->cache = $this->createMock(ICache::class); + $this->cacheFactory = $this->createMock(ICacheFactory::class); + $this->cacheFactory + ->method('createLocal') + ->with('NegativeDnsCache') + ->willReturn($this->cache); + + $this->negativeDnsCache = new NegativeDnsCache($this->cacheFactory); + } + + public function testSetNegativeCacheForDnsType() : void { + $this->cache + ->expects($this->once()) + ->method('set') + ->with('www.example.com-1', 'true', 3600); + + $this->negativeDnsCache->setNegativeCacheForDnsType('www.example.com', DNS_A, 3600); + } + + public function testIsNegativeCached(): void { + $this->cache + ->expects($this->once()) + ->method('hasKey') + ->with('www.example.com-1') + ->willReturn(true); + + $this->assertTrue($this->negativeDnsCache->isNegativeCached('www.example.com', DNS_A)); + } +} diff --git a/tests/lib/Http/Client/ResponseTest.php b/tests/lib/Http/Client/ResponseTest.php new file mode 100644 index 00000000000..1acf1eb1cbd --- /dev/null +++ b/tests/lib/Http/Client/ResponseTest.php @@ -0,0 +1,59 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace Test\Http\Client; + +use GuzzleHttp\Psr7\Response as GuzzleResponse; +use GuzzleHttp\Psr7\Utils; +use OC\Http\Client\Response; + +/** + * Class ResponseTest + */ +class ResponseTest extends \Test\TestCase { + /** @var GuzzleResponse */ + private $guzzleResponse; + + protected function setUp(): void { + parent::setUp(); + $this->guzzleResponse = new GuzzleResponse(418); + } + + public function testGetBody(): void { + $response = new Response($this->guzzleResponse->withBody(Utils::streamFor('MyResponse'))); + $this->assertSame('MyResponse', $response->getBody()); + } + + public function testGetStatusCode(): void { + $response = new Response($this->guzzleResponse); + $this->assertSame(418, $response->getStatusCode()); + } + + public function testGetHeader(): void { + $response = new Response($this->guzzleResponse->withHeader('bar', 'foo')); + $this->assertSame('foo', $response->getHeader('bar')); + } + + public function testGetHeaders(): void { + $response = new Response($this->guzzleResponse + ->withHeader('bar', 'foo') + ->withHeader('x-awesome', 'yes') + ); + + $expected = [ + 'bar' => [ + 0 => 'foo', + ], + 'x-awesome' => [ + 0 => 'yes', + ], + ]; + $this->assertSame($expected, $response->getHeaders()); + $this->assertSame('yes', $response->getHeader('x-awesome')); + } +} diff --git a/tests/lib/Http/WellKnown/GenericResponseTest.php b/tests/lib/Http/WellKnown/GenericResponseTest.php new file mode 100644 index 00000000000..f35f43221b8 --- /dev/null +++ b/tests/lib/Http/WellKnown/GenericResponseTest.php @@ -0,0 +1,24 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace Tests\Http\WellKnown; + +use OCP\AppFramework\Http\JSONResponse; +use OCP\Http\WellKnown\GenericResponse; +use Test\TestCase; + +class GenericResponseTest extends TestCase { + public function testToHttpResponse(): void { + $httpResponse = $this->createMock(JSONResponse::class); + + $response = new GenericResponse($httpResponse); + + self::assertSame($httpResponse, $response->toHttpResponse()); + } +} diff --git a/tests/lib/Http/WellKnown/JrdResponseTest.php b/tests/lib/Http/WellKnown/JrdResponseTest.php new file mode 100644 index 00000000000..375f244d618 --- /dev/null +++ b/tests/lib/Http/WellKnown/JrdResponseTest.php @@ -0,0 +1,92 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace Test\Http\WellKnown; + +use OCP\AppFramework\Http\JSONResponse; +use OCP\Http\WellKnown\JrdResponse; +use Test\TestCase; + +class JrdResponseTest extends TestCase { + public function testEmptyToHttpResponse(): void { + $response = new JrdResponse('subject'); + $httpResponse = $response->toHttpResponse(); + + self::assertTrue($response->isEmpty()); + self::assertInstanceOf(JSONResponse::class, $httpResponse); + /** @var JSONResponse $httpResponse */ + self::assertEquals( + [ + 'subject' => 'subject', + ], + $httpResponse->getData() + ); + } + + public function testComplexToHttpResponse(): void { + $response = new JrdResponse('subject'); + $response->addAlias('alias'); + $response->addAlias('blias'); + $response->addProperty('propa', 'a'); + $response->addProperty('propb', null); + $response->setExpires('tomorrow'); + $response->addLink('rel', null, null); + $response->addLink('rel', 'type', null); + $response->addLink('rel', 'type', 'href', ['title' => 'titlevalue']); + $response->addLink('rel', 'type', 'href', ['title' => 'titlevalue'], ['propx' => 'valx']); + $httpResponse = $response->toHttpResponse(); + + self::assertFalse($response->isEmpty()); + self::assertInstanceOf(JSONResponse::class, $httpResponse); + /** @var JSONResponse $httpResponse */ + self::assertEquals( + [ + 'subject' => 'subject', + 'aliases' => [ + 'alias', + 'blias', + ], + 'properties' => [ + 'propa' => 'a', + 'propb' => null, + ], + 'expires' => 'tomorrow', + 'links' => [ + [ + 'rel' => 'rel', + ], + [ + 'rel' => 'rel', + 'type' => 'type', + ], + [ + 'rel' => 'rel', + 'type' => 'type', + 'href' => 'href', + 'titles' => [ + 'title' => 'titlevalue', + ], + ], + [ + 'rel' => 'rel', + 'type' => 'type', + 'href' => 'href', + 'titles' => [ + 'title' => 'titlevalue', + ], + 'properties' => [ + 'propx' => 'valx', + ], + ], + ] + ], + $httpResponse->getData() + ); + } +} diff --git a/tests/lib/Http/WellKnown/RequestManagerTest.php b/tests/lib/Http/WellKnown/RequestManagerTest.php new file mode 100644 index 00000000000..3ab6cb190b2 --- /dev/null +++ b/tests/lib/Http/WellKnown/RequestManagerTest.php @@ -0,0 +1,154 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace Test\Http\WellKnown; + +use OC\AppFramework\Bootstrap\Coordinator; +use OC\AppFramework\Bootstrap\RegistrationContext; +use OC\AppFramework\Bootstrap\ServiceRegistration; +use OC\Http\WellKnown\RequestManager; +use OCP\AppFramework\QueryException; +use OCP\Http\WellKnown\IHandler; +use OCP\Http\WellKnown\IRequestContext; +use OCP\Http\WellKnown\IResponse; +use OCP\Http\WellKnown\JrdResponse; +use OCP\IRequest; +use OCP\IServerContainer; +use PHPUnit\Framework\MockObject\MockObject; +use Psr\Log\LoggerInterface; +use RuntimeException; +use Test\TestCase; +use function get_class; + +class RequestManagerTest extends TestCase { + /** @var Coordinator|MockObject */ + private $coordinator; + + /** @var IServerContainer|MockObject */ + private $container; + + /** @var MockObject|LoggerInterface */ + private $logger; + + /** @var RequestManager */ + private $manager; + + protected function setUp(): void { + parent::setUp(); + + $this->coordinator = $this->createMock(Coordinator::class); + $this->container = $this->createMock(IServerContainer::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->manager = new RequestManager( + $this->coordinator, + $this->container, + $this->logger, + ); + } + + public function testProcessAppsNotRegistered(): void { + $request = $this->createMock(IRequest::class); + $this->expectException(RuntimeException::class); + + $this->manager->process('webfinger', $request); + } + + public function testProcessNoHandlersRegistered(): void { + $request = $this->createMock(IRequest::class); + $registrationContext = $this->createMock(RegistrationContext::class); + $this->coordinator->expects(self::once()) + ->method('getRegistrationContext') + ->willReturn($registrationContext); + $registrationContext->expects(self::once()) + ->method('getWellKnownHandlers') + ->willReturn([]); + + $response = $this->manager->process('webfinger', $request); + + self::assertNull($response); + } + + public function testProcessHandlerNotLoadable(): void { + $request = $this->createMock(IRequest::class); + $registrationContext = $this->createMock(RegistrationContext::class); + $this->coordinator->expects(self::once()) + ->method('getRegistrationContext') + ->willReturn($registrationContext); + $handler = new class { + }; + $registrationContext->expects(self::once()) + ->method('getWellKnownHandlers') + ->willReturn([ + new ServiceRegistration('test', get_class($handler)), + ]); + $this->container->expects(self::once()) + ->method('get') + ->with(get_class($handler)) + ->willThrowException(new QueryException('')); + $this->logger->expects(self::once()) + ->method('error'); + + $response = $this->manager->process('webfinger', $request); + + self::assertNull($response); + } + + public function testProcessHandlerOfWrongType(): void { + $request = $this->createMock(IRequest::class); + $registrationContext = $this->createMock(RegistrationContext::class); + $this->coordinator->expects(self::once()) + ->method('getRegistrationContext') + ->willReturn($registrationContext); + $handler = new class { + }; + $registrationContext->expects(self::once()) + ->method('getWellKnownHandlers') + ->willReturn([ + new ServiceRegistration('test', get_class($handler)), + ]); + $this->container->expects(self::once()) + ->method('get') + ->with(get_class($handler)) + ->willReturn($handler); + $this->logger->expects(self::once()) + ->method('error'); + + $response = $this->manager->process('webfinger', $request); + + self::assertNull($response); + } + + public function testProcess(): void { + $request = $this->createMock(IRequest::class); + $registrationContext = $this->createMock(RegistrationContext::class); + $this->coordinator->expects(self::once()) + ->method('getRegistrationContext') + ->willReturn($registrationContext); + $handler = new class implements IHandler { + public function handle(string $service, IRequestContext $context, ?IResponse $previousResponse): ?IResponse { + return (new JrdResponse($service))->addAlias('alias'); + } + }; + $registrationContext->expects(self::once()) + ->method('getWellKnownHandlers') + ->willReturn([ + new ServiceRegistration('test', get_class($handler)), + ]); + $this->container->expects(self::once()) + ->method('get') + ->with(get_class($handler)) + ->willReturn($handler); + + $response = $this->manager->process('webfinger', $request); + + self::assertNotNull($response); + self::assertInstanceOf(JrdResponse::class, $response); + } +} |