#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"