From 1c16e9e9937a555e1e3e0df98175aab43788249a Mon Sep 17 00:00:00 2001 From: Lewis Larsen Date: Mon, 22 Jul 2024 22:15:46 +0100 Subject: [PATCH] refactor: Wip --- .../CheckRemoteServerConnection.php | 50 +-- app/Contracts/ServerConnectionInterface.php | 73 ---- app/Enums/ConnectionType.php | 11 - app/Exceptions/ServerConnectionException.php | 12 - app/Facades/ServerConnection.php | 39 +-- app/Factories/ServerConnectionFactory.php | 82 ----- .../RemoteServers/CreateRemoteServerForm.php | 12 +- .../ServerConnectionServiceProvider.php | 37 +- app/Services/ServerConnection.php | 184 ---------- app/Support/ServerConnection/Connection.php | 105 ++++++ .../Exceptions/ConnectionException.php | 70 ++++ .../ServerConnection/Fakes/ConnectionFake.php | 108 ++++++ .../Fakes/ServerConnectionFake.php | 315 ++++++++++++++++++ .../ServerConnection/PendingConnection.php | 173 ++++++++++ .../ServerConnectionManager.php | 228 +++++++++++++ app/Testing/ServerConnectionFake.php | 264 --------------- app/Traits/ManagesCommands.php | 55 +++ app/Traits/ManagesConnections.php | 78 +++++ app/Traits/ManagesFiles.php | 114 +++++++ .../ServerConnectionFakeTest.php | 196 +++++++++++ .../CheckRemoteServerConnectionTest.php | 42 ++- .../Fakes/ServerConnectionFakeTest.php | 178 ---------- 22 files changed, 1547 insertions(+), 879 deletions(-) delete mode 100644 app/Contracts/ServerConnectionInterface.php delete mode 100644 app/Enums/ConnectionType.php delete mode 100644 app/Exceptions/ServerConnectionException.php delete mode 100644 app/Factories/ServerConnectionFactory.php delete mode 100644 app/Services/ServerConnection.php create mode 100644 app/Support/ServerConnection/Connection.php create mode 100644 app/Support/ServerConnection/Exceptions/ConnectionException.php create mode 100644 app/Support/ServerConnection/Fakes/ConnectionFake.php create mode 100644 app/Support/ServerConnection/Fakes/ServerConnectionFake.php create mode 100644 app/Support/ServerConnection/PendingConnection.php create mode 100644 app/Support/ServerConnection/ServerConnectionManager.php delete mode 100644 app/Testing/ServerConnectionFake.php create mode 100644 app/Traits/ManagesCommands.php create mode 100644 app/Traits/ManagesConnections.php create mode 100644 app/Traits/ManagesFiles.php create mode 100644 tests/Fakes/ServerConnection/ServerConnectionFakeTest.php delete mode 100644 tests/Testing/Fakes/ServerConnectionFakeTest.php diff --git a/app/Actions/RemoteServer/CheckRemoteServerConnection.php b/app/Actions/RemoteServer/CheckRemoteServerConnection.php index 075cb070..68c002e0 100644 --- a/app/Actions/RemoteServer/CheckRemoteServerConnection.php +++ b/app/Actions/RemoteServer/CheckRemoteServerConnection.php @@ -4,11 +4,10 @@ namespace App\Actions\RemoteServer; -use App\Enums\ConnectionType; use App\Events\RemoteServerConnectivityStatusChanged; -use App\Exceptions\ServerConnectionException; -use App\Factories\ServerConnectionFactory; +use App\Facades\ServerConnection; use App\Models\RemoteServer; +use App\Support\ServerConnection\Exceptions\ConnectionException; use Exception; use Illuminate\Support\Facades\Log; use RuntimeException; @@ -23,20 +22,13 @@ class CheckRemoteServerConnection { private const int CONNECTION_TIMEOUT = 3; - /** - * @param ServerConnectionFactory $serverConnectionFactory Factory for creating server connections - */ - public function __construct( - private readonly ServerConnectionFactory $serverConnectionFactory - ) {} - /** * Check connection status of a remote server by its ID * * Retrieves the server by ID and initiates a connection check. * * @param int $remoteServerId The ID of the remote server to check - * @return array Connection check results + * @return array{status: string, connectivity_status: string, message?: string, error?: string} Connection check results * * @throws Exception If the server cannot be found or connection fails unexpectedly */ @@ -55,7 +47,7 @@ public function byRemoteServerId(int $remoteServerId): array * Creates a temporary RemoteServer instance and initiates a connection check. * * @param array $remoteServerConnectionDetails Connection details including host, port, and username - * @return array Connection check results + * @return array{status: string, connectivity_status: string, message?: string, error?: string} Connection check results * * @throws Exception If required connection details are missing or connection fails unexpectedly */ @@ -82,25 +74,42 @@ public function byRemoteServerConnectionDetails(array $remoteServerConnectionDet * Attempts to establish an SSH connection to the server and updates its status accordingly. * * @param RemoteServer $remoteServer The server to check - * @return array Connection check results + * @return array{status: string, connectivity_status: string, message?: string, error?: string} Connection check results * * @throws Exception If connection fails unexpectedly */ private function checkServerConnection(RemoteServer $remoteServer): array { try { - $connection = $this->serverConnectionFactory->makeFromModel($remoteServer, ConnectionType::SSH, self::CONNECTION_TIMEOUT); - $connection->connect(); + $connection = ServerConnection::connectFromModel($remoteServer) + ->timeout(self::CONNECTION_TIMEOUT) + ->establish(); - $this->updateServerStatus($remoteServer, RemoteServer::STATUS_ONLINE); + if ($connection->connected()) { + $this->updateServerStatus($remoteServer, RemoteServer::STATUS_ONLINE); - Log::debug('[Server Connection Check] Successfully connected to remote server'); + Log::debug('[Server Connection Check] Successfully connected to remote server'); + + return [ + 'status' => 'success', + 'connectivity_status' => RemoteServer::STATUS_ONLINE, + 'message' => 'Successfully connected to remote server', + ]; + } + + // If we reach here, the connection was established but not explicitly connected + $this->updateServerStatus($remoteServer, RemoteServer::STATUS_OFFLINE); + + Log::debug('[Server Connection Check] Connection established but not explicitly connected'); return [ - 'connectivity_status' => RemoteServer::STATUS_ONLINE, + 'status' => 'error', + 'connectivity_status' => RemoteServer::STATUS_OFFLINE, + 'message' => 'Connection established but not explicitly connected', + 'error' => 'Connection not fully established', ]; - } catch (ServerConnectionException $exception) { + } catch (ConnectionException $exception) { Log::info('[Server Connection Check] Unable to connect to remote server (offline)', [ 'error' => $exception->getMessage(), 'server_id' => $remoteServer->getAttribute('id'), @@ -109,10 +118,11 @@ private function checkServerConnection(RemoteServer $remoteServer): array $this->updateServerStatus($remoteServer, RemoteServer::STATUS_OFFLINE); return [ + 'status' => 'error', 'connectivity_status' => RemoteServer::STATUS_OFFLINE, 'message' => 'Server is offline or unreachable', + 'error' => $exception->getMessage(), ]; - } finally { if (isset($connection)) { $connection->disconnect(); diff --git a/app/Contracts/ServerConnectionInterface.php b/app/Contracts/ServerConnectionInterface.php deleted file mode 100644 index cc7631b3..00000000 --- a/app/Contracts/ServerConnectionInterface.php +++ /dev/null @@ -1,73 +0,0 @@ -bound(ServerConnectionFake::class)) { - $fake = app(ServerConnectionFake::class); - $server = RemoteServer::findOrFail($serverId); - - return $fake->setConnectedServer($server); - } - - $server = RemoteServer::findOrFail($serverId); - - return new ServerConnection($server, $connectionType); - } - - /** - * Create a new ServerConnection instance from a RemoteServer model. - * - * @param RemoteServer $remoteServer The RemoteServer model - * @param ConnectionType $connectionType The type of connection (SSH or SFTP) - * @param int $timeout How long until the connection times out. - * - * @throws FileNotFoundException|ServerConnectionException - */ - public function makeFromModel(RemoteServer $remoteServer, ConnectionType $connectionType, int $timeout = 10): ServerConnectionInterface - { - if (app()->bound(ServerConnectionFake::class)) { - return app(ServerConnectionFake::class)->setConnectedServer($remoteServer); - } - - return new ServerConnection($remoteServer, $connectionType); - } - - /** - * Create a new SSH ServerConnection instance. - * - * @param int $serverId The ID of the RemoteServer - * - * @throws ModelNotFoundException|FileNotFoundException|ServerConnectionException If the RemoteServer is not found - */ - public function makeSSH(int $serverId): ServerConnectionInterface - { - return $this->make($serverId, ConnectionType::SSH); - } - - /** - * Create a new SFTP ServerConnection instance. - * - * @param int $serverId The ID of the RemoteServer - * - * @throws ModelNotFoundException|FileNotFoundException|ServerConnectionException If the RemoteServer is not found - */ - public function makeSFTP(int $serverId): ServerConnectionInterface - { - return $this->make($serverId, ConnectionType::SFTP); - } -} diff --git a/app/Livewire/RemoteServers/CreateRemoteServerForm.php b/app/Livewire/RemoteServers/CreateRemoteServerForm.php index 1063961d..e52fdc73 100644 --- a/app/Livewire/RemoteServers/CreateRemoteServerForm.php +++ b/app/Livewire/RemoteServers/CreateRemoteServerForm.php @@ -114,16 +114,22 @@ public function usingServerProvider(string $provider): void private function connectionAttempt(): bool { $checkRemoteServerConnection = new CheckRemoteServerConnection; + $response = $checkRemoteServerConnection->byRemoteServerConnectionDetails([ 'host' => $this->host, 'port' => $this->port, 'username' => $this->username, ]); - if ($response['status'] === 'error') { - $this->connectionError = $response['error']; + $status = $response['status'] ?? 'error'; + $error = $response['error'] ?? $response['message'] ?? 'Unknown error occurred'; + + if ($status === 'error') { + $this->connectionError = $error; + + return false; } - return $response['status'] === 'success'; + return true; } } diff --git a/app/Providers/ServerConnectionServiceProvider.php b/app/Providers/ServerConnectionServiceProvider.php index 7664ada0..4916c552 100644 --- a/app/Providers/ServerConnectionServiceProvider.php +++ b/app/Providers/ServerConnectionServiceProvider.php @@ -4,39 +4,28 @@ namespace App\Providers; -use App\Contracts\ServerConnectionInterface; -use App\Factories\ServerConnectionFactory; -use App\Services\ServerConnection; +use App\Support\ServerConnection\ServerConnectionManager; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\ServiceProvider; +use RuntimeException; -/** - * Service Provider for Server Connection services. - * - * This provider is responsible for registering the ServerConnectionInterface - * and ServerConnectionFactory in the Laravel service container. It ensures - * that these services are available for dependency injection throughout the application. - */ class ServerConnectionServiceProvider extends ServiceProvider { - /** - * Register services related to server connections. - * - * This method binds the ServerConnectionInterface to the ServerConnectionFactory - * and registers the ServerConnectionFactory in the service container. - */ public function register(): void { - $this->app->bind(ServerConnectionInterface::class, ServerConnection::class); + $this->app->singleton('server.connection', function ($app): ServerConnectionManager { + return new ServerConnectionManager; + }); } - /** - * Bootstrap any application services related to server connections. - * - * This method is called after all other service providers have been registered. - * It can be used to perform any actions that are necessary when the application is starting up. - */ public function boot(): void { - // Currently, no bootstrapping is required for server connection services. + $manager = $this->app->make('server.connection'); + $manager->defaultPrivateKey(Storage::disk('local')->path('app/ssh/id_rsa')); + $manager->defaultPassphrase(config('app.ssh.passphrase')); + + if (! config('app.ssh.passphrase')) { + throw new RuntimeException('SSH passphrase is not set in the configuration.'); + } } } diff --git a/app/Services/ServerConnection.php b/app/Services/ServerConnection.php deleted file mode 100644 index cff7741d..00000000 --- a/app/Services/ServerConnection.php +++ /dev/null @@ -1,184 +0,0 @@ -privateKeyPath = $privateKeyPath ?? $this->getDefaultPrivateKeyPath(); - } - - /** - * Establish a connection to the remote server. - * - * @throws ServerConnectionException If connection fails or private key cannot be loaded - */ - public function connect(): void - { - $this->ssh2 = match ($this->connectionType) { - ConnectionType::SSH => new SSH2($this->remoteServer->getAttribute('ip_address'), (int) $this->remoteServer->getAttribute('port')), - ConnectionType::SFTP => new SFTP($this->remoteServer->getAttribute('ip_address'), (int) $this->remoteServer->getAttribute('port')), - }; - - $keyContent = file_get_contents((string) $this->privateKeyPath); - if ($keyContent === false) { - throw new ServerConnectionException('Failed to read private key file.'); - } - - try { - /** @var PrivateKey $key */ - $key = PublicKeyLoader::load($keyContent); - } catch (Exception $e) { - throw new ServerConnectionException("Failed to load private key: {$e->getMessage()}"); - } - - if (! $this->ssh2->login($this->remoteServer->getAttribute('username'), $key)) { - throw new ServerConnectionException("Failed to connect to server: {$this->remoteServer->getAttribute('ip_address')}"); - } - } - - /** - * Disconnect from the remote server. - */ - public function disconnect(): void - { - if ($this->ssh2 instanceof SSH2) { - $this->ssh2->disconnect(); - $this->ssh2 = null; - } - } - - /** - * Execute a command on the remote server. - * - * @param string $command The command to execute - * @return string The output of the command - * - * @throws ServerConnectionException If not connected or if the command execution fails - */ - public function executeCommand(string $command): string - { - if (! $this->ssh2 instanceof SSH2) { - throw new ServerConnectionException('Not connected to server. Call connect() first.'); - } - - $output = $this->ssh2->exec($command); - - if ($output === false) { - throw new ServerConnectionException("Failed to execute command: {$command}"); - } - - return (string) $output; - } - - /** - * Upload a file to the remote server using SFTP. - * - * @param string $localPath The local path of the file to upload - * @param string $remotePath The remote path where the file should be uploaded - * - * @throws ServerConnectionException If not connected via SFTP or if the upload fails - */ - public function uploadFile(string $localPath, string $remotePath): void - { - if (! $this->ssh2 instanceof SFTP) { - throw new ServerConnectionException('File upload is only supported for SFTP connections'); - } - - if (! $this->ssh2->put($remotePath, $localPath, SFTP::SOURCE_LOCAL_FILE)) { - throw new ServerConnectionException("Failed to upload file: {$localPath}"); - } - } - - /** - * Download a file from the remote server using SFTP. - * - * @param string $remotePath The remote path of the file to download - * @param string $localPath The local path where the file should be saved - * - * @throws ServerConnectionException If not connected via SFTP or if the download fails - */ - public function downloadFile(string $remotePath, string $localPath): void - { - if (! $this->ssh2 instanceof SFTP) { - throw new ServerConnectionException('File download is only supported for SFTP connections'); - } - - if (! $this->ssh2->get($remotePath, $localPath)) { - throw new ServerConnectionException("Failed to download file: {$remotePath}"); - } - } - - /** - * Set a custom private key path for the connection. - * - * @param string $path The path to the private key file - */ - public function setPrivateKeyPath(string $path): self - { - $this->privateKeyPath = $path; - - return $this; - } - - /** - * Get the current connection type. - * - * @return ConnectionType The current connection type (SSH or SFTP) - */ - public function getConnectionType(): ConnectionType - { - return $this->connectionType; - } - - /** - * Get the default private key path. - * - * @return string The path to the default private key file - * - * @throws ServerConnectionException If the default private key file is not found - */ - private function getDefaultPrivateKeyPath(): string - { - $path = Storage::disk('local')->path('ssh/key'); - - if (! file_exists($path)) { - throw new ServerConnectionException('Default SSH private key not found in storage/app/ssh/key'); - } - - return $path; - } -} diff --git a/app/Support/ServerConnection/Connection.php b/app/Support/ServerConnection/Connection.php new file mode 100644 index 00000000..42ef44cb --- /dev/null +++ b/app/Support/ServerConnection/Connection.php @@ -0,0 +1,105 @@ +ssh2 instanceof SSH2) { + throw new RuntimeException('Cannot execute command: Connection is null'); + } + + $output = $this->ssh2->exec($command); + + if (! is_string($output)) { + throw new RuntimeException("Failed to execute command: {$command}"); + } + + return $output; + } + + /** + * Upload a file to the server. + * + * @param string $localPath The local file path + * @param string $remotePath The remote file path + * @return bool True if upload was successful, false otherwise + * + * @throws RuntimeException If the connection is not SFTP or is null + */ + public function upload(string $localPath, string $remotePath): bool + { + if (! $this->ssh2 instanceof SFTP) { + throw new RuntimeException('SFTP connection required for file upload'); + } + + return $this->ssh2->put($remotePath, $localPath, SFTP::SOURCE_LOCAL_FILE); + } + + /** + * Download a file from the server. + * + * @param string $remotePath The remote file path + * @param string $localPath The local file path + * @return bool True if download was successful, false otherwise + * + * @throws RuntimeException If the connection is not SFTP, is null, or download fails + */ + public function download(string $remotePath, string $localPath): bool + { + if (! $this->ssh2 instanceof SFTP) { + throw new RuntimeException('SFTP connection required for file download'); + } + + $result = $this->ssh2->get($remotePath, $localPath); + + if (! is_bool($result)) { + throw new RuntimeException("Unexpected result when downloading file: {$remotePath}"); + } + + return $result; + } + + /** + * Check if the connection is active. + */ + public function connected(): bool + { + return $this->ssh2 instanceof SSH2 && $this->ssh2->isConnected(); + } + + /** + * Disconnect from the server. + */ + public function disconnect(): void + { + if ($this->ssh2 instanceof SSH2) { + $this->ssh2->disconnect(); + } + } +} diff --git a/app/Support/ServerConnection/Exceptions/ConnectionException.php b/app/Support/ServerConnection/Exceptions/ConnectionException.php new file mode 100644 index 00000000..2bac4fe2 --- /dev/null +++ b/app/Support/ServerConnection/Exceptions/ConnectionException.php @@ -0,0 +1,70 @@ +serverConnectionFake->disconnect(); + } + + /** + * Simulate running a command on the server. + * + * @param string $command The command to run + * @return string The simulated output of the command + * + * @throws RuntimeException If the connection is closed + */ + public function run(string $command): string + { + $this->ensureConnected(); + $this->serverConnectionFake->recordCommand($command); + + return $this->serverConnectionFake->getOutput(); + } + + /** + * Simulate uploading a file to the server. + * + * @param string $localPath The local path of the file to upload + * @param string $remotePath The remote path where the file should be uploaded + * @return bool Always returns true to simulate successful upload + * + * @throws RuntimeException If the connection is closed + */ + public function upload(string $localPath, string $remotePath): bool + { + $this->ensureConnected(); + $this->serverConnectionFake->recordUpload($localPath, $remotePath); + + return true; + } + + /** + * Simulate downloading a file from the server. + * + * @param string $remotePath The remote path of the file to download + * @param string $localPath The local path where the file should be saved + * @return bool Always returns true to simulate successful download + * + * @throws RuntimeException If the connection is closed + */ + public function download(string $remotePath, string $localPath): bool + { + $this->ensureConnected(); + $this->serverConnectionFake->recordDownload($remotePath, $localPath); + + return true; + } + + /** + * Check if the fake connection is active. + * + * @return bool True if the fake connection is active, false otherwise + */ + public function connected(): bool + { + return $this->serverConnectionFake->isConnected(); + } + + /** + * Ensure that the fake connection is active before performing an operation. + * + * @throws RuntimeException If the fake connection is closed + */ + protected function ensureConnected(): void + { + if (! $this->serverConnectionFake->isConnected()) { + throw new RuntimeException('Cannot perform operation: Connection is closed.'); + } + } +} diff --git a/app/Support/ServerConnection/Fakes/ServerConnectionFake.php b/app/Support/ServerConnection/Fakes/ServerConnectionFake.php new file mode 100644 index 00000000..1eb72ee8 --- /dev/null +++ b/app/Support/ServerConnection/Fakes/ServerConnectionFake.php @@ -0,0 +1,315 @@ + + */ + protected array $connectionAttempts = []; + + /** + * List of commands that were run. + * + * @var array + */ + protected array $commands = []; + + /** + * List of uploaded files. + * + * @var array + */ + protected array $uploads = []; + + /** + * List of downloaded files. + * + * @var array + */ + protected array $downloads = []; + + /** + * The simulated command output. + */ + protected string $output = ''; + + /** + * Simulate connecting from a RemoteServer model. + * + * @param RemoteServer $remoteServer The RemoteServer model instance + * @return $this + */ + public function connectFromModel(RemoteServer $remoteServer): self + { + $this->connectionAttempts[] = [ + 'host' => $remoteServer->getAttribute('ip_address'), + 'port' => (int) $remoteServer->getAttribute('port'), + 'username' => $remoteServer->getAttribute('username'), + ]; + + return $this; + } + + /** + * Simulate connecting to a server. + * + * @param string $host The hostname or IP address + * @param int $port The port number + * @param string $username The username + * @return $this + */ + public function connect(string $host = '', int $port = 22, string $username = 'root'): self + { + $this->connectionAttempts[] = ['host' => $host, 'port' => $port, 'username' => $username]; + + return $this; + } + + /** + * Simulate establishing a connection. + * + * @throws ConnectionException If the connection should fail + */ + public function establish(): Connection + { + if (! $this->shouldConnect) { + throw ConnectionException::connectionFailed(); + } + + $this->wasEverConnected = true; + $this->isCurrentlyConnected = true; + + return new ConnectionFake($this); + } + + /** + * Set the connection to succeed. + * + * @return $this + */ + public function shouldConnect(): self + { + $this->shouldConnect = true; + + return $this; + } + + /** + * Set the connection to fail. + * + * @return $this + */ + public function shouldNotConnect(): self + { + $this->shouldConnect = false; + + return $this; + } + + /** + * Simulate disconnecting from the server. + */ + public function disconnect(): void + { + $this->isCurrentlyConnected = false; + } + + /** + * Assert that a connection was established. + * + * @throws ExpectationFailedException + */ + public function assertConnected(): void + { + PHPUnit::assertTrue($this->wasEverConnected, 'Failed asserting that a connection was ever established.'); + } + + /** + * Assert that a connection was not established. + * + * @throws ExpectationFailedException + */ + public function assertNotConnected(): void + { + PHPUnit::assertFalse($this->isCurrentlyConnected, 'Failed asserting that a connection was not established.'); + } + + /** + * Assert that a connection was attempted with specific details. + * + * @param array{host: string, port: int, username: string} $connectionDetails + * + * @throws ExpectationFailedException + */ + public function assertConnectionAttempted(array $connectionDetails): void + { + $connectionDetails['port'] = (int) $connectionDetails['port']; + + PHPUnit::assertContains($connectionDetails, $this->connectionAttempts, 'Failed asserting that a connection was attempted with the given details.'); + } + + /** + * Assert that a specific command was run. + * + * @param string $command The command to assert + * + * @throws ExpectationFailedException + */ + public function assertCommandRan(string $command): void + { + PHPUnit::assertContains($command, $this->commands, "The command [{$command}] was not run."); + } + + /** + * Assert that a specific file was uploaded. + * + * @param string $localPath The local file path + * @param string $remotePath The remote file path + * + * @throws ExpectationFailedException + */ + public function assertFileUploaded(string $localPath, string $remotePath): void + { + PHPUnit::assertContains( + ['localPath' => $localPath, 'remotePath' => $remotePath], + $this->uploads, + "The file [{$localPath}] was not uploaded to [{$remotePath}]." + ); + } + + /** + * Assert that a specific file was downloaded. + * + * @param string $remotePath The remote file path + * @param string $localPath The local file path + * + * @throws ExpectationFailedException + */ + public function assertFileDownloaded(string $remotePath, string $localPath): void + { + PHPUnit::assertContains( + ['remotePath' => $remotePath, 'localPath' => $localPath], + $this->downloads, + "The file [{$remotePath}] was not downloaded to [{$localPath}]." + ); + } + + /** + * Assert that a specific output was produced. + * + * @param string $output The expected output + * + * @throws ExpectationFailedException + */ + public function assertOutput(string $output): void + { + PHPUnit::assertEquals($output, $this->output, 'The command output does not match.'); + } + + /** + * Record a command that was run. + * + * @param string $command The command to record + */ + public function recordCommand(string $command): void + { + $this->commands[] = $command; + } + + /** + * Record a file upload. + * + * @param string $localPath The local file path + * @param string $remotePath The remote file path + */ + public function recordUpload(string $localPath, string $remotePath): void + { + $this->uploads[] = ['localPath' => $localPath, 'remotePath' => $remotePath]; + } + + /** + * Record a file download. + * + * @param string $remotePath The remote file path + * @param string $localPath The local file path + */ + public function recordDownload(string $remotePath, string $localPath): void + { + $this->downloads[] = ['remotePath' => $remotePath, 'localPath' => $localPath]; + } + + /** + * Set the simulated command output. + * + * @param string $output The output to set + */ + public function setOutput(string $output): void + { + $this->output = $output; + } + + /** + * Get the simulated command output. + * + * @return string The current output + */ + public function getOutput(): string + { + return $this->output; + } + + /** + * Check if the connection is established. + * + * @return bool True if connected, false otherwise + */ + public function isConnected(): bool + { + return $this->isCurrentlyConnected; + } + + /** + * Assert that the connection was disconnected. + * + * @throws ExpectationFailedException + */ + public function assertDisconnected(): void + { + PHPUnit::assertFalse($this->isCurrentlyConnected, 'Failed asserting that the connection was disconnected.'); + } +} diff --git a/app/Support/ServerConnection/PendingConnection.php b/app/Support/ServerConnection/PendingConnection.php new file mode 100644 index 00000000..6492eae5 --- /dev/null +++ b/app/Support/ServerConnection/PendingConnection.php @@ -0,0 +1,173 @@ +timeout = $seconds; + + return $this; + } + + /** + * Set the connection details from a RemoteServer model. + * + * @param RemoteServer $remoteServer The RemoteServer model instance + */ + public function connectFromModel(RemoteServer $remoteServer): self + { + $this->host = $remoteServer->getAttribute('ip_address'); + $this->port = (int) $remoteServer->getAttribute('port'); + $this->username = $remoteServer->getAttribute('username'); + $this->privateKey = $remoteServer->getAttribute('private_key') ?? config('app.ssh.private_key'); + + return $this; + } + + /** + * Set the connection details manually. + * + * @param string $host The hostname or IP address + * @param int $port The port number + * @param string $username The username + */ + public function connect(string $host, int $port = 22, string $username = 'root'): self + { + $this->host = $host; + $this->port = $port; + $this->username = $username; + + return $this; + } + + /** + * Set the password for authentication. + * + * @param string $password The password + */ + public function withPassword(string $password): self + { + $this->password = $password; + + return $this; + } + + /** + * Set the private key for authentication. + * + * @param string $privateKeyPath The path to the private key file + * @param string|null $passphrase The passphrase for the private key + */ + public function withPrivateKey(string $privateKeyPath, ?string $passphrase = null): self + { + $this->privateKey = $privateKeyPath; + $this->passphrase = $passphrase ?? config('app.ssh.passphrase'); + + return $this; + } + + /** + * Establish the connection. + * + * @throws ConnectionException If unable to connect or authenticate + */ + public function establish(): Connection + { + if (! $this->host || ! $this->username) { + throw ConnectionException::withMessage('Insufficient connection details provided.'); + } + + $this->connection = $this->createConnection(); + + if ($this->privateKey) { + $key = $this->loadPrivateKey(); + $result = $this->connection->login($this->username, $key); + } else { + $result = $this->connection->login($this->username, $this->password); + } + + if (! $result) { + throw ConnectionException::authenticationFailed(); + } + + return new Connection($this->connection); + } + + /** + * Create the underlying connection object. + */ + protected function createConnection(): SSH2|SFTP + { + return new SFTP((string) $this->host, $this->port, $this->timeout); + } + + /** + * Load the private key. + * + * @throws ConnectionException If unable to load the private key + */ + protected function loadPrivateKey(): PrivateKey + { + if ($this->privateKey === null) { + throw ConnectionException::withMessage('Private key path is not set.'); + } + + $keyContent = @file_get_contents($this->privateKey); + if ($keyContent === false) { + throw ConnectionException::withMessage('Unable to read private key file.'); + } + + $privateKey = RSA::loadPrivateKey($keyContent, (string) $this->passphrase); + if (! $privateKey instanceof PrivateKey) { + throw ConnectionException::withMessage('Invalid private key format.'); + } + + return $privateKey; + } +} diff --git a/app/Support/ServerConnection/ServerConnectionManager.php b/app/Support/ServerConnection/ServerConnectionManager.php new file mode 100644 index 00000000..bde0c5a5 --- /dev/null +++ b/app/Support/ServerConnection/ServerConnectionManager.php @@ -0,0 +1,228 @@ +fake instanceof ServerConnectionFake) { + return $this->fake->connect($host, $port, $username); + } + + $pendingConnection = new PendingConnection; + + if ($this->defaultPrivateKey) { + $pendingConnection->withPrivateKey($this->defaultPrivateKey, $this->defaultPassphrase); + } + + if ($host !== '' && $host !== '0') { + $pendingConnection->connect($host, $port, $username); + } + + return $pendingConnection; + } + + /** + * Create a new PendingConnection instance from a RemoteServer model. + * + * @param RemoteServer $remoteServer The RemoteServer model instance + */ + public function connectFromModel(RemoteServer $remoteServer): PendingConnection + { + if ($this->fake instanceof ServerConnectionFake) { + return $this->fake->connectFromModel($remoteServer); + } + + return $this->connect()->connectFromModel($remoteServer); + } + + /** + * Set the default private key path. + * + * @param string $path The path to the private key + */ + public function defaultPrivateKey(string $path): void + { + $this->defaultPrivateKey = $path; + } + + /** + * Set the default passphrase. + * + * @param string $passphrase The passphrase for the private key + */ + public function defaultPassphrase(string $passphrase): void + { + $this->defaultPassphrase = $passphrase; + } + + /** + * Enable fake mode for testing. + */ + public function fake(): ServerConnectionFake + { + return $this->fake = new ServerConnectionFake; + } + + /** + * Assert that a connection was established. + * + * @throws RuntimeException If server connection is not in fake mode + */ + public function assertConnected(): void + { + $this->getFake()->assertConnected(); + } + + /** + * Assert that a connection was disconnected. + * + * @throws RuntimeException If server connection is not in fake mode + */ + public function assertDisconnected(): void + { + $this->getFake()->assertDisconnected(); + } + + /** + * Assert that a connection was not established. + * + * @throws RuntimeException If server connection is not in fake mode + */ + public function assertNotConnected(): void + { + $this->getFake()->assertNotConnected(); + } + + /** + * Assert that a command was run. + * + * @param string $command The command to assert + * + * @throws RuntimeException If server connection is not in fake mode + */ + public function assertCommandRan(string $command): void + { + $this->getFake()->assertCommandRan($command); + } + + /** + * Assert that a file was uploaded. + * + * @param string $localPath The local file path + * @param string $remotePath The remote file path + * + * @throws RuntimeException If server connection is not in fake mode + */ + public function assertFileUploaded(string $localPath, string $remotePath): void + { + $this->getFake()->assertFileUploaded($localPath, $remotePath); + } + + /** + * Assert that a file was downloaded. + * + * @param string $remotePath The remote file path + * @param string $localPath The local file path + * + * @throws RuntimeException If server connection is not in fake mode + */ + public function assertFileDownloaded(string $remotePath, string $localPath): void + { + $this->getFake()->assertFileDownloaded($remotePath, $localPath); + } + + /** + * Assert that a specific output was produced. + * + * @param string $output The expected output + * + * @throws RuntimeException If server connection is not in fake mode + */ + public function assertOutput(string $output): void + { + $this->getFake()->assertOutput($output); + } + + /** + * Set the fake connection to succeed. + */ + public function shouldConnect(): ServerConnectionFake + { + if (! $this->fake instanceof ServerConnectionFake) { + $this->fake = new ServerConnectionFake; + } + + return $this->getFake()->shouldConnect(); + } + + /** + * Set the fake connection to fail. + */ + public function shouldNotConnect(): ServerConnectionFake + { + if (! $this->fake instanceof ServerConnectionFake) { + $this->fake = new ServerConnectionFake; + } + + return $this->getFake()->shouldConnect(); + } + + /** + * Assert that a connection was attempted with specific details. + * + * @param array{host: string, port: int, username: string} $connectionDetails + * + * @throws RuntimeException If server connection is not in fake mode + */ + public function assertConnectionAttempted(array $connectionDetails): void + { + $this->getFake()->assertConnectionAttempted($connectionDetails); + } + + /** + * Get the fake instance for assertions. + * + * @throws RuntimeException If server connection is not in fake mode + */ + protected function getFake(): ServerConnectionFake + { + if (! $this->fake instanceof ServerConnectionFake) { + throw new RuntimeException('Server connection is not in fake mode.'); + } + + return $this->fake; + } +} diff --git a/app/Testing/ServerConnectionFake.php b/app/Testing/ServerConnectionFake.php deleted file mode 100644 index 94399953..00000000 --- a/app/Testing/ServerConnectionFake.php +++ /dev/null @@ -1,264 +0,0 @@ - */ - private array $commandResponses = []; - /** @var array */ - private array $executedCommands = []; - /** @var array */ - private array $uploadedFiles = []; - /** @var array */ - private array $downloadedFiles = []; - private ?string $connectException = null; - private ?string $privateKeyPath = null; - - /** - * Constructor for ServerConnectionFake. - * - * Binds this instance to the service container for easy retrieval in tests. - */ - public function __construct() - { - App::instance(__CLASS__, $this); - } - - /** - * Assert that a connection has been established. - * - * @throws ExpectationFailedException - */ - public static function assertConnected(): void - { - $serverConnectionFake = app(__CLASS__); - PHPUnit::assertTrue($serverConnectionFake->isConnected, 'Expected connection to be successful, but it was not.'); - } - - /** - * Assert that a connection has not been established. - * - * @throws ExpectationFailedException - */ - public static function assertNotConnected(): void - { - $serverConnectionFake = app(__CLASS__); - PHPUnit::assertFalse($serverConnectionFake->isConnected, 'Expected connection to fail, but it was successful.'); - } - - /** - * Assert that a connection has been established to a specific server. - * - * @param callable $callback A callback to validate the connected server - * - * @throws ExpectationFailedException - */ - public static function assertConnectedTo(callable $callback): void - { - $serverConnectionFake = app(__CLASS__); - PHPUnit::assertTrue($serverConnectionFake->isConnected, 'Expected to be connected, but no connection was established.'); - PHPUnit::assertNotNull($serverConnectionFake->remoteServer, 'Connected server information is not available.'); - PHPUnit::assertTrue($callback($serverConnectionFake->remoteServer), 'The connected server does not match the expected criteria.'); - } - - /** - * Assert that a specific command has been executed. - * - * @param string $command The command to check for execution - * - * @throws ExpectationFailedException - */ - public static function assertCommandExecuted(string $command): void - { - $serverConnectionFake = app(__CLASS__); - PHPUnit::assertContains($command, $serverConnectionFake->executedCommands, "The command '{$command}' was not executed."); - } - - /** - * Assert that a file has been uploaded. - * - * @param string $localPath The expected local path of the uploaded file - * @param string $remotePath The expected remote path of the uploaded file - * - * @throws ExpectationFailedException - */ - public static function assertFileUploaded(string $localPath, string $remotePath): void - { - $serverConnectionFake = app(__CLASS__); - PHPUnit::assertArrayHasKey($localPath, $serverConnectionFake->uploadedFiles, "File was not uploaded from '{$localPath}'."); - PHPUnit::assertEquals($remotePath, $serverConnectionFake->uploadedFiles[$localPath], "File from '{$localPath}' was not uploaded to '{$remotePath}'."); - } - - /** - * Assert that a file has been downloaded. - * - * @param string $remotePath The expected remote path of the downloaded file - * @param string $localPath The expected local path where the file was downloaded - * - * @throws ExpectationFailedException - */ - public static function assertFileDownloaded(string $remotePath, string $localPath): void - { - $serverConnectionFake = app(__CLASS__); - PHPUnit::assertArrayHasKey($remotePath, $serverConnectionFake->downloadedFiles, "File was not downloaded from '{$remotePath}'."); - PHPUnit::assertEquals($localPath, $serverConnectionFake->downloadedFiles[$remotePath], "File from '{$remotePath}' was not downloaded to '{$localPath}'."); - } - - /** - * Simulate connecting to a server. - * - * @throws ServerConnectionException If connection is set to fail or private key is not set - */ - public function connect(): void - { - if ($this->connectException) { - throw new ServerConnectionException($this->connectException); - } - - if ($this->privateKeyPath === null) { - throw new ServerConnectionException('Private key path not set. Call setPrivateKeyPath() first.'); - } - - $this->isConnected = true; - } - - /** - * Simulate disconnecting from a server. - */ - public function disconnect(): void - { - $this->isConnected = false; - $this->remoteServer = null; - } - - /** - * Simulate executing a command on the server. - * - * @param string $command The command to execute - * @return string The simulated command output - * - * @throws ServerConnectionException If not connected - */ - public function executeCommand(string $command): string - { - if (! $this->isConnected) { - throw new ServerConnectionException('Not connected. Call connect() first.'); - } - $this->executedCommands[] = $command; - - return $this->commandResponses[$command] ?? ''; - } - - /** - * Simulate uploading a file to the server. - * - * @param string $localPath The local path of the file - * @param string $remotePath The remote path to upload to - * - * @throws ServerConnectionException If not connected - */ - public function uploadFile(string $localPath, string $remotePath): void - { - if (! $this->isConnected) { - throw new ServerConnectionException('Not connected. Call connect() first.'); - } - $this->uploadedFiles[$localPath] = $remotePath; - } - - /** - * Simulate downloading a file from the server. - * - * @param string $remotePath The remote path of the file - * @param string $localPath The local path to save the file - * - * @throws ServerConnectionException If not connected - */ - public function downloadFile(string $remotePath, string $localPath): void - { - if (! $this->isConnected) { - throw new ServerConnectionException('Not connected. Call connect() first.'); - } - $this->downloadedFiles[$remotePath] = $localPath; - } - - /** - * Set the private key path for the connection. - * - * @param string $path The path to the private key - */ - public function setPrivateKeyPath(string $path): self - { - $this->privateKeyPath = $path; - - return $this; - } - - /** - * Get the connection type. - * - * @return ConnectionType Always returns SSH for this fake - */ - public function getConnectionType(): ConnectionType - { - return ConnectionType::SSH; - } - - /** - * Configure whether the connection should succeed or fail. - * - * @param bool $should Whether the connection should succeed - * @param string|null $exceptionMessage The exception message if connection should fail - */ - public function shouldConnect(bool $should = true, ?string $exceptionMessage = null): self - { - $this->isConnected = $should; - $this->connectException = $exceptionMessage; - - return $this; - } - - /** - * Set a predefined response for a command. - * - * @param string $command The command to set a response for - * @param string $response The response to return for the command - */ - public function withCommandResponse(string $command, string $response): self - { - $this->commandResponses[$command] = $response; - - return $this; - } - - /** - * Set the connected server for this fake connection. - * - * @param RemoteServer $remoteServer The server to set as connected - */ - public function setConnectedServer(RemoteServer $remoteServer): self - { - $this->remoteServer = $remoteServer; - - return $this; - } -} diff --git a/app/Traits/ManagesCommands.php b/app/Traits/ManagesCommands.php new file mode 100644 index 00000000..38463c96 --- /dev/null +++ b/app/Traits/ManagesCommands.php @@ -0,0 +1,55 @@ +isConnected()) { + throw new ConnectionException('No active connection. Please connect first.'); + } + + $output = $this->connection->exec($command); + + if ($output === false) { + throw new ConnectionException('Failed to execute command: ' . $command); + } + + return $output; + } + + /** + * Run a command on the server and stream the output. + * + * @param string $command The command to run + * @param callable $callback The callback to handle streamed output + * + * @throws ConnectionException If the connection is not established + */ + public function runStream(string $command, callable $callback): void + { + if (! $this->isConnected()) { + throw new ConnectionException('No active connection. Please connect first.'); + } + + $this->connection->exec($command, function ($stream) use ($callback): void { + $buffer = ''; + while ($buffer = fgets($stream)) { + $callback($buffer); + } + }); + } +} diff --git a/app/Traits/ManagesConnections.php b/app/Traits/ManagesConnections.php new file mode 100644 index 00000000..815062fa --- /dev/null +++ b/app/Traits/ManagesConnections.php @@ -0,0 +1,78 @@ +host = $host; + $this->port = $port; + $this->username = $username; + + return $this; + } + + /** + * Set the authentication method to password. + */ + public function withPassword(string $password): self + { + $this->password = $password; + $this->privateKey = null; + + return $this; + } + + /** + * Set the authentication method to private key. + */ + public function withPrivateKey(string $privateKeyPath, ?string $passphrase = null): self + { + $this->privateKey = $privateKeyPath; + $this->passphrase = $passphrase ?? $this->getDefaultPassphrase(); + $this->password = null; + + return $this; + } + + /** + * Get the default private key path. + */ + protected function getDefaultPrivateKey(): ?string + { + return self::$defaultPrivateKey ?? storage_path('app/ssh/id_rsa'); + } + + /** + * Get the default passphrase. + */ + protected function getDefaultPassphrase(): ?string + { + return self::$defaultPassphrase; + } +} diff --git a/app/Traits/ManagesFiles.php b/app/Traits/ManagesFiles.php new file mode 100644 index 00000000..dfdb211e --- /dev/null +++ b/app/Traits/ManagesFiles.php @@ -0,0 +1,114 @@ +isConnected()) { + throw new ConnectionException('No active connection. Please connect first.'); + } + + return $this->connection->put($remotePath, $data); + } + + /** + * Download a file from the server. + * + * @param string $remotePath The remote path of the file + * @return string|false The file contents or false on error + * + * @throws ConnectionException If the connection is not established + */ + public function get(string $remotePath): false|string + { + if (! $this->isConnected()) { + throw new ConnectionException('No active connection. Please connect first.'); + } + + return $this->connection->get($remotePath); + } + + /** + * List files in a directory on the server. + * + * @param string $remotePath The remote path of the directory + * @return array|false An array of files or false on error + * + * @throws ConnectionException If the connection is not established + */ + public function listDirectory(string $remotePath): false|array + { + if (! $this->isConnected()) { + throw new ConnectionException('No active connection. Please connect first.'); + } + + return $this->connection->nlist($remotePath); + } + + /** + * Delete a file on the server. + * + * @param string $remotePath The remote path of the file + * @return bool Whether the operation was successful + * + * @throws ConnectionException If the connection is not established + */ + public function delete(string $remotePath): bool + { + if (! $this->isConnected()) { + throw new ConnectionException('No active connection. Please connect first.'); + } + + return $this->connection->delete($remotePath); + } + + /** + * Rename a file on the server. + * + * @param string $from The current name of the file + * @param string $to The new name of the file + * @return bool Whether the operation was successful + * + * @throws ConnectionException If the connection is not established + */ + public function rename(string $from, string $to): bool + { + if (! $this->isConnected()) { + throw new ConnectionException('No active connection. Please connect first.'); + } + + return $this->connection->rename($from, $to); + } + + /** + * Get file stats. + * + * @param string $remotePath The remote path of the file + * @return array|false An array of file stats or false on error + * + * @throws ConnectionException If the connection is not established + */ + public function stat(string $remotePath): false|array + { + if (! $this->isConnected()) { + throw new ConnectionException('No active connection. Please connect first.'); + } + + return $this->connection->stat($remotePath); + } +} diff --git a/tests/Fakes/ServerConnection/ServerConnectionFakeTest.php b/tests/Fakes/ServerConnection/ServerConnectionFakeTest.php new file mode 100644 index 00000000..1b94253a --- /dev/null +++ b/tests/Fakes/ServerConnection/ServerConnectionFakeTest.php @@ -0,0 +1,196 @@ +serverConnectionFake = new ServerConnectionFake; + } + + /** @test */ + public function it_can_connect_from_model(): void + { + $remoteServer = new RemoteServer; + $remoteServer->getAttribute('ip_address') = 'example.com'; + $remoteServer->getAttribute('port') = 2222; + $remoteServer->getAttribute('username') = 'testuser'; + + $serverConnectionFake = $this->serverConnectionFake->connectFromModel($remoteServer); + + $this->assertInstanceOf(ServerConnectionFake::class, $serverConnectionFake); + $this->serverConnectionFake->assertConnectionAttempted([ + 'host' => 'example.com', + 'port' => 2222, + 'username' => 'testuser', + ]); + } + + /** @test */ + public function it_can_connect_with_custom_details(): void + { + $serverConnectionFake = $this->serverConnectionFake->connect('custom.com', 2222, 'customuser'); + + $this->assertInstanceOf(ServerConnectionFake::class, $serverConnectionFake); + $this->serverConnectionFake->assertConnectionAttempted([ + 'host' => 'custom.com', + 'port' => 2222, + 'username' => 'customuser', + ]); + } + + /** @test */ + public function it_can_establish_connection(): void + { + $connection = $this->serverConnectionFake->establish(); + + $this->assertInstanceOf(Connection::class, $connection); + $this->serverConnectionFake->assertConnected(); + } + + /** @test */ + public function it_can_fail_to_connect(): void + { + $this->serverConnectionFake->shouldConnect(); + + $this->expectException(ConnectionException::class); + $this->serverConnectionFake->establish(); + } + + /** @test */ + public function it_can_assert_command_ran(): void + { + $this->serverConnectionFake->establish()->run('ls -la'); + + $this->serverConnectionFake->assertCommandRan('ls -la'); + $this->expectException(ExpectationFailedException::class); + $this->serverConnectionFake->assertCommandRan('non-existent-command'); + } + + /** @test */ + public function it_can_assert_file_uploaded(): void + { + $this->serverConnectionFake->establish()->upload('/local/path', '/remote/path'); + + $this->serverConnectionFake->assertFileUploaded('/local/path', '/remote/path'); + $this->expectException(ExpectationFailedException::class); + $this->serverConnectionFake->assertFileUploaded('/wrong/path', '/remote/path'); + } + + /** @test */ + public function it_can_assert_file_downloaded(): void + { + $this->serverConnectionFake->establish()->download('/remote/path', '/local/path'); + + $this->serverConnectionFake->assertFileDownloaded('/remote/path', '/local/path'); + $this->expectException(ExpectationFailedException::class); + $this->serverConnectionFake->assertFileDownloaded('/wrong/path', '/local/path'); + } + + /** @test */ + public function it_can_assert_output(): void + { + $this->serverConnectionFake->setOutput('Command output'); + $output = $this->serverConnectionFake->establish()->run('some-command'); + + $this->assertEquals('Command output', $output); + $this->serverConnectionFake->assertOutput('Command output'); + $this->expectException(ExpectationFailedException::class); + $this->serverConnectionFake->assertOutput('Wrong output'); + } + + /** @test */ + public function it_can_disconnect(): void + { + $connection = $this->serverConnectionFake->establish(); + $this->serverConnectionFake->assertConnected(); + + $connection->disconnect(); + $this->serverConnectionFake->assertDisconnected(); + } + + /** @test */ + public function it_fails_assert_disconnected_when_still_connected(): void + { + $this->serverConnectionFake->establish(); + + $this->expectException(ExpectationFailedException::class); + $this->serverConnectionFake->assertDisconnected(); + } + + /** @test */ + public function it_fails_assert_connected_when_disconnected(): void + { + $connection = $this->serverConnectionFake->establish(); + $connection->disconnect(); + + $this->expectException(ExpectationFailedException::class); + $this->serverConnectionFake->assertConnected(); + } + + /** @test */ + public function it_cannot_run_commands_after_disconnect(): void + { + $connection = $this->serverConnectionFake->establish(); + $connection->disconnect(); + + $this->expectException(RuntimeException::class); + $connection->run('ls -la'); + } + + /** @test */ + public function it_cannot_upload_after_disconnect(): void + { + $connection = $this->serverConnectionFake->establish(); + $connection->disconnect(); + + $this->expectException(RuntimeException::class); + $connection->upload('/local/path', '/remote/path'); + } + + /** @test */ + public function it_cannot_download_after_disconnect(): void + { + $connection = $this->serverConnectionFake->establish(); + $connection->disconnect(); + + $this->expectException(RuntimeException::class); + $connection->download('/remote/path', '/local/path'); + } + + /** @test */ + public function it_can_assert_not_connected(): void + { + $this->serverConnectionFake->assertNotConnected(); + + $this->serverConnectionFake->establish(); + $this->expectException(ExpectationFailedException::class); + $this->serverConnectionFake->assertNotConnected(); + } + + /** @test */ + public function it_can_check_if_connected(): void + { + $this->assertFalse($this->serverConnectionFake->isConnected()); + + $this->serverConnectionFake->establish(); + $this->assertTrue($this->serverConnectionFake->isConnected()); + + $this->serverConnectionFake->disconnect(); + $this->assertFalse($this->serverConnectionFake->isConnected()); + } +} diff --git a/tests/Feature/RemoteServers/Actions/CheckRemoteServerConnectionTest.php b/tests/Feature/RemoteServers/Actions/CheckRemoteServerConnectionTest.php index ae8eafe7..3a500e7b 100644 --- a/tests/Feature/RemoteServers/Actions/CheckRemoteServerConnectionTest.php +++ b/tests/Feature/RemoteServers/Actions/CheckRemoteServerConnectionTest.php @@ -5,49 +5,73 @@ use App\Actions\RemoteServer\CheckRemoteServerConnection; use App\Events\RemoteServerConnectivityStatusChanged; use App\Facades\ServerConnection; -use App\Factories\ServerConnectionFactory; use App\Models\RemoteServer; use Illuminate\Support\Facades\Event; it('can check that a server is online', function (): void { Event::fake(); - $serverConnectionFake = ServerConnection::fake(); - $serverConnectionFake->shouldConnect(); + + $fake = ServerConnection::fake(); + $fake->shouldConnect(); $remoteServer = RemoteServer::factory()->create([ 'connectivity_status' => RemoteServer::STATUS_OFFLINE, ]); - $action = new CheckRemoteServerConnection(new ServerConnectionFactory); + $action = new CheckRemoteServerConnection; $result = $action->byRemoteServerId($remoteServer->id); expect($result)->toBe([ + 'status' => 'success', 'connectivity_status' => RemoteServer::STATUS_ONLINE, + 'message' => 'Successfully connected to remote server', + ]); + + $fake->assertConnectionAttempted([ + 'host' => $remoteServer->ip_address, + 'port' => $remoteServer->port, + 'username' => $remoteServer->username, ]); + $fake->assertConnected(); + $remoteServer->refresh(); expect($remoteServer->isOnline())->toBeTrue(); - ServerConnection::assertConnected(); Event::assertDispatched(RemoteServerConnectivityStatusChanged::class); }); it('can check that a server is offline', function (): void { - ServerConnection::fake(); Event::fake(); + ServerConnection::fake()->shouldNotConnect(); + $remoteServer = RemoteServer::factory()->create([ 'connectivity_status' => RemoteServer::STATUS_ONLINE, ]); - $action = new CheckRemoteServerConnection(new ServerConnectionFactory); - $action->byRemoteServerId($remoteServer->id); + $action = new CheckRemoteServerConnection; + $result = $action->byRemoteServerId($remoteServer->id); + + expect($result)->toBe([ + 'status' => 'error', + 'connectivity_status' => RemoteServer::STATUS_OFFLINE, + 'message' => 'Server is offline or unreachable', + 'error' => 'Failed to establish a connection with the remote server.', + ]); + + ServerConnection::assertConnectionAttempted([ + 'host' => $remoteServer->ip_address, + 'port' => $remoteServer->port, + 'username' => $remoteServer->username, + ]); + + ServerConnection::assertNotConnected(); $remoteServer->refresh(); expect($remoteServer->isOffline())->toBeTrue(); Event::assertDispatched(RemoteServerConnectivityStatusChanged::class); - ServerConnection::assertNotConnected(); }); it('uses the current connectivity status if not provided', function (): void { diff --git a/tests/Testing/Fakes/ServerConnectionFakeTest.php b/tests/Testing/Fakes/ServerConnectionFakeTest.php deleted file mode 100644 index 9251254e..00000000 --- a/tests/Testing/Fakes/ServerConnectionFakeTest.php +++ /dev/null @@ -1,178 +0,0 @@ -getFake()->connect(); - - ServerConnection::assertConnected(); - } - - /** - * @test - */ - public function fake_connection_failure(): void - { - $this->getFake()->shouldConnect(false, 'Connection timed out'); - - $this->expectException(ServerConnectionException::class); - $this->expectExceptionMessage('Connection timed out'); - - $this->getFake()->connect(); - } - - /** - * @test - */ - public function fake_connection_to_specific_server(): void - { - $server = RemoteServer::factory()->create(['ip_address' => '192.168.1.1']); - - $serverConnectionFake = $this->getFake(); - $serverConnectionFake->setConnectedServer($server); - $serverConnectionFake->connect(); - - ServerConnection::assertConnectedTo(function ($connectedServer) use ($server): bool { - return $connectedServer->id === $server->id && - $connectedServer->ip_address === '192.168.1.1'; - }); - } - - /** - * @test - */ - public function fake_command_execution(): void - { - $serverConnectionFake = $this->getFake(); - $serverConnectionFake->withCommandResponse('ls -la', 'total 0\ndrwxr-xr-x 2 user user 40 Jul 21 12:34 .'); - - $serverConnectionFake->connect(); - $output = $serverConnectionFake->executeCommand('ls -la'); - - $this->assertEquals('total 0\ndrwxr-xr-x 2 user user 40 Jul 21 12:34 .', $output); - ServerConnection::assertCommandExecuted('ls -la'); - } - - /** - * @test - */ - public function fake_file_upload(): void - { - $serverConnectionFake = $this->getFake(); - $serverConnectionFake->connect(); - $serverConnectionFake->uploadFile('/local/path/file.txt', '/remote/path/file.txt'); - - ServerConnection::assertFileUploaded('/local/path/file.txt', '/remote/path/file.txt'); - } - - /** - * @test - */ - public function fake_file_download(): void - { - $serverConnectionFake = $this->getFake(); - $serverConnectionFake->connect(); - $serverConnectionFake->downloadFile('/remote/path/file.txt', '/local/path/file.txt'); - - ServerConnection::assertFileDownloaded('/remote/path/file.txt', '/local/path/file.txt'); - } - - /** - * @test - */ - public function fake_disconnect(): void - { - $serverConnectionFake = $this->getFake(); - $serverConnectionFake->connect(); - ServerConnection::assertConnected(); - - $serverConnectionFake->disconnect(); - ServerConnection::assertNotConnected(); - } - - /** - * @test - */ - public function fake_set_private_key_path(): void - { - $serverConnectionFake = $this->getFake()->setPrivateKeyPath('/path/to/private/key'); - - $this->assertInstanceOf(ServerConnectionFake::class, $serverConnectionFake); - } - - /** - * @test - */ - public function fake_get_connection_type(): void - { - $connectionType = $this->getFake()->getConnectionType(); - - $this->assertEquals(ConnectionType::SSH, $connectionType); - } - - /** - * @test - */ - public function fake_multiple_command_responses(): void - { - $serverConnectionFake = $this->getFake(); - $serverConnectionFake->withCommandResponse('ls', 'file1.txt file2.txt') - ->withCommandResponse('pwd', '/home/user'); - - $serverConnectionFake->connect(); - - $lsOutput = $serverConnectionFake->executeCommand('ls'); - $pwdOutput = $serverConnectionFake->executeCommand('pwd'); - - $this->assertEquals('file1.txt file2.txt', $lsOutput); - $this->assertEquals('/home/user', $pwdOutput); - - ServerConnection::assertCommandExecuted('ls'); - ServerConnection::assertCommandExecuted('pwd'); - } - - /** - * @test - */ - public function fake_multiple_file_operations(): void - { - $serverConnectionFake = $this->getFake(); - $serverConnectionFake->connect(); - $serverConnectionFake->uploadFile('/local/file1.txt', '/remote/file1.txt'); - $serverConnectionFake->uploadFile('/local/file2.txt', '/remote/file2.txt'); - $serverConnectionFake->downloadFile('/remote/file3.txt', '/local/file3.txt'); - - ServerConnection::assertFileUploaded('/local/file1.txt', '/remote/file1.txt'); - ServerConnection::assertFileUploaded('/local/file2.txt', '/remote/file2.txt'); - ServerConnection::assertFileDownloaded('/remote/file3.txt', '/local/file3.txt'); - } - - private function getFake(): ServerConnectionFake - { - return app(ServerConnectionFake::class); - } -}