diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 9157b384..6f993399 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -36,3 +36,9 @@ cfebd7374679440f4f65ae9a9834e468661884ac 804ea8cdef7c8fd0147edd1a51395c496f4277f4 3ed4621268fc5a9310b57d4768c72451d2472522 2840bdb93070016aecea2b31f9473c697b834bb2 +ae756aa79581fcb25834604f29eff9e9bbe5abc4 +dc4da567a2683bac3e0f4ae3384b3f03d2acbdde +8e43a02d7dc3a0cb5fe04301f31d5f77e1755391 +92fead4d79fc6f51a20ad953a7e96ff2e8c5daa1 +a2aeba000de332321193ca3261420fa02ab6bf48 +01a0e2a1aef33b18be5e886745c577d9be510402 diff --git a/.github/workflows/pest-tests.yml b/.github/workflows/pest-tests.yml index 25510ee8..098678c7 100644 --- a/.github/workflows/pest-tests.yml +++ b/.github/workflows/pest-tests.yml @@ -39,6 +39,7 @@ jobs: run: | cp .env.example.ci .env php artisan key:generate + php artisan vanguard:generate-ssh-key - name: Cache Laravel application uses: actions/cache@v4 diff --git a/app/Actions/RemoteServer/CheckRemoteServerConnection.php b/app/Actions/RemoteServer/CheckRemoteServerConnection.php index a3061c8f..8a18eb48 100644 --- a/app/Actions/RemoteServer/CheckRemoteServerConnection.php +++ b/app/Actions/RemoteServer/CheckRemoteServerConnection.php @@ -5,20 +5,30 @@ namespace App\Actions\RemoteServer; use App\Events\RemoteServerConnectivityStatusChanged; +use App\Facades\ServerConnection; use App\Models\RemoteServer; +use App\Support\ServerConnection\Exceptions\ConnectionException; use Exception; use Illuminate\Support\Facades\Log; -use phpseclib3\Crypt\Common\PrivateKey; -use phpseclib3\Crypt\PublicKeyLoader; -use phpseclib3\Net\SSH2; use RuntimeException; +/** + * Manages remote server connection checks + * + * This class is responsible for verifying the connectivity status of remote servers. + * It can perform checks using either a server ID or connection details provided directly. + */ class CheckRemoteServerConnection { /** - * @return array + * Check connection status of a remote server by its ID * - * @throws Exception + * Retrieves the server by ID and initiates a connection check. + * + * @param int $remoteServerId The ID of the remote server to check + * @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 */ public function byRemoteServerId(int $remoteServerId): array { @@ -26,89 +36,116 @@ public function byRemoteServerId(int $remoteServerId): array Log::debug('[Server Connection Check] Beginning connection check of server.', ['id' => $remoteServer->id]); - return $this->checkServerConnection([ - 'host' => $remoteServer->ip_address, - 'port' => $remoteServer->port, - 'username' => $remoteServer->username, - ], $remoteServer); + return $this->checkServerConnection($remoteServer); } /** - * @param array $remoteServerConnectionDetails - * @return array + * Check connection status using provided server connection details + * + * Creates a temporary RemoteServer instance and initiates a connection check. * - * @throws Exception + * @param array $remoteServerConnectionDetails Connection details including host, port, and username + * @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 */ public function byRemoteServerConnectionDetails(array $remoteServerConnectionDetails): array { Log::debug('[Server Connection Check] Beginning connection check of server by connection details.', ['connection_details' => $remoteServerConnectionDetails]); - return $this->checkServerConnection($remoteServerConnectionDetails); + if (! isset($remoteServerConnectionDetails['host'], $remoteServerConnectionDetails['port'], $remoteServerConnectionDetails['username'])) { + throw new RuntimeException('Missing required data to check server connection. Ensure host, port, and username are provided.'); + } + + $remoteServer = new RemoteServer([ + 'ip_address' => $remoteServerConnectionDetails['host'], + 'port' => $remoteServerConnectionDetails['port'], + 'username' => $remoteServerConnectionDetails['username'], + ]); + + return $this->checkServerConnection($remoteServer); } /** - * @param array $data - * @return array + * Perform the actual server connection check + * + * Attempts to establish an SSH connection to the server and updates its status accordingly. + * + * @param RemoteServer $remoteServer The server to check + * @return array{status: string, connectivity_status: string, message?: string, error?: string} Connection check results * - * @throws Exception + * @throws Exception If connection fails unexpectedly */ - private function checkServerConnection(array $data, ?RemoteServer $remoteServer = null): array + private function checkServerConnection(RemoteServer $remoteServer): array { - if (! array_key_exists('host', $data) || ! array_key_exists('port', $data) || ! array_key_exists('username', $data)) { - throw new RuntimeException('Missing required data to check server connection. Ensure host, port, and username are provided.'); - } - try { - /** @var PrivateKey $key */ - $key = PublicKeyLoader::load(get_ssh_private_key(), config('app.ssh.passphrase')); + $connection = ServerConnection::connectFromModel($remoteServer) + ->establish(); - $ssh2 = new SSH2($data['host'], $data['port']); + if ($connection->connected()) { + $this->updateServerStatus($remoteServer, RemoteServer::STATUS_ONLINE); - // Attempt to login - if (! $ssh2->login($data['username'], $key)) { - Log::debug('[Server Connection Check] Failed to connect to remote server', ['error' => $ssh2->getLastError()]); - - $remoteServer?->update([ - 'connectivity_status' => RemoteServer::STATUS_OFFLINE, - ]); + Log::debug('[Server Connection Check] Successfully connected to remote server'); return [ - 'error' => $ssh2->getLastError(), - 'status' => 'error', + 'status' => 'success', + 'connectivity_status' => RemoteServer::STATUS_ONLINE, + 'message' => 'Successfully connected to remote server', ]; } - $remoteServer?->update([ - 'connectivity_status' => RemoteServer::STATUS_ONLINE, - ]); + // If we reach here, the connection was established but not explicitly connected + $this->updateServerStatus($remoteServer, RemoteServer::STATUS_OFFLINE); - Log::debug('[Server Connection Check] Successfully connected to remote server'); - - if ($remoteServer instanceof RemoteServer) { - Log::debug('[Server Connection Check] Dispatching RemoteServerConnectivityStatusChanged event (result: ' . $remoteServer->getAttribute('connectivity_status') . ')', ['remote_server' => $remoteServer]); - RemoteServerConnectivityStatusChanged::dispatch($remoteServer, $remoteServer->getAttribute('connectivity_status')); - } + Log::debug('[Server Connection Check] Connection established but not explicitly connected'); return [ - 'status' => 'success', + 'status' => 'error', + 'connectivity_status' => RemoteServer::STATUS_OFFLINE, + 'message' => 'Connection established but not explicitly connected', + 'error' => 'Connection not fully established', ]; - } catch (Exception $exception) { - Log::error('[Server Connection Check] Failed to connect to remote server', ['error' => $exception->getMessage()]); - - $remoteServer?->update([ - 'connectivity_status' => RemoteServer::STATUS_OFFLINE, + } catch (ConnectionException $exception) { + Log::info('[Server Connection Check] Unable to connect to remote server (offline)', [ + 'error' => $exception->getMessage(), + 'server_id' => $remoteServer->getAttribute('id'), ]); - if ($remoteServer instanceof RemoteServer) { - Log::debug('[Server Connection Check] Dispatching RemoteServerConnectivityStatusChanged event (result: ' . $remoteServer->getAttribute('connectivity_status') . ')', ['remote_server' => $remoteServer]); - RemoteServerConnectivityStatusChanged::dispatch($remoteServer, $remoteServer->getAttribute('connectivity_status')); - } + $this->updateServerStatus($remoteServer, RemoteServer::STATUS_OFFLINE); return [ - 'error' => $exception->getMessage(), 'status' => 'error', + 'connectivity_status' => RemoteServer::STATUS_OFFLINE, + 'message' => 'Server is offline or unreachable', + 'error' => $exception->getMessage(), ]; + } finally { + if (isset($connection)) { + $connection->disconnect(); + } + } + } + + /** + * Update the connectivity status of a remote server + * + * Updates the server's status in the database and dispatches a status change event. + * + * @param RemoteServer $remoteServer The server to update + * @param string $status The new connectivity status + */ + private function updateServerStatus(RemoteServer $remoteServer, string $status): void + { + if (! $remoteServer->exists()) { + return; } + + $remoteServer->update([ + 'connectivity_status' => $status, + ]); + + Log::debug('[Server Connection Check] Dispatching RemoteServerConnectivityStatusChanged event (result: ' . $status . ')', ['remote_server' => $remoteServer]); + RemoteServerConnectivityStatusChanged::dispatch($remoteServer, $status); } } diff --git a/app/Facades/ServerConnection.php b/app/Facades/ServerConnection.php new file mode 100644 index 00000000..2afc9777 --- /dev/null +++ b/app/Facades/ServerConnection.php @@ -0,0 +1,55 @@ +byRemoteServerId($this->remoteServerId); + $result = $checkRemoteServerConnection->byRemoteServerId($this->remoteServerId); + + Log::info('[Server Connection Check Job] Completed', [ + 'server_id' => $this->remoteServerId, + 'result' => $result, + ]); } } 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 new file mode 100644 index 00000000..01e9abd2 --- /dev/null +++ b/app/Providers/ServerConnectionServiceProvider.php @@ -0,0 +1,45 @@ +app->singleton('server.connection', function (): ServerConnectionManager { + return new ServerConnectionManager; + }); + } + + /** + * Bootstrap services. + * + * This method sets up the default private key and passphrase for + * the ServerConnectionManager. The default values are fine and + * this shouldn't have to be altered for any purposes. + */ + public function boot(): void + { + ServerConnection::defaultPrivateKey(Storage::path('ssh')); + ServerConnection::defaultPassphrase(config('app.ssh.passphrase')); + } +} diff --git a/app/Services/RemoveSSHKey/Contracts/KeyRemovalNotifierInterface.php b/app/Services/RemoveSSHKey/Contracts/KeyRemovalNotifierInterface.php deleted file mode 100644 index 24dda5eb..00000000 --- a/app/Services/RemoveSSHKey/Contracts/KeyRemovalNotifierInterface.php +++ /dev/null @@ -1,14 +0,0 @@ - $remoteServer->getAttribute('id')]); + Log::info("Initiating SSH key removal for server: {$remoteServer->getAttribute('label')}"); try { - $this->connectToServer($remoteServer); - $this->removeKeyFromServer($remoteServer); - $this->notifySuccess($remoteServer); - } catch (SSHConnectionException $e) { + $connection = $this->establishConnection($remoteServer); + $publicKey = $this->getSSHPublicKey(); + + if (! $this->isKeyPresent($connection, $publicKey)) { + Log::info("SSH key not present on server: {$remoteServer->getAttribute('label')}"); + $this->notifySuccess($remoteServer); + + return; + } + + $result = $this->removeKey($connection, $publicKey); + + $this->processResult($remoteServer, $result); + + } catch (ConnectionException $e) { $this->handleConnectionFailure($remoteServer, $e); } } /** - * Establish an SSH connection to the remote server. + * Establishes a connection to the remote server. * - * @param RemoteServer $remoteServer The remote server to connect to - * - * @throws SSHConnectionException If unable to connect to the remote server + * @throws ConnectionException */ - private function connectToServer(RemoteServer $remoteServer): void + private function establishConnection(RemoteServer $remoteServer): Connection { - $privateKey = $this->sshKeyProvider->getPrivateKey(); - - if (! $this->sshClient->connect( - $remoteServer->getAttribute('ip_address'), - $remoteServer->getAttribute('port'), - $remoteServer->getAttribute('username'), - $privateKey - )) { - throw new SSHConnectionException('Failed to connect to remote server'); - } + Log::debug("Connecting to server: {$remoteServer->getAttribute('label')}"); + + return ServerConnection::connectFromModel($remoteServer)->establish(); } /** - * Remove the SSH key from the remote server. - * - * @param RemoteServer $remoteServer The remote server to remove the key from + * Retrieves the SSH public key content. */ - private function removeKeyFromServer(RemoteServer $remoteServer): void + private function getSSHPublicKey(): string { - $publicKey = $this->sshKeyProvider->getPublicKey(); - $command = sprintf("sed -i -e '/^%s/d' ~/.ssh/authorized_keys", preg_quote($publicKey, '/')); + return ServerConnection::getDefaultPublicKey(); + } - $this->sshClient->executeCommand($command); + /** + * Checks if the SSH public key exists on the remote server. + */ + private function isKeyPresent(Connection $connection, string $publicKey): bool + { + $escapedKey = preg_quote(trim($publicKey), '/'); + $command = sprintf("grep -q '%s' ~/.ssh/authorized_keys", $escapedKey); + Log::debug("Checking SSH key presence: {$command}"); - Log::info('SSH key removed from server.', ['server_id' => $remoteServer->getAttribute('id')]); + return $connection->run($command) === ''; } /** - * Notify the user of successful key removal. - * - * @param RemoteServer $remoteServer The remote server the key was removed from + * Executes the SSH key removal command on the remote server. + */ + private function removeKey(Connection $connection, string $publicKey): string + { + $escapedKey = preg_quote(trim($publicKey), '/'); + $command = sprintf("sed -i -e '/^%s/d' ~/.ssh/authorized_keys", $escapedKey); + Log::debug("Executing key removal: {$command}"); + + return $connection->run($command); + } + + /** + * Processes the result of the key removal attempt. + */ + private function processResult(RemoteServer $remoteServer, string $result): void + { + if ($result === '' || str_contains($result, 'Successfully removed')) { + $this->notifySuccess($remoteServer); + + return; + } + + $this->notifyFailure($remoteServer, $result); + } + + /** + * Handles successful key removal notification. */ private function notifySuccess(RemoteServer $remoteServer): void { - $this->keyRemovalNotifier->notifySuccess($remoteServer); - Log::info('User notified of successful key removal.', ['server_id' => $remoteServer->getAttribute('id')]); + Log::info("SSH key removed from server: {$remoteServer->getAttribute('label')}"); + Mail::to($remoteServer->getAttribute('user'))->queue(new SuccessfullyRemovedKey($remoteServer)); } /** - * Handle and log connection failures, and notify the user. - * - * @param RemoteServer $remoteServer The remote server that failed to connect - * @param SSHConnectionException $sshConnectionException The exception that occurred + * Handles failed key removal notification. */ - private function handleConnectionFailure(RemoteServer $remoteServer, SSHConnectionException $sshConnectionException): void + private function notifyFailure(RemoteServer $remoteServer, string $result): void { - Log::error('Failed to connect to remote server for key removal.', [ - 'server_id' => $remoteServer->getAttribute('id'), - 'error' => $sshConnectionException->getMessage(), - ]); + Log::error("Failed to remove SSH key from server: {$remoteServer->getAttribute('label')}. Result: {$result}"); + Mail::to($remoteServer->getAttribute('user'))->queue(new FailedToRemoveKey($remoteServer)); + } - $this->keyRemovalNotifier->notifyFailure($remoteServer, $sshConnectionException->getMessage()); + /** + * Handles connection failure notification. + */ + private function handleConnectionFailure(RemoteServer $remoteServer, ConnectionException $connectionException): void + { + Log::error("Failed to connect to server: {$remoteServer->getAttribute('label')}. Error: {$connectionException->getMessage()}"); + Mail::to($remoteServer->getAttribute('user'))->queue(new FailedToRemoveKey($remoteServer)); } } diff --git a/app/Support/ServerConnection/Concerns/ManagesCommands.php b/app/Support/ServerConnection/Concerns/ManagesCommands.php new file mode 100644 index 00000000..61d48cce --- /dev/null +++ b/app/Support/ServerConnection/Concerns/ManagesCommands.php @@ -0,0 +1,68 @@ +ensureConnected(); + + $output = $this->connection->exec($command); + + if ($output === false) { + throw ConnectionException::withMessage('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 + { + $this->ensureConnected(); + + $this->connection->exec($command, function ($stream) use ($callback): void { + $buffer = ''; + while ($buffer = fgets($stream)) { + $callback($buffer); + } + }); + } + + /** + * Ensure that the connection is established before performing an operation. + * + * @throws ConnectionException If the connection is not established + */ + private function ensureConnected(): void + { + if (! $this->isConnected()) { + throw ConnectionException::withMessage('No active connection. Please connect first.'); + } + } +} diff --git a/app/Support/ServerConnection/Concerns/ManagesConnections.php b/app/Support/ServerConnection/Concerns/ManagesConnections.php new file mode 100644 index 00000000..013a4f9b --- /dev/null +++ b/app/Support/ServerConnection/Concerns/ManagesConnections.php @@ -0,0 +1,103 @@ +host = $host; + $this->port = $port; + $this->username = $username; + + return $this; + } + + /** + * Set the authentication method to password. + * + * @param string $password The password for authentication + */ + public function withPassword(string $password): self + { + $this->password = $password; + $this->privateKey = null; + + return $this; + } + + /** + * Set the authentication method to private key. + * + * @param string $privateKeyPath The path to the private key file + * @param string|null $passphrase The passphrase for the private key (optional) + */ + 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. + * + * @return string|null The path to the default private key file + */ + protected function getDefaultPrivateKey(): ?string + { + return self::$defaultPrivateKey ?? storage_path('app/ssh/id_rsa'); + } + + /** + * Get the default passphrase. + * + * @return string|null The default passphrase for the private key + */ + protected function getDefaultPassphrase(): ?string + { + return self::$defaultPassphrase; + } +} diff --git a/app/Support/ServerConnection/Concerns/ManagesFiles.php b/app/Support/ServerConnection/Concerns/ManagesFiles.php new file mode 100644 index 00000000..71225414 --- /dev/null +++ b/app/Support/ServerConnection/Concerns/ManagesFiles.php @@ -0,0 +1,119 @@ +ensureConnected(); + + 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): string|false + { + $this->ensureConnected(); + + 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): array|false + { + $this->ensureConnected(); + + 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 + { + $this->ensureConnected(); + + 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 + { + $this->ensureConnected(); + + 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): array|false + { + $this->ensureConnected(); + + return $this->connection->stat($remotePath); + } + + /** + * Ensure that the connection is established before performing an operation. + * + * @throws ConnectionException If the connection is not established + */ + private function ensureConnected(): void + { + if (! $this->isConnected()) { + throw ConnectionException::withMessage('No active connection. Please connect first.'); + } + } +} diff --git a/app/Support/ServerConnection/Connection.php b/app/Support/ServerConnection/Connection.php new file mode 100644 index 00000000..4d21f20a --- /dev/null +++ b/app/Support/ServerConnection/Connection.php @@ -0,0 +1,116 @@ +ssh2 instanceof SSH2 && $this->ssh2->isConnected() && $this->ssh2->isAuthenticated(); + } + + /** + * Disconnect from the server. + */ + public function disconnect(): void + { + if ($this->ssh2 instanceof SSH2) { + $this->ssh2->disconnect(); + } + } + + /** + * Run a command on the server. + * + * @param string $command The command to execute + * @return string The command output + * + * @throws RuntimeException If command execution fails or connection is null + */ + public function run(string $command): string + { + if (! $this->ssh2 instanceof SSH2) { + throw new RuntimeException('Cannot execute command: Connection is null'); + } + + if (! $this->ssh2->isConnected() || ! $this->ssh2->isAuthenticated()) { + throw new RuntimeException('Connection lost. Please re-establish the connection.'); + } + + $output = $this->ssh2->exec($command); + + if ($output === false) { + throw new RuntimeException("Failed to execute command: {$command}"); + } + + return (string) $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; + } +} diff --git a/app/Support/ServerConnection/Exceptions/ConnectionException.php b/app/Support/ServerConnection/Exceptions/ConnectionException.php new file mode 100644 index 00000000..6aab18c7 --- /dev/null +++ b/app/Support/ServerConnection/Exceptions/ConnectionException.php @@ -0,0 +1,75 @@ +serverConnectionFake->isConnected(); + } + + /** + * Simulate disconnecting from the server. + */ + public function disconnect(): void + { + $this->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; + } + + /** + * Get the underlying SSH2 or SFTP instance (always null for the fake). + * + * @return SSH2|null Always returns null for the fake connection + */ + public function getConnection(): ?SSH2 + { + return null; + } + + /** + * Ensure that the fake connection is active before performing an operation. + * + * @throws RuntimeException If the fake connection is closed + */ + private 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..41c13c04 --- /dev/null +++ b/app/Support/ServerConnection/Fakes/ServerConnectionFake.php @@ -0,0 +1,452 @@ + + */ + 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 = ''; + + /** + * Simulated connection timeout in seconds. + */ + protected int $timeout = 30; + + /** + * Fake private key content. + */ + protected string $fakePrivateKey = 'fake_private_key_content'; + + /** + * Fake public key content. + */ + protected string $fakePublicKey = 'fake_public_key_content'; + + /** + * Fake passphrase. + */ + protected string $fakePassphrase = 'fake_passphrase'; + + /** + * 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; + } + + /** + * Simulate setting the connection timeout. + * + * @param int $seconds The timeout in seconds + * @return $this + */ + public function timeout(int $seconds): self + { + $this->timeout = $seconds; + + return $this; + } + + /** + * Simulate setting the private key for authentication. + * + * @param string|null $privateKeyPath The path to the private key file + * @param string|null $passphrase The passphrase for the private key + * @return $this + */ + public function withPrivateKey(?string $privateKeyPath = null, ?string $passphrase = null): self + { + // Simulate setting private key + return $this; + } + + /** + * 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]; + } + + /** + * 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 the connection was disconnected. + * + * @throws ExpectationFailedException + */ + public function assertDisconnected(): void + { + PHPUnit::assertFalse($this->isCurrentlyConnected, 'Failed asserting that the connection was disconnected.'); + } + + /** + * 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, or that any command was run if no specific command is provided. + * + * This method can be used in two ways: + * 1. To check if a specific command was run by passing the command as an argument. + * 2. To check if any command was run by calling the method without arguments. + * + * @param string|null $command The command to assert, or null to check if any command was run + * + * @throws ExpectationFailedException + */ + public function assertCommandRan(?string $command = null): void + { + $message = $command === null ? 'Any command' : "The command [{$command}]"; + + PHPUnit::assertNotEmpty($this->commands, "{$message} was not run."); + + if ($command !== null) { + PHPUnit::assertContains($command, $this->commands, "The command [{$command}] was not run."); + } + } + + /** + * Assert that no commands were run. + * + * @throws ExpectationFailedException + */ + public function assertNoCommandsRan(): void + { + PHPUnit::assertEmpty($this->commands, 'Commands were run when none were expected.'); + } + + /** + * Assert that any command was run. + * + * This is an alias for calling assertCommandRan() without arguments. + * It provides a more expressive way to check if any command was executed. + * + * @throws ExpectationFailedException + */ + public function assertAnyCommandRan(): void + { + $this->assertCommandRan(); + } + + /** + * 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.'); + } + + /** + * 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; + } + + /** + * Get the default private key content. + * + * @return string The fake private key content + */ + public function getDefaultPrivateKey(): string + { + return $this->fakePrivateKey; + } + + /** + * Get the default public key content. + * + * @return string The fake public key content + */ + public function getDefaultPublicKey(): string + { + return $this->fakePublicKey; + } + + /** + * Get the default passphrase. + * + * @return string The fake passphrase + */ + public function getDefaultPassphrase(): string + { + return $this->fakePassphrase; + } + + /** + * Get the content of a private key file. + * + * @param string $path The path to the private key file + * @return string The fake content of the private key file + */ + public function getPrivateKeyContent(string $path): string + { + return $this->fakePrivateKey; + } + + /** + * Get the content of a public key file. + * + * @param string $path The path to the public key file + * @return string The fake content of the public key file + */ + public function getPublicKeyContent(string $path): string + { + return $this->fakePublicKey; + } + + /** + * Get the default private key path. + * + * @return string The fake path to the private key + */ + public function getDefaultPrivateKeyPath(): string + { + return 'fake/path/to/private/key'; + } +} diff --git a/app/Support/ServerConnection/PendingConnection.php b/app/Support/ServerConnection/PendingConnection.php new file mode 100644 index 00000000..bb161b70 --- /dev/null +++ b/app/Support/ServerConnection/PendingConnection.php @@ -0,0 +1,226 @@ +privateKey = ServerConnection::getDefaultPrivateKey(); + $this->passphrase = ServerConnection::getDefaultPassphrase(); + } + + /** + * Set the connection timeout. + * + * @param int $seconds The timeout in seconds + */ + public function timeout(int $seconds): self + { + $this->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'); + + 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 private key for authentication. + * + * @param string|null $privateKeyPath The path to the private key file + * @param string|null $passphrase The passphrase for the private key + */ + public function withPrivateKey(?string $privateKeyPath = null, ?string $passphrase = null): self + { + if ($privateKeyPath !== null) { + $this->privateKey = ServerConnection::getPrivateKeyContent($privateKeyPath); + } else { + $this->privateKey = ServerConnection::getDefaultPrivateKey(); + } + $this->passphrase = $passphrase ?? ServerConnection::getDefaultPassphrase(); + $this->useDefaultCredentials = false; + + return $this; + } + + /** + * Establish the connection. + * + * @throws ConnectionException If unable to connect or authenticate + */ + public function establish(): Connection + { + $this->validateConnectionDetails(); + + try { + $this->createConnection(); + $this->authenticateConnection(); + + if (! $this->connection instanceof SSH2 || ! $this->connection->isConnected() || ! $this->connection->isAuthenticated()) { + throw new RuntimeException('Connection not fully established and authenticated'); + } + + Log::info('Successfully connected to the remote server.', [ + 'host' => $this->host, + 'port' => $this->port, + ]); + + return new Connection($this->connection); + } catch (Exception $e) { + Log::error('Failed to establish connection', [ + 'error' => $e->getMessage(), + 'host' => $this->host, + ]); + throw ConnectionException::withMessage('Unable to connect to the server: ' . $e->getMessage()); + } + } + + /** + * Validate that all necessary connection details are provided. + * + * @throws ConnectionException If connection details are insufficient + */ + protected function validateConnectionDetails(): void + { + $missingDetails = array_filter([ + 'host' => $this->host, + 'username' => $this->username, + ], fn ($value): bool => $value === null); + + if ($missingDetails !== []) { + $missingFields = implode(', ', array_keys($missingDetails)); + throw ConnectionException::withMessage("Insufficient connection details provided. Missing: {$missingFields}"); + } + } + + /** + * Create the underlying connection object. + * + * @throws RuntimeException If connection creation fails + */ + protected function createConnection(): void + { + try { + $this->connection = new SSH2($this->host, $this->port, $this->timeout); + } catch (Exception $e) { + throw new RuntimeException('Failed to create SSH connection: ' . $e->getMessage(), $e->getCode(), $e); + } + } + + /** + * Authenticate the connection. + * + * @throws ConnectionException If authentication fails + */ + protected function authenticateConnection(): void + { + if (! $this->connection instanceof SSH2) { + throw ConnectionException::withMessage('The connection has not been established or has become invalid.'); + } + + $privateKey = $this->loadPrivateKey(); + + if (! $this->connection->login((string) $this->username, $privateKey)) { + throw ConnectionException::authenticationFailed(); + } + } + + /** + * Load the private key. + * + * @throws ConnectionException If unable to load the private key + */ + protected function loadPrivateKey(): PrivateKey + { + $passphrase = (string) $this->passphrase; + + try { + $privateKey = PublicKeyLoader::load((string) $this->privateKey, $passphrase); + + if (! $privateKey instanceof PrivateKey) { + throw new RuntimeException('Invalid private key format.'); + } + + return $privateKey; + } catch (Exception $e) { + throw ConnectionException::withMessage('Failed to load private key: ' . $e->getMessage()); + } + } +} diff --git a/app/Support/ServerConnection/ServerConnectionManager.php b/app/Support/ServerConnection/ServerConnectionManager.php new file mode 100644 index 00000000..ccdd76a6 --- /dev/null +++ b/app/Support/ServerConnection/ServerConnectionManager.php @@ -0,0 +1,450 @@ +connect($host, $port, $username); + } + + $pendingConnection = new PendingConnection; + + if (static::$defaultPrivateKey) { + $pendingConnection->withPrivateKey(static::$defaultPrivateKey, static::$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 static function connectFromModel(RemoteServer $remoteServer): PendingConnection + { + if (static::isFake()) { + return static::getFake()->connectFromModel($remoteServer); + } + + return static::connect()->connectFromModel($remoteServer); + } + + /** + * Set the default private key path. + * + * @param string $path The path to the private key file + */ + public static function defaultPrivateKey(string $path): void + { + static::$defaultPrivateKey = rtrim($path, DIRECTORY_SEPARATOR); + } + + /** + * Set the default passphrase. + * + * @param string $passphrase The passphrase for the private key + */ + public static function defaultPassphrase(string $passphrase): void + { + static::$defaultPassphrase = $passphrase; + } + + /** + * Get the default private key path. + * + * @return string The full path to the default private key + * + * @throws RuntimeException If the default private key path is not set + */ + public static function getDefaultPrivateKeyPath(): string + { + if (static::isFake()) { + return static::getFake()->getDefaultPrivateKeyPath(); + } + + if (! static::$defaultPrivateKey) { + throw new RuntimeException('Default private key path is not set.'); + } + + $path = rtrim(static::$defaultPrivateKey, DIRECTORY_SEPARATOR) + . DIRECTORY_SEPARATOR . self::SSH_KEY_FILE_NAME; + + // We're excluding the testing environment to suppress the missing key errors. + // Not pretty, but doable. + if (! file_exists($path) && ! App::environment('testing')) { + throw new RuntimeException("Private key file does not exist: {$path}"); + } + + return $path; + } + + /** + * Get the path to the default public key file. + * + * @return string The full path to the default public key + * + * @throws RuntimeException If the default private key path is not set + */ + public static function getDefaultPublicKeyPath(): string + { + if (! static::$defaultPrivateKey) { + throw new RuntimeException('Default private key path is not set.'); + } + + return static::$defaultPrivateKey . '/' . self::SSH_KEY_FILE_NAME . '.' . self::SSH_KEY_PUBLIC_EXT; + } + + /** + * Get the content of the default private key. + * + * @return string The content of the default private key + * + * @throws RuntimeException|FileNotFoundException If the private key file cannot be found + */ + public static function getDefaultPrivateKey(): string + { + if (static::isFake()) { + return static::getFake()->getDefaultPrivateKey(); + } + + if (! static::$defaultPrivateKey) { + throw new RuntimeException('Default private key path is not set.'); + } + + return static::getPrivateKeyContent(static::getDefaultPrivateKeyPath()); + } + + /** + * Get the default public key. + * + * @return string The content of the default public key + * + * @throws RuntimeException|FileNotFoundException If the public key file cannot be found + */ + public static function getDefaultPublicKey(): string + { + if (static::isFake()) { + return static::getFake()->getDefaultPublicKey(); + } + + if (! static::$defaultPrivateKey) { + throw new RuntimeException('Default private key path is not set.'); + } + + $publicKeyPath = static::$defaultPrivateKey . '/' . self::SSH_KEY_FILE_NAME . '.' . self::SSH_KEY_PUBLIC_EXT; + + return static::getPublicKeyContent($publicKeyPath); + } + + /** + * Get the default passphrase for the private key. + * + * @return string The default passphrase + */ + public static function getDefaultPassphrase(): string + { + if (static::isFake()) { + return static::getFake()->getDefaultPassphrase(); + } + + return (string) static::$defaultPassphrase; + } + + /** + * Get the content of a private key file. + * + * @param string $path The path to the private key file + * @return string The content of the private key file + * + * @throws RuntimeException If the private key file cannot be found or read + */ + public static function getPrivateKeyContent(string $path): string + { + if (static::isFake() || App::environment('testing')) { + return static::isFake() + ? static::getFake()->getPrivateKeyContent($path) + : 'fake_private_key_content_for_testing'; + } + + $fullPath = is_dir($path) ? $path . DIRECTORY_SEPARATOR . self::SSH_KEY_FILE_NAME : $path; + + if (! file_exists($fullPath)) { + throw new RuntimeException("Private key file does not exist: {$fullPath}"); + } + + if (is_dir($fullPath)) { + throw new RuntimeException("Expected file but found directory: {$fullPath}"); + } + + Log::debug('The private key path is:', ['path' => $fullPath]); + + $content = file_get_contents($fullPath); + + if ($content === false) { + throw new RuntimeException("Failed to read private key file: {$fullPath}"); + } + + return $content; + } + + /** + * Get the content of a public key file. + * + * @param string $path The path to the public key file + * @return string The content of the public key file + * + * @throws RuntimeException|FileNotFoundException If the public key file cannot be found or read + */ + public static function getPublicKeyContent(string $path): string + { + if (static::isFake()) { + return static::getFake()->getPublicKeyContent($path); + } + + if (File::missing($path)) { + throw new RuntimeException("Public key file does not exist: {$path}"); + } + + return File::get($path); + } + + /** + * Enable fake mode for testing with optional initial setup. + * + * @param callable|null $setup A function to set up the fake + */ + public static function fake(?callable $setup = null): ServerConnectionFake + { + static::$fake = new ServerConnectionFake; + + if ($setup) { + $setup(static::$fake); + } + + static::usesFake(); + + return static::$fake; + } + + /** + * Set whether to use the fake implementation. + * + * @param bool $use Whether to use the fake + */ + public static function usesFake(bool $use = true): void + { + static::$usesFake = $use; + } + + /** + * Check if the fake implementation is being used. + * + * @return bool Whether the fake is being used + */ + public static function isFake(): bool + { + return static::$usesFake && static::$fake instanceof ServerConnectionFake; + } + + /** + * Reset the manager to its initial state. + */ + public static function reset(): void + { + static::$fake = null; + static::$usesFake = false; + static::$defaultPrivateKey = null; + static::$defaultPassphrase = null; + } + + /** + * Assert that a connection was established. + * + * @throws RuntimeException If server connection is not in fake mode + */ + public static function assertConnected(): void + { + static::getFake()->assertConnected(); + } + + /** + * Assert that a connection was disconnected. + * + * @throws RuntimeException If server connection is not in fake mode + */ + public static function assertDisconnected(): void + { + static::getFake()->assertDisconnected(); + } + + /** + * Assert that a connection was not established. + * + * @throws RuntimeException If server connection is not in fake mode + */ + public static function assertNotConnected(): void + { + static::getFake()->assertNotConnected(); + } + + /** + * Assert that a command was run or that any command was run. + * + * @param string|null $command The command to assert, or null to check if any command was run + * + * @throws RuntimeException If server connection is not in fake mode + */ + public static function assertCommandRan(?string $command = null): void + { + static::getFake()->assertCommandRan($command); + } + + /** + * Assert that no commands were run. + * + * @throws RuntimeException If server connection is not in fake mode + */ + public static function assertNoCommandsRan(): void + { + static::getFake()->assertNoCommandsRan(); + } + + /** + * Assert that any command was run. + * + * This is an alias for calling assertCommandRan() without arguments. + * It provides a more expressive way to check if any command was executed. + * + * @throws RuntimeException If server connection is not in fake mode + */ + public static function assertAnyCommandRan(): void + { + static::getFake()->assertAnyCommandRan(); + } + + /** + * 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 static function assertFileUploaded(string $localPath, string $remotePath): void + { + static::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 static function assertFileDownloaded(string $remotePath, string $localPath): void + { + static::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 static function assertOutput(string $output): void + { + static::getFake()->assertOutput($output); + } + + /** + * 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 static function assertConnectionAttempted(array $connectionDetails): void + { + static::getFake()->assertConnectionAttempted($connectionDetails); + } + + /** + * Get the fake instance for assertions. + * + * @throws RuntimeException If server connection is not in fake mode or if the fake instance is not set + */ + protected static function getFake(): ServerConnectionFake + { + if (! static::isFake()) { + throw new RuntimeException('Server connection is not in fake mode.'); + } + + if (! static::$fake instanceof ServerConnectionFake) { + throw new RuntimeException('Fake instance is not set. Call ServerConnectionManager::fake() first.'); + } + + return static::$fake; + } +} diff --git a/app/helpers.php b/app/helpers.php index 4cb23842..3faec501 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -16,6 +16,8 @@ function ssh_keys_exist(): bool } /** + * @deprecated Please use the ServerConnectionManager static implementation. + * * Get the contents of the SSH public key. * * @return string The contents of the SSH public key. @@ -35,6 +37,8 @@ function get_ssh_public_key(): string } /** + * @deprecated Please use the ServerConnectionManager static implementation. + * * Get the contents of the SSH private key. * * @return string The contents of the SSH private key. diff --git a/bootstrap/providers.php b/bootstrap/providers.php index e150abd7..02562132 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -4,4 +4,5 @@ App\Providers\AppServiceProvider::class, App\Providers\HorizonServiceProvider::class, App\Providers\VoltServiceProvider::class, + App\Providers\ServerConnectionServiceProvider::class, ]; diff --git a/phpunit.xml b/phpunit.xml index 506b9a38..479fb8b1 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -11,6 +11,12 @@ tests/Feature + + tests/Fakes + + + tests/Rules + diff --git a/tests/Fakes/ServerConnection/ServerConnectionFakeTest.php b/tests/Fakes/ServerConnection/ServerConnectionFakeTest.php new file mode 100644 index 00000000..bf061728 --- /dev/null +++ b/tests/Fakes/ServerConnection/ServerConnectionFakeTest.php @@ -0,0 +1,230 @@ +toBeTrue(); +}); + +it('returns fake private key content', function (): void { + $privateKey = ServerConnectionManager::getDefaultPrivateKey(); + expect($privateKey)->toBe('fake_private_key_content'); +}); + +it('returns fake public key content', function (): void { + $publicKey = ServerConnectionManager::getDefaultPublicKey(); + expect($publicKey)->toBe('fake_public_key_content'); +}); + +it('returns fake passphrase', function (): void { + $passphrase = ServerConnectionManager::getDefaultPassphrase(); + expect($passphrase)->toBe('fake_passphrase'); +}); + +it('returns fake private key path', function (): void { + $path = ServerConnectionManager::getDefaultPrivateKeyPath(); + expect($path)->toBe('fake/path/to/private/key'); +}); + +it('creates a PendingConnection when connecting', function (): void { + $pendingConnection = ServerConnectionManager::connect('example.com', 2222, 'user'); + expect($pendingConnection)->toBeInstanceOf(PendingConnection::class); +}); + +it('records connection attempts', function (): void { + ServerConnectionManager::connect('example.com', 2222, 'user'); + ServerConnectionManager::assertConnectionAttempted([ + 'host' => 'example.com', + 'port' => 2222, + 'username' => 'user', + ]); +}); + +it('creates a PendingConnection when connecting from a model', function (): void { + $remoteServer = RemoteServer::factory()->create(); + + $pendingConnection = ServerConnectionManager::connectFromModel($remoteServer); + expect($pendingConnection)->toBeInstanceOf(PendingConnection::class); +}); + +it('simulates establishing a connection', function (): void { + $pendingConnection = ServerConnectionManager::connect('example.com', 2222, 'user'); + $connection = $pendingConnection->establish(); + + expect($connection)->toBeInstanceOf(Connection::class); + ServerConnectionManager::assertConnected(); +}); + +it('simulates running a command', function (): void { + $pendingConnection = ServerConnectionManager::connect('example.com', 2222, 'user'); + $connection = $pendingConnection->establish(); + + $connection->run('ls -la'); + ServerConnectionManager::assertCommandRan('ls -la'); +}); + +it('simulates file upload', function (): void { + $pendingConnection = ServerConnectionManager::connect('example.com', 2222, 'user'); + $connection = $pendingConnection->establish(); + + $connection->upload('/local/path', '/remote/path'); + ServerConnectionManager::assertFileUploaded('/local/path', '/remote/path'); +}); + +it('simulates file download', function (): void { + $pendingConnection = ServerConnectionManager::connect('example.com', 2222, 'user'); + $connection = $pendingConnection->establish(); + + $connection->download('/remote/path', '/local/path'); + ServerConnectionManager::assertFileDownloaded('/remote/path', '/local/path'); +}); + +it('simulates disconnecting', function (): void { + $pendingConnection = ServerConnectionManager::connect('example.com', 2222, 'user'); + $connection = $pendingConnection->establish(); + + $connection->disconnect(); + ServerConnectionManager::assertDisconnected(); +}); + +it('allows setting custom output for commands', function (): void { + ServerConnectionManager::fake(function ($fake): void { + $fake->setOutput('Custom output'); + }); + + $pendingConnection = ServerConnectionManager::connect('example.com', 2222, 'user'); + $connection = $pendingConnection->establish(); + + $output = $connection->run('some command'); + expect($output)->toBe('Custom output'); +}); + +it('throws an exception when asserting on non-fake connection', function (): void { + ServerConnectionManager::reset(); + ServerConnectionManager::assertConnected(); +})->throws(RuntimeException::class, 'Server connection is not in fake mode.'); + +it('simulates connection failure', function (): void { + ServerConnectionManager::fake(function ($fake): void { + $fake->shouldNotConnect(); + }); + + $pendingConnection = ServerConnectionManager::connect('example.com', 2222, 'user'); + + expect(fn (): Connection => $pendingConnection->establish())->toThrow(ConnectionException::class); + ServerConnectionManager::assertNotConnected(); +}); + +it('simulates multiple command runs', function (): void { + $pendingConnection = ServerConnectionManager::connect('example.com', 2222, 'user'); + $connection = $pendingConnection->establish(); + + $connection->run('command1'); + $connection->run('command2'); + $connection->run('command3'); + + ServerConnectionManager::assertCommandRan('command1'); + ServerConnectionManager::assertCommandRan('command2'); + ServerConnectionManager::assertCommandRan('command3'); + ServerConnectionManager::assertAnyCommandRan(); +}); + +it('asserts no commands were run', function (): void { + $pendingConnection = ServerConnectionManager::connect('example.com', 2222, 'user'); + $pendingConnection->establish(); + + ServerConnectionManager::assertNoCommandsRan(); +}); + +it('simulates multiple file uploads', function (): void { + $pendingConnection = ServerConnectionManager::connect('example.com', 2222, 'user'); + $connection = $pendingConnection->establish(); + + $connection->upload('/local/path1', '/remote/path1'); + $connection->upload('/local/path2', '/remote/path2'); + + ServerConnectionManager::assertFileUploaded('/local/path1', '/remote/path1'); + ServerConnectionManager::assertFileUploaded('/local/path2', '/remote/path2'); +}); + +it('simulates multiple file downloads', function (): void { + $pendingConnection = ServerConnectionManager::connect('example.com', 2222, 'user'); + $connection = $pendingConnection->establish(); + + $connection->download('/remote/path1', '/local/path1'); + $connection->download('/remote/path2', '/local/path2'); + + ServerConnectionManager::assertFileDownloaded('/remote/path1', '/local/path1'); + ServerConnectionManager::assertFileDownloaded('/remote/path2', '/local/path2'); +}); + +it('resets fake state correctly', function (): void { + ServerConnectionManager::connect('example.com', 2222, 'user'); + ServerConnectionManager::reset(); + + expect(ServerConnectionManager::isFake())->toBeFalse(); +}); + +it('allows custom setup of fake instance', function (): void { + ServerConnectionManager::fake(function ($fake): void { + $fake->shouldConnect()->setOutput('Custom output'); + }); + + $pendingConnection = ServerConnectionManager::connect('example.com', 2222, 'user'); + $connection = $pendingConnection->establish(); + + expect($connection->run('command'))->toBe('Custom output'); +}); + +it('simulates connection from model with custom attributes', function (): void { + $remoteServer = RemoteServer::factory()->create([ + 'ip_address' => '127.0.0.1', + 'port' => 2222, + 'username' => 'customuser', + ]); + + ServerConnectionManager::connectFromModel($remoteServer); + ServerConnectionManager::assertConnectionAttempted([ + 'host' => '127.0.0.1', + 'port' => 2222, + 'username' => 'customuser', + ]); +}); + +it('throws exception when trying to run command on closed connection', function (): void { + $pendingConnection = ServerConnectionManager::connect('example.com', 2222, 'user'); + $connection = $pendingConnection->establish(); + $connection->disconnect(); + + expect(fn (): string => $connection->run('command'))->toThrow(RuntimeException::class, 'Cannot perform operation: Connection is closed.'); +}); + +it('throws exception when trying to upload file on closed connection', function (): void { + $pendingConnection = ServerConnectionManager::connect('example.com', 2222, 'user'); + $connection = $pendingConnection->establish(); + $connection->disconnect(); + + expect(fn (): bool => $connection->upload('/local/path', '/remote/path'))->toThrow(RuntimeException::class, 'Cannot perform operation: Connection is closed.'); +}); + +it('throws exception when trying to download file on closed connection', function (): void { + $pendingConnection = ServerConnectionManager::connect('example.com', 2222, 'user'); + $connection = $pendingConnection->establish(); + $connection->disconnect(); + + expect(fn (): bool => $connection->download('/remote/path', '/local/path'))->toThrow(RuntimeException::class, 'Cannot perform operation: Connection is closed.'); +}); diff --git a/tests/Feature/RemoteServers/Actions/CheckRemoteServerConnectionTest.php b/tests/Feature/RemoteServers/Actions/CheckRemoteServerConnectionTest.php index 84ebfb00..3a500e7b 100644 --- a/tests/Feature/RemoteServers/Actions/CheckRemoteServerConnectionTest.php +++ b/tests/Feature/RemoteServers/Actions/CheckRemoteServerConnectionTest.php @@ -4,17 +4,82 @@ use App\Actions\RemoteServer\CheckRemoteServerConnection; use App\Events\RemoteServerConnectivityStatusChanged; +use App\Facades\ServerConnection; use App\Models\RemoteServer; +use Illuminate\Support\Facades\Event; -it('dispatches events when the script runs if the model id is passed', closure: function (): void { +it('can check that a server is online', function (): void { Event::fake(); + $fake = ServerConnection::fake(); + $fake->shouldConnect(); + $remoteServer = RemoteServer::factory()->create([ 'connectivity_status' => RemoteServer::STATUS_OFFLINE, ]); - $check = new CheckRemoteServerConnection; - $check->byRemoteServerId($remoteServer->id); + $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(); + Event::assertDispatched(RemoteServerConnectivityStatusChanged::class); +}); + +it('can check that a server is offline', function (): void { + Event::fake(); + + ServerConnection::fake()->shouldNotConnect(); + + $remoteServer = RemoteServer::factory()->create([ + 'connectivity_status' => RemoteServer::STATUS_ONLINE, + ]); + + $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); +}); + +it('uses the current connectivity status if not provided', function (): void { + $remoteServer = RemoteServer::factory()->create([ + 'connectivity_status' => RemoteServer::STATUS_ONLINE, + ]); + + $event = new RemoteServerConnectivityStatusChanged($remoteServer); - Event::assertDispatched(RemoteServerConnectivityStatusChanged::class, fn ($e): bool => $e->remoteServer->id === $remoteServer->id); + expect($event->connectivityStatus)->toBe(RemoteServer::STATUS_ONLINE); }); diff --git a/tests/Feature/Services/RemoveSSHKeyServiceTest.php b/tests/Feature/Services/RemoveSSHKeyServiceTest.php new file mode 100644 index 00000000..00d35365 --- /dev/null +++ b/tests/Feature/Services/RemoveSSHKeyServiceTest.php @@ -0,0 +1,45 @@ +create(); + $service = new RemoveSSHKeyService; + + $service->handle($remoteServer); + + ServerConnection::assertConnected(); + ServerConnection::assertAnyCommandRan(); + + Mail::assertQueued(SuccessfullyRemovedKey::class, function ($mail) use ($remoteServer): bool { + return $mail->remoteServer->id === $remoteServer->id; + }); + + Mail::assertNotQueued(FailedToRemoveKey::class); +}); + +it('handles connection failure by not running commands and sending failure email', function (): void { + Mail::fake(); + ServerConnection::fake()->shouldNotConnect(); + $remoteServer = RemoteServer::factory()->create(); + $service = new RemoveSSHKeyService; + + $service->handle($remoteServer); + + ServerConnection::assertNotConnected(); + ServerConnection::assertNoCommandsRan(); + + Mail::assertQueued(FailedToRemoveKey::class, function ($mail) use ($remoteServer): bool { + return $mail->remoteServer->id === $remoteServer->id; + }); + + Mail::assertNotQueued(SuccessfullyRemovedKey::class); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 69292812..157dded3 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -25,6 +25,7 @@ uses(TestCase::class, RefreshDatabase::class)->in('Feature'); uses(TestCase::class, RefreshDatabase::class)->in('Unit'); uses(DuskTestCase::class)->in('Browser'); +uses(TestCase::class, RefreshDatabase::class)->in('Fakes'); /* |-------------------------------------------------------------------------- @@ -50,11 +51,6 @@ | */ -function something(): void -{ - // .. -} - function test_create_keys(): void { $pathToSSHKeys = storage_path('app/ssh'); diff --git a/tests/Unit/Services/SSHKeyRemoval/RemoveSSHKeyServiceTest.php b/tests/Unit/Services/SSHKeyRemoval/RemoveSSHKeyServiceTest.php deleted file mode 100644 index f068d755..00000000 --- a/tests/Unit/Services/SSHKeyRemoval/RemoveSSHKeyServiceTest.php +++ /dev/null @@ -1,88 +0,0 @@ -sshClient = Mockery::mock(SSHClientInterface::class); - $this->notifier = Mockery::mock(KeyRemovalNotifierInterface::class); - $this->sshKeyProvider = Mockery::mock(SSHKeyProviderInterface::class); - - $this->service = new RemoveSSHKeyService( - $this->sshClient, - $this->notifier, - $this->sshKeyProvider - ); - - $this->remoteServer = new RemoteServer([ - 'id' => 1, - 'ip_address' => '192.168.1.1', - 'port' => 22, - 'username' => 'testuser', - ]); -}); - -it('successfully removes SSH key from remote server', function (): void { - $this->sshKeyProvider->shouldReceive('getPrivateKey')->once()->andReturn('private-key'); - $this->sshKeyProvider->shouldReceive('getPublicKey')->once()->andReturn('public-key'); - - $this->sshClient->shouldReceive('connect') - ->with('192.168.1.1', 22, 'testuser', 'private-key') - ->once() - ->andReturn(true); - - $this->sshClient->shouldReceive('executeCommand') - ->with(Mockery::pattern('/sed -i -e/')) - ->once(); - - $this->notifier->shouldReceive('notifySuccess') - ->with($this->remoteServer) - ->once(); - - $this->service->handle($this->remoteServer); -}); - -it('handles connection failure gracefully', function (): void { - $this->sshKeyProvider->shouldReceive('getPrivateKey')->once()->andReturn('private-key'); - - $this->sshClient->shouldReceive('connect') - ->with('192.168.1.1', 22, 'testuser', 'private-key') - ->once() - ->andReturn(false); - - $this->notifier->shouldReceive('notifyFailure') - ->with($this->remoteServer, Mockery::type('string')) - ->once(); - - $this->service->handle($this->remoteServer); -}); - -it('logs appropriate messages during the process', function (): void { - $this->sshKeyProvider->shouldReceive('getPrivateKey')->once()->andReturn('private-key'); - $this->sshKeyProvider->shouldReceive('getPublicKey')->once()->andReturn('public-key'); - - $this->sshClient->shouldReceive('connect')->once()->andReturn(true); - $this->sshClient->shouldReceive('executeCommand')->once(); - - $this->notifier->shouldReceive('notifySuccess')->once(); - - Log::shouldReceive('info')->times(3) - ->withArgs(function ($message): bool { - return in_array($message, [ - 'Initiating SSH key removal process.', - 'SSH key removed from server.', - 'User notified of successful key removal.', - ]); - }); - - $this->service->handle($this->remoteServer); -}); - -afterEach(function (): void { - Mockery::close(); -});