diff --git a/src/ssh_session_backend.cpp b/src/ssh_session_backend.cpp index 9e0e3be..5343b9f 100644 --- a/src/ssh_session_backend.cpp +++ b/src/ssh_session_backend.cpp @@ -7,16 +7,14 @@ #include #include -namespace { -QString escapeForShellSingleQuotes(const QString& value) +SshSessionBackend::SshSessionBackend(const Profile& profile, QObject* parent) + : SshSessionBackend(profile, QStringLiteral("ssh"), parent) { - QString escaped = value; - escaped.replace(QStringLiteral("'"), QStringLiteral("'\"'\"'")); - return escaped; -} } -SshSessionBackend::SshSessionBackend(const Profile& profile, QObject* parent) +SshSessionBackend::SshSessionBackend(const Profile& profile, + const QString& sshProgramOverride, + QObject* parent) : SessionBackend(profile, parent), m_process(new QProcess(this)), m_connectedProbeTimer(new QTimer(this)), @@ -27,7 +25,8 @@ SshSessionBackend::SshSessionBackend(const Profile& profile, QObject* parent) m_waitingForHostKeyConfirmation(false), m_passwordSubmitted(false), m_terminalColumns(0), - m_terminalRows(0) + m_terminalRows(0), + m_sshProgram(sshProgramOverride) { m_connectedProbeTimer->setSingleShot(true); @@ -395,7 +394,7 @@ bool SshSessionBackend::startSshProcess(const SessionConnectOptions& options) args << target; m_process->setProcessEnvironment(environment); - m_process->setProgram(QStringLiteral("ssh")); + m_process->setProgram(m_sshProgram); m_process->setArguments(args); m_process->setProcessChannelMode(QProcess::SeparateChannels); @@ -471,7 +470,7 @@ void SshSessionBackend::cleanupAskPassScript() } } -QString SshSessionBackend::mapSshError(const QString& rawError) const +QString SshSessionBackend::mapSshError(const QString& rawError) { const QString raw = rawError.trimmed(); if (raw.contains(QStringLiteral("Permission denied"), Qt::CaseInsensitive)) { @@ -510,6 +509,13 @@ QString SshSessionBackend::mapSshError(const QString& rawError) const return QStringLiteral("SSH connection failed."); } +QString SshSessionBackend::escapeForShellSingleQuotes(const QString& value) +{ + QString escaped = value; + escaped.replace(QStringLiteral("'"), QStringLiteral("'\"'\"'")); + return escaped; +} + QString SshSessionBackend::knownHostsFileForNullDevice() const { #ifdef Q_OS_WIN diff --git a/src/ssh_session_backend.h b/src/ssh_session_backend.h index cb522e5..3f5af2c 100644 --- a/src/ssh_session_backend.h +++ b/src/ssh_session_backend.h @@ -13,8 +13,16 @@ class SshSessionBackend : public SessionBackend public: explicit SshSessionBackend(const Profile& profile, QObject* parent = nullptr); + // Test-only: overrides the executable launched instead of "ssh", so + // tests can point it at a controllable fixture script. + SshSessionBackend(const Profile& profile, const QString& sshProgramOverride, QObject* parent); ~SshSessionBackend() override; + // Pure, state-free helpers exposed as public statics purely so tests + // can exercise them directly without spinning up a real ssh process. + static QString mapSshError(const QString& rawError); + static QString escapeForShellSingleQuotes(const QString& value); + public slots: void connectSession(const SessionConnectOptions& options) override; void disconnectSession() override; @@ -46,6 +54,7 @@ private: bool m_passwordSubmitted; int m_terminalColumns; int m_terminalRows; + QString m_sshProgram; void setState(SessionState state, const QString& message); bool startSshProcess(const SessionConnectOptions& options); @@ -53,7 +62,6 @@ private: QProcessEnvironment& environment, QString& error); void cleanupAskPassScript(); - QString mapSshError(const QString& rawError) const; QString knownHostsFileForNullDevice() const; void applyTerminalSizeIfAvailable(); }; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f99255e..9cd2943 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -5,3 +5,15 @@ add_executable(test_profile_repository target_include_directories(test_profile_repository PRIVATE ${CMAKE_SOURCE_DIR}/src) target_link_libraries(test_profile_repository PRIVATE Qt6::Core Qt6::Sql Qt6::Test) add_test(NAME test_profile_repository COMMAND test_profile_repository) + +add_executable(test_ssh_session_backend + test_ssh_session_backend.cpp + ${CMAKE_SOURCE_DIR}/src/ssh_session_backend.cpp + ${CMAKE_SOURCE_DIR}/src/session_backend.h +) +target_include_directories(test_ssh_session_backend PRIVATE ${CMAKE_SOURCE_DIR}/src) +target_link_libraries(test_ssh_session_backend PRIVATE Qt6::Core Qt6::Gui Qt6::Test) +target_compile_definitions(test_ssh_session_backend PRIVATE + ORBITHUB_TEST_FIXTURES_DIR="${CMAKE_CURRENT_SOURCE_DIR}/fixtures" +) +add_test(NAME test_ssh_session_backend COMMAND test_ssh_session_backend) diff --git a/tests/fixtures/fake_ssh.sh b/tests/fixtures/fake_ssh.sh new file mode 100755 index 0000000..22f2e89 --- /dev/null +++ b/tests/fixtures/fake_ssh.sh @@ -0,0 +1,29 @@ +#!/bin/sh +# Minimal, deterministic stand-in for the real `ssh` binary, used by +# SshSessionBackend's state-machine tests so they never touch a real +# network or SSH server. Behavior is selected by which fixture hostname +# appears among argv (SshSessionBackend always passes the profile's +# host, optionally as user@host, as the final argument). +for arg in "$@"; do + case "$arg" in + *@succeed|succeed) + echo "Welcome to the fake host." + # Stay alive echoing stdin back (simulates an interactive + # session) until the backend terminates us. + while IFS= read -r line; do + echo "$line" + done + exit 0 + ;; + *@fail-auth|fail-auth) + echo "Permission denied (publickey,password)." >&2 + exit 255 + ;; + *@refuse|refuse) + echo "ssh: connect to host refuse port 22: Connection refused" >&2 + exit 255 + ;; + esac +done +echo "fake_ssh.sh: no recognized fixture host in arguments: $*" >&2 +exit 1 diff --git a/tests/test_ssh_session_backend.cpp b/tests/test_ssh_session_backend.cpp new file mode 100644 index 0000000..367e1c4 --- /dev/null +++ b/tests/test_ssh_session_backend.cpp @@ -0,0 +1,236 @@ +#include "ssh_session_backend.h" + +#include + +#include + +#ifndef ORBITHUB_TEST_FIXTURES_DIR +#error "ORBITHUB_TEST_FIXTURES_DIR must be defined by the build" +#endif + +namespace { +Profile makeProfile(const QString& fixtureHost) +{ + Profile profile; + profile.name = QStringLiteral("Test Profile"); + profile.host = fixtureHost; + profile.port = 22; + profile.username = QStringLiteral("tester"); + profile.protocol = QStringLiteral("SSH"); + profile.authMode = QStringLiteral("Password"); + return profile; +} + +SessionConnectOptions makeOptions() +{ + SessionConnectOptions options; + options.password = QStringLiteral("dummy-password"); + return options; +} +} + +class TestSshSessionBackend : public QObject +{ + Q_OBJECT + +private slots: + // Pure-function coverage -- no process involved. + void mapSshErrorRecognizesKnownPatterns(); + void mapSshErrorFallsBackForUnknownText(); + void mapSshErrorHandlesEmptyInput(); + void escapeForShellSingleQuotesNeutralizesQuotes(); + void escapeForShellSingleQuotesLeavesPlainTextAlone(); + + // State-machine coverage, driven against tests/fixtures/fake_ssh.sh + // instead of a real ssh binary or network. + void init(); + void cleanup(); + void successfulConnectReachesConnectedThenDisconnects(); + void authFailureReachesFailedStateWithMappedMessage(); + void connectionRefusedReachesFailedState(); + void sendInputEchoesThroughOutputReceived(); + void reconnectRestartsAndReachesConnectedAgain(); + +private: + QString fixturePath() const; + void createBackend(const QString& fixtureHost); + + std::unique_ptr m_backend; + SessionState m_lastState = SessionState::Disconnected; + QString m_lastErrorDisplay; + QString m_lastErrorRaw; + QString m_receivedOutput; +}; + +QString TestSshSessionBackend::fixturePath() const +{ + return QStringLiteral(ORBITHUB_TEST_FIXTURES_DIR "/fake_ssh.sh"); +} + +void TestSshSessionBackend::createBackend(const QString& fixtureHost) +{ + m_backend = + std::make_unique(makeProfile(fixtureHost), fixturePath(), nullptr); + connect(m_backend.get(), + &SessionBackend::stateChanged, + this, + [this](SessionState state, const QString&) { m_lastState = state; }); + connect(m_backend.get(), + &SessionBackend::connectionError, + this, + [this](const QString& display, const QString& raw) { + m_lastErrorDisplay = display; + m_lastErrorRaw = raw; + }); + connect(m_backend.get(), + &SessionBackend::outputReceived, + this, + [this](const QString& chunk) { m_receivedOutput += chunk; }); +} + +void TestSshSessionBackend::mapSshErrorRecognizesKnownPatterns() +{ + QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("Permission denied (publickey,password).")), + QStringLiteral("Authentication failed. Check username and credentials.")); + QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("Host key verification failed.")), + QStringLiteral("Host key verification failed.")); + QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("ssh: Could not resolve hostname bogus")), + QStringLiteral("Host could not be resolved.")); + QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("ssh: connect to host x port 22: Connection timed out")), + QStringLiteral("Connection timed out.")); + QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("ssh: connect to host x port 22: Connection refused")), + QStringLiteral("Connection refused by remote host.")); + QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("ssh: connect to host x port 22: No route to host")), + QStringLiteral("No route to host.")); + QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("Identity file /nope not accessible: No such file.")), + QStringLiteral("Private key file is not accessible.")); + QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("posix_spawn: /usr/bin/ssh-askpass: No such file or directory")), + QStringLiteral("SSH password helper is missing or failed to launch.")); + QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("open /some/other/path: No such file or directory")), + QStringLiteral("Required file was not found.")); +} + +void TestSshSessionBackend::mapSshErrorFallsBackForUnknownText() +{ + QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral("some completely novel ssh error text")), + QStringLiteral("SSH connection failed.")); +} + +void TestSshSessionBackend::mapSshErrorHandlesEmptyInput() +{ + QCOMPARE(SshSessionBackend::mapSshError(QString()), + QStringLiteral("SSH connection failed for an unknown reason.")); + QCOMPARE(SshSessionBackend::mapSshError(QStringLiteral(" ")), + QStringLiteral("SSH connection failed for an unknown reason.")); +} + +void TestSshSessionBackend::escapeForShellSingleQuotesNeutralizesQuotes() +{ + // A password containing a single quote must not be able to break out + // of the single-quoted printf argument in the askpass script -- this + // is the actual security boundary, not just cosmetic escaping. + const QString malicious = QStringLiteral("pw' ; rm -rf ~ ; echo '"); + const QString escaped = SshSessionBackend::escapeForShellSingleQuotes(malicious); + const QString reconstructedScriptArg = QStringLiteral("'") + escaped + QStringLiteral("'"); + // Every single quote in the reconstructed argument must be either the + // outer boundary quote (open at index 0, close at the very end) or the + // start of a full '"'"' re-opening sequence -- never a bare, unescaped + // quote that could close the argument early. + int index = 0; + while (index < reconstructedScriptArg.length()) { + if (reconstructedScriptArg.at(index) != QChar::fromLatin1('\'')) { + ++index; + continue; + } + if (index == 0 || index == reconstructedScriptArg.length() - 1) { + ++index; + continue; + } + QCOMPARE(reconstructedScriptArg.mid(index, 5), QStringLiteral("'\"'\"'")); + index += 5; + } +} + +void TestSshSessionBackend::escapeForShellSingleQuotesLeavesPlainTextAlone() +{ + QCOMPARE(SshSessionBackend::escapeForShellSingleQuotes(QStringLiteral("plain-password-123")), + QStringLiteral("plain-password-123")); +} + +void TestSshSessionBackend::init() +{ +#ifdef Q_OS_WIN + // fixtures/fake_ssh.sh is a POSIX shell script; there's no Windows + // fixture yet, so skip only the tests that actually launch it. The + // pure-function tests above (mapSshError*, escapeForShellSingleQuotes*) + // don't touch the fixture and still run everywhere. + const QByteArray currentTest = QTest::currentTestFunction(); + if (!currentTest.startsWith("mapSshError") && !currentTest.startsWith("escapeForShellSingleQuotes")) { + QSKIP("No Windows equivalent of tests/fixtures/fake_ssh.sh yet"); + } +#endif + + m_lastState = SessionState::Disconnected; + m_lastErrorDisplay.clear(); + m_lastErrorRaw.clear(); + m_receivedOutput.clear(); + // Individual tests call createBackend() with the fixture host they + // need; most want "succeed", so provide it as the default here. + createBackend(QStringLiteral("succeed")); +} + +void TestSshSessionBackend::cleanup() +{ + if (m_backend) { + m_backend->disconnectSession(); + } + m_backend.reset(); +} + +void TestSshSessionBackend::successfulConnectReachesConnectedThenDisconnects() +{ + m_backend->connectSession(makeOptions()); + QTRY_COMPARE(m_lastState, SessionState::Connected); + + m_backend->disconnectSession(); + QTRY_COMPARE(m_lastState, SessionState::Disconnected); +} + +void TestSshSessionBackend::authFailureReachesFailedStateWithMappedMessage() +{ + createBackend(QStringLiteral("fail-auth")); + m_backend->connectSession(makeOptions()); + QTRY_COMPARE(m_lastState, SessionState::Failed); + QCOMPARE(m_lastErrorDisplay, QStringLiteral("Authentication failed. Check username and credentials.")); + QVERIFY(m_lastErrorRaw.contains(QStringLiteral("Permission denied"))); +} + +void TestSshSessionBackend::connectionRefusedReachesFailedState() +{ + createBackend(QStringLiteral("refuse")); + m_backend->connectSession(makeOptions()); + QTRY_COMPARE(m_lastState, SessionState::Failed); + QCOMPARE(m_lastErrorDisplay, QStringLiteral("Connection refused by remote host.")); +} + +void TestSshSessionBackend::sendInputEchoesThroughOutputReceived() +{ + m_backend->connectSession(makeOptions()); + QTRY_COMPARE(m_lastState, SessionState::Connected); + + m_backend->sendInput(QStringLiteral("hello-from-test\n")); + QTRY_VERIFY(m_receivedOutput.contains(QStringLiteral("hello-from-test"))); +} + +void TestSshSessionBackend::reconnectRestartsAndReachesConnectedAgain() +{ + m_backend->connectSession(makeOptions()); + QTRY_COMPARE(m_lastState, SessionState::Connected); + + m_lastState = SessionState::Connecting; + m_backend->reconnectSession(makeOptions()); + QTRY_COMPARE(m_lastState, SessionState::Connected); +} + +QTEST_GUILESS_MAIN(TestSshSessionBackend) +#include "test_ssh_session_backend.moc"