diff --git a/docker-compose.yml b/docker-compose.yml index 4e1b723b1..3ab6b7729 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -44,3 +44,15 @@ services: - "2122:21" - "30000-30009:30000-30009" command: "/run.sh -l puredb:/etc/pure-ftpd/pureftpd.pdb -E -j -P localhost" + toxiproxy: + container_name: toxiproxy + restart: unless-stopped + image: ghcr.io/shopify/toxiproxy + command: "-host 0.0.0.0 -config /opt/toxiproxy/config.json" + volumes: + - ./test_files/toxiproxy/toxiproxy.json:/opt/toxiproxy/config.json:ro + ports: + - "8474:8474" # HTTP API + - "8222:8222" # SFTP + - "8121:8121" # FTP + - "8122:8122" # FTPD diff --git a/src/AdapterTestUtilities/ToxiproxyManagement.php b/src/AdapterTestUtilities/ToxiproxyManagement.php new file mode 100644 index 000000000..47a4b1721 --- /dev/null +++ b/src/AdapterTestUtilities/ToxiproxyManagement.php @@ -0,0 +1,75 @@ +apiClient = $apiClient; + } + + public static function forServer(string $apiUri = 'http://localhost:8474'): self + { + return new self( + new Client( + [ + 'base_uri' => $apiUri, + 'base_url' => $apiUri, // Compatibility with older versions of Guzzle + ] + ) + ); + } + + public function removeAllToxics(): void + { + $this->apiClient->post('/reset'); + } + + /** + * Simulates a peer reset on the client->server direction. + * + * @param RegisteredProxies $proxyName + */ + public function resetPeerOnRequest( + string $proxyName, + int $timeoutInMilliseconds + ): void { + $configuration = [ + 'type' => 'reset_peer', + 'stream' => 'upstream', + 'attributes' => ['timeout' => $timeoutInMilliseconds], + ]; + + $this->addToxic($proxyName, $configuration); + } + + /** + * Registers a network toxic for the given proxy. + * + * @param RegisteredProxies $proxyName + * @param Toxic $configuration + */ + private function addToxic(string $proxyName, array $configuration): void + { + $this->apiClient->post('/proxies/' . $proxyName . '/toxics', ['json' => $configuration]); + } +} diff --git a/src/PhpseclibV3/SftpConnectionProviderTest.php b/src/PhpseclibV3/SftpConnectionProviderTest.php index 31764c0e5..453bd9cfa 100644 --- a/src/PhpseclibV3/SftpConnectionProviderTest.php +++ b/src/PhpseclibV3/SftpConnectionProviderTest.php @@ -4,6 +4,7 @@ namespace League\Flysystem\PhpseclibV3; +use League\Flysystem\AdapterTestUtilities\ToxiproxyManagement; use phpseclib3\Net\SFTP; use PHPUnit\Framework\TestCase; @@ -241,6 +242,52 @@ public function providing_an_invalid_password(): void $provider->provideConnection(); } + /** + * @test + */ + public function retries_several_times_until_failure(): void + { + $connectivityChecker = new class implements ConnectivityChecker { + /** @var int */ + public $calls = 0; + + public function isConnected(SFTP $connection): bool + { + ++$this->calls; + + return $connection->isConnected(); + } + }; + + $managesConnectionToxics = ToxiproxyManagement::forServer(); + $managesConnectionToxics->resetPeerOnRequest('sftp', 10); + + $maxTries = 5; + + $provider = SftpConnectionProvider::fromArray( + [ + 'host' => 'localhost', + 'username' => 'bar', + 'privateKey' => __DIR__ . '/../../test_files/sftp/id_rsa', + 'passphrase' => 'secret', + 'port' => 8222, + 'maxTries' => $maxTries, + 'timeout' => 1, + 'connectivityChecker' => $connectivityChecker, + ] + ); + + $this->expectException(UnableToConnectToSftpHost::class); + + try { + $provider->provideConnection(); + } finally { + $managesConnectionToxics->removeAllToxics(); + + self::assertSame($maxTries + 1, $connectivityChecker->calls); + } + } + private function computeFingerPrint(string $publicKey): string { $content = explode(' ', $publicKey, 3); diff --git a/test_files/toxiproxy/toxiproxy.json b/test_files/toxiproxy/toxiproxy.json new file mode 100644 index 000000000..9396fb838 --- /dev/null +++ b/test_files/toxiproxy/toxiproxy.json @@ -0,0 +1,20 @@ +[ + { + "name": "sftp", + "listen": "[::]:8222", + "upstream": "sftp:22", + "enabled": true + }, + { + "name": "ftp", + "listen": "[::]:8121", + "upstream": "ftp:21", + "enabled": true + }, + { + "name": "ftpd", + "listen": "[::]:8122", + "upstream": "ftpd:21", + "enabled": true + } +]