Internal
Public Access
The username entered at the connect-time prompt (added for issue #21) was updating SessionTab's own in-memory Profile copy, but SshSessionBackend/RdpSessionBackend are constructed with -- and only ever read from -- their own separate Profile copy on a worker thread, which never saw that edit. Authentication was still built from the original (blank) username regardless of what was typed into the prompt. SessionConnectOptions gains a username field, populated by SessionTab on every connect attempt and threaded through the same way password already is; both backends now prefer options.username over profile().username. Covered by a new SSH regression test using an exact-match fixture host that only succeeds for a specific user@host target. Bump version to v2026.9.16.5. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
269 lines
10 KiB
C++
269 lines
10 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;
|
|
}
|
|
|
|
Profile makeBlankUsernameProfile(const QString& fixtureHost)
|
|
{
|
|
Profile profile = makeProfile(fixtureHost);
|
|
profile.username.clear();
|
|
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();
|
|
void connectOptionsUsernameReachesProcessWhenProfileUsernameIsBlank();
|
|
|
|
private:
|
|
QString fixturePath() const;
|
|
void createBackend(const QString& fixtureHost);
|
|
void createBackend(const Profile& profile);
|
|
|
|
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)
|
|
{
|
|
createBackend(makeProfile(fixtureHost));
|
|
}
|
|
|
|
void TestSshSessionBackend::createBackend(const Profile& profile)
|
|
{
|
|
m_backend = std::make_unique<SshSessionBackend>(profile, 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);
|
|
}
|
|
|
|
void TestSshSessionBackend::connectOptionsUsernameReachesProcessWhenProfileUsernameIsBlank()
|
|
{
|
|
// Regression test for a bug where a username entered at the
|
|
// connect-time prompt (SessionTab::requestConnectOptions(), for a
|
|
// profile with no saved username -- issue #21) never actually reached
|
|
// the ssh process: SshSessionBackend built its target purely from
|
|
// profile().username, which is a separate copy captured when the
|
|
// backend was constructed and never sees SessionTab's later edit.
|
|
// fixtures/fake_ssh.sh's "requireuser" host only accepts the exact
|
|
// target "prompted-user@requireuser", so this fails unless
|
|
// SessionConnectOptions::username is actually used.
|
|
createBackend(makeBlankUsernameProfile(QStringLiteral("requireuser")));
|
|
|
|
SessionConnectOptions options = makeOptions();
|
|
options.username = QStringLiteral("prompted-user");
|
|
m_backend->connectSession(options);
|
|
QTRY_COMPARE(m_lastState, SessionState::Connected);
|
|
}
|
|
|
|
QTEST_GUILESS_MAIN(TestSshSessionBackend)
|
|
#include "test_ssh_session_backend.moc"
|