mirror of
https://github.com/Mudlet/Mudlet
synced 2026-08-13 18:26:27 -04:00
#### Brief overview of PR changes/additions
- Removes all direct Qt Widgets usage from `ctelnet.{h,cpp}`: the
TLS-upgrade question, GUI-download progress dialog, and bell alert/beep
now go through Qt signals; the frontend (mudlet/TMainConsole) owns the
actual widgets
- Establishes the seam template for the libmudlet split: core emits
pre-translated payload -> frontend shows widget -> callback slot with a
state guard
- Adds `TelnetTlsPromptTest`: drives a real MSSP `TLS` subnegotiation
through a stub server and asserts the new signal fires with the right
payload
#### Motivation for adding to Mudlet
First concrete step of the re-scoped libmudlet plan (Widgets-free
`mudlet_core` for headless/testability/WASM); this PR is the pattern
every later extraction (Host, TMap, XMLexport) will copy, so it's a
draft for reviewing the template itself.
#### Other info (issues closed, discussion etc)
Part of #8681 / #9011. Behavior-preserving: the TLS dialog is still
modal and synchronous (same-thread direct connection), all strings stay
in cTelnet's tr() context so existing translations are unaffected. One
hardening: the TLS response slot now guards against the connection
dropping while the dialog is open. Functional suite 17/17, unit 27/28
(lone failure is the pre-existing TKeySequenceEditTest headless flake).
Assisted-by: Claude:claude-opus-4-8
**Test case:** Connect to a game; on a server advertising MSSP TLS (e.g.
one that sends `IAC SB MSSP` with a TLS port) the upgrade question
appears and both Yes/No behave as before; trigger a server BEL and check
the taskbar alert/beep; install a server-offered GUI package and check
the download progress dialog shows, updates, and its Cancel aborts the
download.
#### Demo (before & after)
Behavior-preserving refactor, so this proves parity of the user-visible
flow: a server advertising an MSSP `TLS` port raises the modal
secure-port question, and clicking **No** reconnects in open mode
without re-prompting - identical on `development` (before) and this
branch (after).
https://github.com/user-attachments/assets/77955117-fb95-400b-9e52-7bc08157478d
356 lines
15 KiB
C++
356 lines
15 KiB
C++
/***************************************************************************
|
|
* Copyright (C) 2026 by Mudlet Developers *
|
|
* *
|
|
* This program is free software; you can redistribute it and/or modify *
|
|
* it under the terms of the GNU General Public License as published by *
|
|
* the Free Software Foundation; either version 2 of the License, or *
|
|
* (at your option) any later version. *
|
|
* *
|
|
* This program is distributed in the hope that it will be useful, *
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
|
* GNU General Public License for more details. *
|
|
* *
|
|
* You should have received a copy of the GNU General Public License *
|
|
* along with this program; if not, write to the *
|
|
* Free Software Foundation, Inc., *
|
|
* 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. *
|
|
***************************************************************************/
|
|
|
|
#include <QtTest/QtTest>
|
|
|
|
#include <chrono>
|
|
|
|
#include "MudletInstanceCoordinator.h"
|
|
#include "TMainConsole.h"
|
|
#include "TelnetServerStub.h"
|
|
#include "ctelnet.h"
|
|
#include "dlgConnectionProfiles.h"
|
|
#include "mudlet.h"
|
|
#include "utils.h"
|
|
|
|
#include <QProgressDialog>
|
|
|
|
extern void qInitResources_mudlet();
|
|
extern void qInitResources_qm();
|
|
extern void qInitResources_additional_splash_screens();
|
|
extern void qInitResources_mudlet_fonts_common();
|
|
extern void qInitResources_mudlet_fonts_posix();
|
|
void initializeQRCResourcesForTlsPrompt();
|
|
|
|
using namespace std::chrono_literals;
|
|
|
|
// Exercises the widget-free seam introduced when cTelnet was de-widgeted: when
|
|
// MSSP advertises a secure (TLS) port on an unencrypted connection, cTelnet must
|
|
// no longer construct a QMessageBox itself; instead it emits
|
|
// signal_promptTlsAvailable() carrying the (already translated) strings, and the
|
|
// frontend owns the modal dialog. This test verifies the signal is emitted with
|
|
// the expected payload.
|
|
class TelnetTlsPromptTest : public QObject
|
|
{
|
|
Q_OBJECT
|
|
|
|
private:
|
|
TelnetServerStub* mpServer = nullptr;
|
|
const QString mHostname = "Test-Telnet-Tls-Prompt";
|
|
const QString mLocalhost = "localhost";
|
|
QString mPort;
|
|
|
|
private slots:
|
|
void initTestCase() { initializeQRCResourcesForTlsPrompt(); }
|
|
|
|
void init()
|
|
{
|
|
mpServer = new TelnetServerStub(qApp);
|
|
// Bind an ephemeral OS-assigned port so parallel test runs (e.g. across
|
|
// git worktrees) do not collide on a shared fixed port.
|
|
mpServer->start(mLocalhost, 0);
|
|
mPort = QString::number(mpServer->serverPort());
|
|
mudlet::start();
|
|
mudlet::self()->setupConfig();
|
|
mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator"));
|
|
mudlet::self()->init();
|
|
mudlet::self()->setStorePasswordsSecurely(false);
|
|
deleteProfileDirectory(mHostname);
|
|
}
|
|
|
|
void test_msspTlsPortEmitsPromptSignal()
|
|
{
|
|
#if defined(QT_NO_SSL)
|
|
QSKIP("Built without SSL support - the TLS upgrade prompt does not exist.");
|
|
#else
|
|
startProfile(mHostname, mLocalhost, mPort);
|
|
auto host = mudlet::self()->getActiveHost();
|
|
QVERIFY2(host, "No active host available for the test.");
|
|
|
|
// Detach the frontend's modal handler: mudlet::addConsoleForNewHost
|
|
// connects a lambda that would call QMessageBox::exec() and block the
|
|
// test (there is no user to click it). Disconnect by signal alone
|
|
// (nullptr receiver/slot) rather than by receiver: if the frontend
|
|
// wiring ever moves off mudlet::self() this still detaches every
|
|
// handler, so the test cannot hang forever inside exec().
|
|
disconnect(&host->mTelnet, &cTelnet::signal_promptTlsAvailable, nullptr, nullptr);
|
|
|
|
QSignalSpy spy(&host->mTelnet, &cTelnet::signal_promptTlsAvailable);
|
|
QVERIFY(spy.isValid());
|
|
|
|
// Build an MSSP subnegotiation advertising a secure port:
|
|
// IAC SB MSSP MSSP_VAR "TLS" MSSP_VAL "48000" IAC SE
|
|
const QByteArray tlsPort = "48000";
|
|
QByteArray data;
|
|
data.append(TN_IAC);
|
|
data.append(TN_SB);
|
|
data.append(OPT_MSSP);
|
|
data.append(MSSP_VAR);
|
|
data.append("TLS");
|
|
data.append(MSSP_VAL);
|
|
data.append(tlsPort);
|
|
data.append(TN_IAC);
|
|
data.append(TN_SE);
|
|
// processSocketData() writes a NUL at in_buffer[size + 1], so give the
|
|
// backing buffer a little slack before handing it its data pointer.
|
|
data.reserve(data.size() + 16);
|
|
|
|
host->mTelnet.loopbackTest(data);
|
|
|
|
// The signal is emitted synchronously inside loopbackTest(), so it has
|
|
// almost certainly already arrived; only wait if it somehow has not,
|
|
// otherwise an unconditional spy.wait() would burn its full timeout.
|
|
if (spy.isEmpty()) {
|
|
QVERIFY2(spy.wait(2s), "cTelnet did not emit signal_promptTlsAvailable when MSSP advertised a TLS port.");
|
|
}
|
|
QCOMPARE(spy.count(), 1);
|
|
|
|
// The informative text carries the advertised port, keeping the string
|
|
// (and its translation context) inside cTelnet.
|
|
const QList<QVariant> arguments = spy.takeFirst();
|
|
QCOMPARE(arguments.size(), 2);
|
|
const QString informativeText = arguments.at(1).toString();
|
|
QVERIFY2(informativeText.contains(QString::fromLatin1(tlsPort)), qPrintable(QString("informativeText did not mention the TLS port: %1").arg(informativeText)));
|
|
|
|
// cTelnet recorded the advertised port as well.
|
|
QCOMPARE(host->mMSSPTlsPort, tlsPort.toInt());
|
|
#endif
|
|
}
|
|
|
|
// No path: declining the TLS upgrade must leave the port and ssl_tsl
|
|
// untouched, stop the client asking again this session, and still bring the
|
|
// connection back (slot_tlsUpgradeResponse() does disconnectIt() +
|
|
// reconnect() against the stub, which accepts repeat connections).
|
|
void test_tlsUpgradeDeclinedKeepsPortAndStopsAsking()
|
|
{
|
|
#if defined(QT_NO_SSL)
|
|
QSKIP("Built without SSL support - the TLS upgrade prompt does not exist.");
|
|
#else
|
|
startProfile(mHostname, mLocalhost, mPort);
|
|
auto host = mudlet::self()->getActiveHost();
|
|
QVERIFY2(host, "No active host available for the test.");
|
|
|
|
// Detach the frontend modal handler so no dialog blocks the test.
|
|
disconnect(&host->mTelnet, &cTelnet::signal_promptTlsAvailable, nullptr, nullptr);
|
|
|
|
const int originalPort = host->getPort();
|
|
const bool originalSsl = host->mSslTsl;
|
|
|
|
// Advertise a secure port so mMSSPTlsPort is populated (the handler is
|
|
// detached, so nothing pops up).
|
|
QByteArray advertise = msspTlsPayload("48000");
|
|
advertise.reserve(advertise.size() + 16);
|
|
host->mTelnet.loopbackTest(advertise);
|
|
|
|
// The user answers No; the reconnect that follows must complete.
|
|
QSignalSpy connectedSpy(&host->mTelnet, &cTelnet::signal_connected);
|
|
host->mTelnet.slot_tlsUpgradeResponse(false);
|
|
QVERIFY2(connectedSpy.wait(5s), "Declining the TLS upgrade did not reconnect to the server.");
|
|
|
|
QVERIFY2(!host->mAskTlsAvailable, "Declining the TLS upgrade did not stop the client asking again.");
|
|
QCOMPARE(host->getPort(), originalPort);
|
|
QCOMPARE(host->mSslTsl, originalSsl);
|
|
|
|
// Re-advertising the same secure port must NOT prompt again now that
|
|
// the user has declined (don't-ask-again is sticky for the session).
|
|
QSignalSpy promptSpy(&host->mTelnet, &cTelnet::signal_promptTlsAvailable);
|
|
QByteArray advertiseAgain = msspTlsPayload("48000");
|
|
advertiseAgain.reserve(advertiseAgain.size() + 16);
|
|
host->mTelnet.loopbackTest(advertiseAgain);
|
|
QCOMPARE(promptSpy.count(), 0);
|
|
#endif
|
|
}
|
|
|
|
// Yes path: accepting switches the profile to the advertised secure port
|
|
// and starts a fresh (encrypted) connection to it. A second plain-TCP stub
|
|
// stands in for the secure server: connectToHostEncrypted() completes the
|
|
// TCP accept before the (doomed) TLS handshake, and SSL errors are reported
|
|
// via postMessage rather than a modal dialog, so nothing blocks.
|
|
void test_tlsUpgradeAcceptedSwitchesPortAndConnects()
|
|
{
|
|
#if defined(QT_NO_SSL)
|
|
QSKIP("Built without SSL support - the TLS upgrade prompt does not exist.");
|
|
#else
|
|
startProfile(mHostname, mLocalhost, mPort);
|
|
auto host = mudlet::self()->getActiveHost();
|
|
QVERIFY2(host, "No active host available for the test.");
|
|
|
|
disconnect(&host->mTelnet, &cTelnet::signal_promptTlsAvailable, nullptr, nullptr);
|
|
|
|
TelnetServerStub secureStub;
|
|
secureStub.start(mLocalhost, 0);
|
|
const quint16 securePort = secureStub.serverPort();
|
|
|
|
// Advertise the second stub's port as the secure port.
|
|
QByteArray advertise = msspTlsPayload(QByteArray::number(securePort));
|
|
advertise.reserve(advertise.size() + 16);
|
|
host->mTelnet.loopbackTest(advertise);
|
|
QCOMPARE(host->mMSSPTlsPort, static_cast<int>(securePort));
|
|
|
|
QSignalSpy acceptSpy(&secureStub, &QTcpServer::newConnection);
|
|
host->mTelnet.slot_tlsUpgradeResponse(true);
|
|
|
|
// The port switch and ssl_tsl flag are set synchronously.
|
|
QCOMPARE(host->getPort(), static_cast<int>(securePort));
|
|
QVERIFY2(host->mSslTsl, "Accepting the TLS upgrade did not enable ssl_tsl on the profile.");
|
|
// The encrypted connect reaches the secure stub's TCP accept.
|
|
QVERIFY2(acceptSpy.wait(5s), "Accepting the TLS upgrade did not open a TCP connection to the secure port.");
|
|
|
|
// Stop talking to the local stub before it is destroyed at scope exit.
|
|
host->mTelnet.disconnectIt();
|
|
#endif
|
|
}
|
|
|
|
// A received telnet BELL (0x07) must emit signal_bell() exactly once per
|
|
// bell byte so the frontend can flash/beep without re-alerting on redraws.
|
|
void test_bellEmitsSignalPerBell()
|
|
{
|
|
startProfile(mHostname, mLocalhost, mPort);
|
|
auto host = mudlet::self()->getActiveHost();
|
|
QVERIFY2(host, "No active host available for the test.");
|
|
|
|
QSignalSpy bellSpy(&host->mTelnet, &cTelnet::signal_bell);
|
|
|
|
QByteArray oneBell("\a");
|
|
oneBell.reserve(oneBell.size() + 16);
|
|
host->mTelnet.loopbackTest(oneBell);
|
|
QCOMPARE(bellSpy.count(), 1);
|
|
|
|
QByteArray twoBells("\a\a");
|
|
twoBells.reserve(twoBells.size() + 16);
|
|
host->mTelnet.loopbackTest(twoBells);
|
|
QCOMPARE(bellSpy.count(), 3);
|
|
}
|
|
|
|
// I2: a second package-download prompt must replace (not stack on top of)
|
|
// the first dialog, and cancelling with no active download is a no-op.
|
|
void test_packageDownloadProgressDialogReplaced()
|
|
{
|
|
startProfile(mHostname, mLocalhost, mPort);
|
|
auto host = mudlet::self()->getActiveHost();
|
|
QVERIFY2(host, "No active host available for the test.");
|
|
QVERIFY2(host->mpConsole, "The active host has no main console.");
|
|
|
|
auto console = host->mpConsole;
|
|
console->showPackageDownloadProgress("Downloading package 1", "Cancel");
|
|
console->showPackageDownloadProgress("Downloading package 2", "Cancel");
|
|
|
|
// The first dialog closes with WA_DeleteOnClose, so let its queued
|
|
// deleteLater() run before counting the live dialogs.
|
|
QTest::qWait(50ms);
|
|
QCOMPARE(console->findChildren<QProgressDialog*>().count(), 1);
|
|
|
|
// Cancelling when no download is in flight must be a harmless no-op.
|
|
host->mTelnet.slot_cancelPackageDownload();
|
|
QCOMPARE(console->findChildren<QProgressDialog*>().count(), 1);
|
|
}
|
|
|
|
// Builds an MSSP subnegotiation advertising a secure TLS port:
|
|
// IAC SB MSSP MSSP_VAR "TLS" MSSP_VAL <port> IAC SE
|
|
QByteArray msspTlsPayload(const QByteArray& port)
|
|
{
|
|
QByteArray data;
|
|
data.append(TN_IAC);
|
|
data.append(TN_SB);
|
|
data.append(OPT_MSSP);
|
|
data.append(MSSP_VAR);
|
|
data.append("TLS");
|
|
data.append(MSSP_VAL);
|
|
data.append(port);
|
|
data.append(TN_IAC);
|
|
data.append(TN_SE);
|
|
return data;
|
|
}
|
|
|
|
void cleanup()
|
|
{
|
|
delete mpServer;
|
|
mpServer = nullptr;
|
|
deleteProfileDirectory(mHostname);
|
|
delete mudlet::self();
|
|
}
|
|
|
|
// Utility function to manually start a profile like a user would do via the
|
|
// GUI
|
|
void startProfile(const QString& hostname, const QString& address, const QString& port)
|
|
{
|
|
QTimer::singleShot(0, qApp, [hostname, address, port]() {
|
|
mudlet::self()->startAutoLogin({});
|
|
QTest::qWait(100ms);
|
|
QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton);
|
|
QTest::qWait(100ms);
|
|
QTest::keyClicks(QApplication::focusWidget(), hostname);
|
|
QTest::qWait(100ms);
|
|
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab);
|
|
QTest::qWait(100ms);
|
|
QTest::keyClicks(QApplication::focusWidget(), address);
|
|
QTest::qWait(100ms);
|
|
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab);
|
|
QTest::qWait(100ms);
|
|
QTest::keyClicks(QApplication::focusWidget(), port);
|
|
QTest::qWait(100ms);
|
|
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return);
|
|
});
|
|
|
|
QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded);
|
|
if (!spy.wait(5s)) {
|
|
QFAIL("Profile took too long to load.");
|
|
}
|
|
auto host = mudlet::self()->getActiveHost();
|
|
if (!host) {
|
|
QFAIL("No active host available for the test.");
|
|
}
|
|
|
|
QSignalSpy spy2(&(host->mTelnet), &cTelnet::signal_connected);
|
|
if (!spy2.wait(2s)) {
|
|
QFAIL("Could not connect with the host.");
|
|
}
|
|
}
|
|
|
|
// Utility function
|
|
void deleteProfileDirectory(const QString& profileName)
|
|
{
|
|
const QString path = mudlet::getMudletPath(enums::profileHomePath, profileName);
|
|
QDir dir(path);
|
|
|
|
if (!dir.exists()) {
|
|
qInfo() << "Profile directory does not exist:" << path;
|
|
return;
|
|
}
|
|
dir.removeRecursively();
|
|
}
|
|
};
|
|
|
|
void initializeQRCResourcesForTlsPrompt()
|
|
{
|
|
#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN
|
|
qInitResources_additional_splash_screens();
|
|
#endif
|
|
#ifdef INCLUDE_FONTS
|
|
qInitResources_mudlet_fonts_common();
|
|
#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD)
|
|
qInitResources_mudlet_fonts_posix();
|
|
#endif
|
|
#endif
|
|
qInitResources_mudlet();
|
|
qInitResources_qm();
|
|
}
|
|
|
|
#include "TelnetTlsPromptTest.moc"
|
|
QTEST_MAIN(TelnetTlsPromptTest)
|