Internal
Public Access
Adds two kinds of coverage, continuing #1's remaining scope: 1. Pure-function tests for mapSshError() and escapeForShellSingleQuotes(), promoted from private members to public statics purely so tests can call them without spinning up a process. escapeForShellSingleQuotes() is the actual security boundary for password auth (it's what stops a password containing a single quote from breaking out of the askpass script's quoting), so it gets a real adversarial test, not just a happy-path one. 2. State-machine tests (connect -> Connected, auth failure -> Failed with the right mapped message, connection refused -> Failed, input round-tripping, reconnect) driven against tests/fixtures/fake_ssh.sh, a small controllable stand-in for the real ssh binary, instead of a real network/SSH server. This needed one small testability seam: a new constructor overload that overrides the launched program ("ssh" in production, the fixture script in tests). POSIX-only for now: the fixture is a shell script, so the state-machine tests QSKIP on Windows until an equivalent fixture exists there; the pure-function tests run everywhere. RdpSessionBackend coverage is still open -- it's a bigger lift again (FreeRDP's own event loop, not just a QProcess), left for a follow-up. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
237 lines
9.0 KiB
C++
237 lines
9.0 KiB
C++
#include "ssh_session_backend.h"
|
|
|
|
#include <QTest>
|
|
|
|
#include <memory>
|
|
|
|
#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<SshSessionBackend> 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<SshSessionBackend>(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"
|