mudlet/test/functional_tests/PackageRemovalSaveTeardownTest.cpp
Vadim Peretokin 669c586f62
infrastructure: rename the six tests Windows refuses to launch without elevation (#9753)
#### Brief overview of PR changes/additions
- Renames the six test executables whose filenames trip Windows' UAC
installer detection: `UpdaterChecksumTest` to
`ReleaseChecksumPairingTest`, `UpdaterPlatformAssetTest` to
`ReleasePlatformAssetTest`, `UpdaterTeardownTest` to
`NewReleaseDialogTeardownTest`, `PackageSelfUninstallTest` to
`PackageSelfRemovalTest`, `PackageUninstallSaveTeardownTest` to
`PackageRemovalSaveTeardownTest`, `ActionSelfUninstallTest` to
`ActionSelfRemovalTest`. Assertions are untouched.
- Adds a configure-time gate in `test/CMakeLists.txt` that fails with an
actionable message if any test executable name contains install, setup,
update or patch. It checks both the targets a configuration builds and
the test source filenames, so conditionally registered tests cannot slip
past it.
- Documents the naming rule in `test/README.md`.

#### Motivation for adding to Mudlet
Windows treats an unsigned executable named that way as an installer and
refuses to start it, so those six tests reported `BAD_COMMAND` for
anyone running the suite from an ordinary Windows shell - and because CI
runners are elevated, nothing caught it as more tests were added.

#### Other info (issues closed, discussion etc)
Fixes #9748

The gate was verified to fire on a target named after the guard
statement, on one in a subdirectory, on `EventDispatcherTest` (the
message names the offending substring, since "dispatch" contains
"patch"), on a test registered only under `USE_UPDATER` when configuring
with the updater off, and to fail loudly if the walk ever stops finding
executables.

**Test case:** `cmake --build build && ctest --test-dir build` - 92/92
pass; adding a test named e.g. `FooUpdateTest` fails the configure with
an explanation.

Assisted-by: Claude:claude-opus-5
2026-08-11 08:07:10 +02:00

403 lines
22 KiB
C++

/***************************************************************************
* Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org *
* *
* 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. *
***************************************************************************/
/*
* Regression test for the profile save that uninstalling a package puts off to
* the next event loop pass outliving the profile (#9653).
*
* Uninstalling a package cannot save the profile there and then - the
* asynchronous save mechanism would be handed a package that has just been
* taken out of memory - so the save is deferred. Closing Mudlet right after an
* uninstall then destroys the Host while that save is still owed, and the save
* ran anyway: against a freed Host, reading its writer map. Under
* AddressSanitizer that is a heap-use-after-free at
* Host::pendingXmlSaveFutures(); in a release build it is a crash or silent
* memory corruption on the way out, i.e. a "Mudlet crashed when I closed it"
* report.
*
* The two tests here pin both halves of what the fix has to hold true: the
* deferred save still happens for a profile that stays up, and nothing of it is
* left to run once the profile has been closed and its Host destroyed. The
* report itself needs the whole application to shut down (the queued call is
* delivered by the event loop pass after mudlet::closeEvent() has returned),
* which is what the busted package specs arrange; what this file adds is the
* contract the fix rests on, and a sanitizer run over the uninstall/close/
* destroy/pump sequence itself.
*
* Run with: ctest -R PackageRemovalSaveTeardownTest -V
*/
#include <QtTest/QtTest>
#include <QTemporaryDir>
#include <chrono>
#include <zip.h>
#include "Host.h"
#include "AliasUnit.h"
#include "HostManager.h"
#include "MudletInstanceCoordinator.h"
#include "TelnetServerStub.h"
#include "dlgConnectionProfiles.h"
#include "mudlet.h"
using namespace std::chrono_literals;
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 initializeQRCResourcesForPackageRemovalSaveTeardownTest();
class PackageRemovalSaveTeardownTest : public QObject
{
Q_OBJECT
private:
TelnetServerStub* mpServer = nullptr;
Host* mpHost = nullptr;
const QString mProfileName = qsl("PackageRemovalSaveTeardown-Test");
const QString mLocalhost = qsl("localhost");
QString mPort; // the stub's actual ephemeral port, assigned in initTestCase()
// The refusal test below installs an archive that names itself ".." - that
// has to happen nowhere near the developer's own profiles.
QTemporaryDir mConfigDir;
QByteArray mSavedXdg;
static void deleteProfileDirectory(const QString& profileName)
{
QDir dir(mudlet::getMudletPath(enums::profileHomePath, profileName));
if (dir.exists()) {
dir.removeRecursively();
}
}
static QStringList savedProfileFiles(const QString& profileName) { return QDir(mudlet::getMudletPath(enums::profileXmlFilesPath, profileName)).entryList(QStringList{qsl("*.xml")}, QDir::Files); }
// Whether needle appears in the profile that was saved last - what actually
// landed on disk, rather than what a save signal says was attempted.
static bool lastSavedProfileContains(const QString& profileName, const QString& needle)
{
const QDir directory(mudlet::getMudletPath(enums::profileXmlFilesPath, profileName));
const QStringList saved = directory.entryList(QStringList{qsl("*.xml")}, QDir::Files, QDir::Name);
if (saved.isEmpty()) {
return false;
}
QFile file(directory.absoluteFilePath(saved.last()));
if (!file.open(QFile::ReadOnly | QFile::Text)) {
return false;
}
return QString::fromUtf8(file.readAll()).contains(needle);
}
// Utility function to manually start a profile like a user would do via the GUI
void startProfile(const QString& profileName, const QString& address, const QString& port)
{
QTimer::singleShot(0ms, qApp, [profileName, 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(), profileName);
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(5000)) {
QFAIL("Profile took too long to load.");
}
mpHost = mudlet::self()->getActiveHost();
if (!mpHost) {
QFAIL("No active host available for the test.");
}
}
// The package itself is beside the point here - what matters is that
// uninstallPackage() has something to take away, and so owes the profile a
// save afterwards.
void uninstallPackageOwingASave(const QString& packageName)
{
mpHost->waitForProfileSave();
mpHost->mInstalledPackages << packageName;
QVERIFY2(mpHost->uninstallPackage(packageName, enums::PackageModuleType::Package), "The package could not be uninstalled");
QVERIFY2(!mpHost->mInstalledPackages.contains(packageName), "The package is still installed");
QVERIFY2(mpHost->hasPendingProfileSave(), "Uninstalling a package left the profile no save to do");
}
// Writes an archive holding one file, i.e. one installPackage() unpacks and
// then refuses, having registered nothing from it.
static bool writeArchive(const QString& path, const QString& entryName, const QByteArray& contents)
{
int errorCode = 0;
zip* archive = zip_open(path.toUtf8().constData(), ZIP_CREATE | ZIP_TRUNCATE, &errorCode);
if (!archive) {
return false;
}
zip_source* source = zip_source_buffer(archive, contents.constData(), contents.size(), 0);
if (!source || zip_file_add(archive, entryName.toUtf8().constData(), source, ZIP_FL_ENC_UTF_8) < 0) {
zip_source_free(source);
zip_discard(archive);
return false;
}
return zip_close(archive) == 0;
}
// ...specifically one whose config.lua renames the package to declaredName.
static bool writeConfigOnlyArchive(const QString& path, const QString& declaredName) { return writeArchive(path, qsl("config.lua"), qsl("mpackage = \"%1\"\n").arg(declaredName).toUtf8()); }
QString profileFilePath(const QString& relativePath) const { return qsl("%1/%2").arg(mudlet::getMudletPath(enums::profileHomePath, mProfileName), relativePath); }
private slots:
void initTestCase()
{
initializeQRCResourcesForPackageRemovalSaveTeardownTest();
// Keep the test hermetic: point the config dir resolution at a temporary
// directory instead of the user's real profiles - one of the tests below
// drives an archive that tries to have the profiles folder deleted.
QVERIFY(mConfigDir.isValid());
mSavedXdg = qgetenv("XDG_CONFIG_HOME");
QVERIFY(QDir().mkpath(qsl("%1/mudlet/profiles").arg(mConfigDir.path())));
qputenv("XDG_CONFIG_HOME", mConfigDir.path().toUtf8());
mpServer = new TelnetServerStub(qApp);
mpServer->start(mLocalhost, 0); // ephemeral OS-assigned port avoids collisions across concurrent test runs
mPort = QString::number(mpServer->serverPort());
mudlet::start();
mudlet::self()->setupConfig();
mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>(qsl("MudletInstanceCoordinator")));
mudlet::self()->init();
mudlet::self()->setStorePasswordsSecurely(false);
deleteProfileDirectory(mProfileName);
startProfile(mProfileName, mLocalhost, mPort);
QVERIFY2(mpHost, "No active host after profile creation");
}
void cleanupTestCase()
{
mpHost = nullptr;
delete mpServer;
mpServer = nullptr;
deleteProfileDirectory(mProfileName);
delete mudlet::self();
mSavedXdg.isNull() ? qunsetenv("XDG_CONFIG_HOME") : qputenv("XDG_CONFIG_HOME", mSavedXdg);
}
// The save is deferred, not dropped: a profile that stays up has to end up
// with the uninstall written out. Without this the test below could be
// passed by never saving at all.
void test_deferredSaveRunsWhileTheProfileIsUp()
{
QSignalSpy saveSpy(mpHost, &Host::profileSaveStarted);
uninstallPackageOwingASave(qsl("uninstall-save-deferred"));
QCOMPARE(saveSpy.count(), 0); // the point of the deferral: not saved on the spot
QTRY_VERIFY_WITH_TIMEOUT(saveSpy.count() >= 1, 5000);
mpHost->waitForProfileSave();
}
// A batch of uninstalls owes the profile one save between them, not one
// each: restarting the timer is what the old "only one timer is running"
// flag did, and a profile save is expensive enough that the package specs
// are shaped around how many of them a run does.
void test_aBatchOfUninstallsOwesOneSave()
{
QSignalSpy saveSpy(mpHost, &Host::profileSaveStarted);
uninstallPackageOwingASave(qsl("uninstall-save-batch-one"));
// no pumping in between, so all three land in the same event loop pass
mpHost->mInstalledPackages << qsl("uninstall-save-batch-two") << qsl("uninstall-save-batch-three");
QVERIFY(mpHost->uninstallPackage(qsl("uninstall-save-batch-two"), enums::PackageModuleType::Package));
QVERIFY(mpHost->uninstallPackage(qsl("uninstall-save-batch-three"), enums::PackageModuleType::Package));
QTRY_VERIFY_WITH_TIMEOUT(saveSpy.count() >= 1, 5000);
mpHost->waitForProfileSave();
QCOMPARE(saveSpy.count(), 1);
}
// Refusing an archive that installed nothing takes the folder it unpacked
// away again (#9654) - and nothing else. The package name can be whatever an
// untrusted archive's config.lua says, and ".." names the folder that holds
// every profile the user has.
void test_refusingAnArchiveOnlyRemovesItsOwnFolder()
{
QTemporaryDir archiveDir;
QVERIFY2(archiveDir.isValid(), "Could not create a temporary directory for the test archive");
const QString archivePath = archiveDir.filePath(qsl("uninstall-save-escape.mpackage"));
QVERIFY2(writeConfigOnlyArchive(archivePath, qsl("..")), "Could not write the test archive");
mpHost->waitForProfileSave(); // an install during a save is postponed and answered with a bare true
const QString profileHome = mudlet::getMudletPath(enums::profileHomePath, mProfileName);
const QString profilesDirectory = QFileInfo(profileHome).absolutePath();
auto [ok, message] = mpHost->installPackage(archivePath, enums::PackageModuleType::Package, true);
QVERIFY2(!ok, "An archive holding no package was installed");
QVERIFY2(QDir(profilesDirectory).exists(), "Refusing the archive took the folder holding every profile with it");
QVERIFY2(QDir(profileHome).exists(), "Refusing the archive took the profile with it");
QVERIFY2(!savedProfileFiles(mProfileName).isEmpty(), "Refusing the archive took the saved profile with it");
}
// ...and it may only remove a folder it made itself. The package name is the
// archive's own file name, and then whatever its config.lua says, so it can
// just as well be "map" - the folder the profile keeps the user's maps in.
void test_refusingAnArchiveLeavesFoldersItDidNotMake()
{
const QString mapFolder = profileFilePath(qsl("map"));
const QString mapFile = qsl("%1/spec-map.dat").arg(mapFolder);
QVERIFY2(QDir().mkpath(mapFolder), "Could not create the map folder the profile would have");
QFile map(mapFile);
QVERIFY2(map.open(QFile::WriteOnly), "Could not write the map file this test is about");
map.write("map data that was here before any package was installed");
map.close();
QTemporaryDir archiveDir;
QVERIFY2(archiveDir.isValid(), "Could not create a temporary directory for the test archives");
mpHost->waitForProfileSave(); // an install during a save is postponed and answered with a bare true
// named through config.lua, from an archive called something harmless
const QString viaConfig = archiveDir.filePath(qsl("uninstall-save-mapgrab.mpackage"));
QVERIFY2(writeConfigOnlyArchive(viaConfig, qsl("map")), "Could not write the test archive");
auto [configOk, configMessage] = mpHost->installPackage(viaConfig, enums::PackageModuleType::Package, true);
QVERIFY2(!configOk, "An archive holding no package was installed");
QVERIFY2(QFile::exists(mapFile), "Refusing the archive took the profile's map folder with it");
// the folder the install did make is this one, and it does have to go
QVERIFY2(!QDir(profileFilePath(qsl("uninstall-save-mapgrab"))).exists(), "Refusing the archive left the folder it unpacked behind");
// ...and the same through the archive's file name alone, no config.lua
mpHost->waitForProfileSave();
const QString viaFileName = archiveDir.filePath(qsl("map.mpackage"));
QVERIFY2(writeArchive(viaFileName, qsl("readme.txt"), QByteArray("no package in here")), "Could not write the test archive");
auto [fileNameOk, fileNameMessage] = mpHost->installPackage(viaFileName, enums::PackageModuleType::Package, true);
QVERIFY2(!fileNameOk, "An archive holding no package was installed");
QVERIFY2(QFile::exists(mapFile), "Refusing the archive took the profile's map folder with it");
}
// The refusal is about archives nothing could be read out of, not about
// archives whose XML turns out to be no good - those are a different case,
// and one this deliberately leaves alone.
void test_anArchiveWithABadXmlIsStillARemovablePackage()
{
QTemporaryDir archiveDir;
QVERIFY2(archiveDir.isValid(), "Could not create a temporary directory for the test archives");
// 1. well-formed XML that is not a Mudlet package at all. XMLimport only
// reports the XML reader's own errors, so the import of this one
// SUCCEEDS - checking the import result would not refuse it either.
mpHost->waitForProfileSave();
const QString notAPackage = archiveDir.filePath(qsl("spec-notapackage.mpackage"));
QVERIFY2(writeArchive(notAPackage, qsl("spec-notapackage.xml"), QByteArray("<?xml version=\"1.0\"?>\n<something-else/>\n")), "Could not write the test archive");
auto [notAPackageOk, notAPackageMessage] = mpHost->installPackage(notAPackage, enums::PackageModuleType::Package, true);
QVERIFY2(notAPackageOk, qPrintable(notAPackageMessage));
QVERIFY2(mpHost->mInstalledPackages.contains(qsl("spec-notapackage")), "The package was not registered");
mpHost->waitForProfileSave(); // installing a package saves, and an uninstall during a save is refused
QVERIFY2(mpHost->uninstallPackage(qsl("spec-notapackage"), enums::PackageModuleType::Package), "The package could not be uninstalled");
QVERIFY2(!QDir(profileFilePath(qsl("spec-notapackage"))).exists(), "Uninstalling left the package folder behind");
// 2. XML the reader does fail on, after it has already read items out of
// it. The import answers false, but the alias it created is in the
// profile - refusing the archive here would delete the folder and
// strand what was imported, and the package is registered either way,
// so it is listed and can be uninstalled. That is what #9654 was about.
mpHost->waitForProfileSave();
const QString truncated = archiveDir.filePath(qsl("spec-truncatedxml.mpackage"));
const QByteArray truncatedXml = QByteArray("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
"<!DOCTYPE MudletPackage>\n"
"<MudletPackage version=\"1.001\">\n"
"<AliasPackage>\n"
"<Alias isActive=\"yes\" isFolder=\"no\">\n"
"<name>spec-truncatedxml alias</name>\n"
"<script>send(\"hello\")</script>\n"
"<command></command>\n"
"<packageName></packageName>\n"
"<regex>^spec-truncatedxml$</regex>\n"
"</Alias>\n"
"</AliasPackage>\n"
"<ActionPackage");
QVERIFY2(writeArchive(truncated, qsl("spec-truncatedxml.xml"), truncatedXml), "Could not write the test archive");
auto [truncatedOk, truncatedMessage] = mpHost->installPackage(truncated, enums::PackageModuleType::Package, true);
QVERIFY2(truncatedOk, qPrintable(truncatedMessage));
QVERIFY2(mpHost->getAliasUnit()->findFirstAlias(qsl("spec-truncatedxml alias")), "The alias read before the XML gave out was not created");
QVERIFY2(mpHost->mInstalledPackages.contains(qsl("spec-truncatedxml")), "The package was not registered");
mpHost->waitForProfileSave();
QVERIFY2(mpHost->uninstallPackage(qsl("spec-truncatedxml"), enums::PackageModuleType::Package), "The package could not be uninstalled");
QVERIFY2(!QDir(profileFilePath(qsl("spec-truncatedxml"))).exists(), "Uninstalling left the package folder behind");
}
// ...and closing the profile straight after an uninstall must leave nothing
// of that save behind: it would run on a destroyed Host.
void test_deferredSaveDoesNotOutliveTheProfile()
{
const QString packageName = qsl("uninstall-save-teardown");
QSignalSpy saveSpy(mpHost, &Host::profileSaveStarted);
uninstallPackageOwingASave(packageName);
// The close path Mudlet takes when the application is closed
// (mudlet::closeEvent): forceClose() keeps TMainConsole::closeEvent()
// from asking whether to save, which would block on a modal dialog.
// deleteHost() is the step of the mudlet::closeHost() that follows which
// destroys the Host - the rest of it is tab and dock bookkeeping, and is
// private to mudlet.
mpHost->forceClose();
QVERIFY2(mpHost->requestClose(), "Closing the profile was refused");
QVERIFY2(!mpHost->hasPendingProfileSave(), "Closing the profile left a package save still owed");
// Dropping that save is only right because the uninstall reached the disk
// on the way out - by the close's own save, or by the deferred one going
// first. Assert the profile that was written, not that a save was tried:
QVERIFY2(saveSpy.count() >= 1, "Closing the profile after an uninstall saved it nowhere");
QVERIFY2(!lastSavedProfileContains(mProfileName, packageName), "The saved profile still carries the uninstalled package");
mpHost = nullptr;
mudlet::self()->getHostManager().deleteHost(mProfileName);
// Nothing the uninstall queued may reach the destroyed Host now. Under
// AddressSanitizer a queued save that does reach it aborts the run here;
// without the sanitizer, the save it writes is what gives it away.
const QStringList savedBefore = savedProfileFiles(mProfileName);
QTest::qWait(500ms);
QCOMPARE(savedProfileFiles(mProfileName), savedBefore);
}
};
void initializeQRCResourcesForPackageRemovalSaveTeardownTest()
{
#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 "PackageRemovalSaveTeardownTest.moc"
QTEST_MAIN(PackageRemovalSaveTeardownTest)