mudlet/test/functional_tests/PackageSelfRemovalTest.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

674 lines
32 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. *
***************************************************************************/
/*
* Regression test for use-after-free when a package uninstalls itself from its
* own timer script or event-handler script (#9337 class, the remaining timer
* and script cases).
*
* Package auto-updaters commonly call uninstallPackage()+installPackage() on
* their own package from one of the package's own items. Since the
* *Unit::uninstall() methods started deleting items immediately (instead of
* just unregistering them), doing that from a timer deleted the very TTimer
* whose execute() was still on the call stack; doing it from an event handler
* deleted TScript objects that Host::raiseEvent() was still iterating over; and
* doing it from a script's top-level body deleted the very TScript that
* compileScript() was in the middle of compiling - heap corruption every way.
* TriggerUnit/AliasUnit/KeyUnit gained a processing-depth deferral in #9383;
* this covers TimerUnit and ScriptUnit (both the event-dispatch and the
* compile-time body paths).
*
* Under AddressSanitizer the pre-fix code aborts with heap-use-after-free
* inside TTimer::execute() / Host::raiseEvent() / TScript::compileScript(); with
* the deferral in place all scenarios complete cleanly.
*
* The second half covers the other side of that deferral: an item whose delete
* is outstanding is still registered, and must not be written back into the
* profile by a save taken before the unit goes idle.
*
* Run with: ctest -R PackageSelfRemovalTest -V
*/
#include <QtTest/QtTest>
#include <QScopeGuard>
#include <QTemporaryDir>
#include "ActionUnit.h"
#include "AliasUnit.h"
#include "Host.h"
#include "HostManager.h"
#include "LuaInterface.h"
#include "MudletInstanceCoordinator.h"
#include "ScriptUnit.h"
#include "TAction.h"
#include "TAlias.h"
#include "TEvent.h"
#include "TScript.h"
#include "TTimer.h"
#include "TTrigger.h"
#include "TimerUnit.h"
#include "TriggerUnit.h"
#include "VarUnit.h"
#include "XMLexport.h"
#include "XMLimport.h"
#include "mudlet.h"
extern "C" {
#if defined(INCLUDE_VERSIONED_LUA_HEADERS)
#include <lua5.1/lauxlib.h>
#include <lua5.1/lua.h>
#else
#include <lauxlib.h>
#include <lua.h>
#endif
}
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 initializeQRCResourcesForPackageSelfRemovalTest();
// TriggerUnit only holds its depth inside processDataStream(), so a save at
// depth has to come from a trigger's own script. Stands in for the Lua
// saveProfile() one would call - a bare test Host has no console for that.
static Host* gpMidPassExportHost = nullptr;
static QString gMidPassExportPath;
static QString gMidPassExportedXml;
static int exportProfileMidPass(lua_State* L)
{
Q_UNUSED(L)
gMidPassExportedXml.clear();
if (!gpMidPassExportHost) {
return 0;
}
auto writer = std::make_shared<XMLexport>(gpMidPassExportHost);
// variables included: the only export here that builds the variable tree,
// and it does so with a Lua call frame live
if (!writer->exportPackage(gMidPassExportPath, true, false)) {
qWarning() << "exportProfileMidPass() - the export itself failed";
return 0;
}
QFile file(gMidPassExportPath);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
qWarning() << "exportProfileMidPass() - could not read back" << gMidPassExportPath;
return 0;
}
gMidPassExportedXml = QString::fromUtf8(file.readAll());
file.close();
QFile::remove(gMidPassExportPath);
return 0;
}
class PackageSelfRemovalTest : public QObject
{
Q_OBJECT
private:
const QString mProfileName = qsl("PackageSelfRemoval-Test");
QTemporaryDir mConfigDir;
QByteArray mSavedXdg;
Host* mpHost = nullptr;
private slots:
void initTestCase()
{
initializeQRCResourcesForPackageSelfRemovalTest();
// Keep the test hermetic: point the config dir resolution at a
// temporary directory instead of the user's real profiles.
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());
mudlet::start();
mudlet::self()->setupConfig();
mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator"));
mudlet::self()->init();
mudlet::self()->setStorePasswordsSecurely(false);
QVERIFY2(mudlet::self()->getHostManager().addHost(mProfileName, QString(), QString(), QString()), "failed to create the Host");
mpHost = mudlet::self()->getHostManager().getHost(mProfileName);
QVERIFY(mpHost);
// A bare Host blocks script compilation until the full profile boot
// would normally clear this; the test's scripts need to compile:
mpHost->mBlockScriptCompile = false;
// NB: mLoadedOk is left false on purpose - the deferred saveProfile()
// that uninstallPackage() schedules then declines to run, which this
// console-less test Host could not service anyway.
createKeeperItems();
}
void cleanupTestCase()
{
mpHost = nullptr;
delete mudlet::self();
mSavedXdg.isNull() ? qunsetenv("XDG_CONFIG_HOME") : qputenv("XDG_CONFIG_HOME", mSavedXdg);
}
// A package's event-handler script uninstalls its own package while
// Host::raiseEvent() is mid-dispatch. The second handler in the same
// package is what the pre-fix code would have called through a freed
// TScript pointer.
void test_scriptEventHandlerSelfUninstall()
{
const QString packageName = qsl("selfuninstall-script");
mpHost->mInstalledPackages << packageName;
auto pUninstaller = new TScript(nullptr, mpHost);
mpHost->getScriptUnit()->registerScript(pUninstaller);
pUninstaller->mPackageName = packageName;
pUninstaller->setName(qsl("selfUninstallHandler"));
QVERIFY2(pUninstaller->setScript(qsl("function selfUninstallHandler(event)\n uninstallPackage(\"%1\")\nend\n").arg(packageName)), "uninstaller handler script failed to compile");
pUninstaller->setEventHandlerList(QStringList{qsl("testSelfUninstallEvent")});
pUninstaller->setIsActive(true);
auto pBystander = new TScript(nullptr, mpHost);
mpHost->getScriptUnit()->registerScript(pBystander);
pBystander->mPackageName = packageName;
pBystander->setName(qsl("selfUninstallBystander"));
QVERIFY2(pBystander->setScript(qsl("function selfUninstallBystander(event)\nend\n")), "bystander handler script failed to compile");
pBystander->setEventHandlerList(QStringList{qsl("testSelfUninstallEvent")});
pBystander->setIsActive(true);
TEvent event{};
event.mArgumentList.append(qsl("testSelfUninstallEvent"));
event.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
// Pre-fix this dispatch freed both TScripts under raiseEvent()'s feet:
mpHost->raiseEvent(event);
QVERIFY2(!mpHost->mInstalledPackages.contains(packageName), "package was not uninstalled");
// The deferred deletes must have been flushed once the dispatch ended:
QVERIFY2(mpHost->getScriptUnit()->findItems(qsl("selfUninstallHandler")).empty(), "uninstalled script is still registered");
QVERIFY2(mpHost->getScriptUnit()->findItems(qsl("selfUninstallBystander")).empty(), "uninstalled script is still registered");
}
// A package script's TOP-LEVEL body (not an event handler) uninstalls its own
// package while it is being compiled - the path profile boot and reset take
// when they run "all the Lua code outside of functions" via ScriptUnit::compileAll().
// Pre-fix, ScriptUnit::uninstall() deleted the script immediately: compileScript()
// then wrote to the freed TScript (heap-use-after-free WRITE at TScript::compileScript),
// and compileAll()'s range-for walked onto the freed std::list node it had unlinked.
void test_scriptBodySelfUninstallOnCompile()
{
const QString packageName = qsl("selfuninstall-script-compile");
mpHost->mInstalledPackages << packageName;
// Defer compilation so the bodies run through compileAll() below rather than
// from setScript() here, mirroring how a freshly loaded package's scripts are
// compiled at profile boot (mudlet::loadProfile) and reset (Host::resetProfile_phase2):
mpHost->mBlockScriptCompile = true;
auto pUninstaller = new TScript(nullptr, mpHost);
mpHost->getScriptUnit()->registerScript(pUninstaller);
pUninstaller->mPackageName = packageName;
pUninstaller->setName(qsl("selfUninstallOnCompile"));
// A bare uninstallPackage() at the top level runs the moment the script is compiled:
pUninstaller->setScript(qsl("uninstallPackage(\"%1\")").arg(packageName));
pUninstaller->setIsActive(true);
// A second script in the same package, registered AFTER the uninstaller, is
// the node the pre-fix immediate delete unlinks from under compileAll()'s loop:
auto pBystander = new TScript(nullptr, mpHost);
mpHost->getScriptUnit()->registerScript(pBystander);
pBystander->mPackageName = packageName;
pBystander->setName(qsl("selfUninstallCompileBystander"));
pBystander->setScript(qsl("local noop = true\n"));
pBystander->setIsActive(true);
mpHost->mBlockScriptCompile = false;
// Pre-fix this trips heap-use-after-free; post-fix the delete is deferred until
// after the loop and then flushed by compileAll():
mpHost->getScriptUnit()->compileAll();
QVERIFY2(!mpHost->mInstalledPackages.contains(packageName), "package was not uninstalled");
// The deferred deletes must have been flushed at the end of compileAll():
QVERIFY2(mpHost->getScriptUnit()->findItems(qsl("selfUninstallOnCompile")).empty(), "uninstalled script is still registered");
QVERIFY2(mpHost->getScriptUnit()->findItems(qsl("selfUninstallCompileBystander")).empty(), "uninstalled bystander script is still registered");
}
// A package script's top-level body uninstalls its own package from a plain
// setScript() compile - the path permScript()/setScript() take when invoked from
// an alias, key or the command line, none of which sit inside compileAll(), the
// editor's saveScript(), or raiseEvent(). Pre-fix this hit the same
// heap-use-after-free in compileScript() as the compileAll() case. The compile
// guard defers the delete here too; because this entry point has no synchronous
// flush of its own, the deferred script lingers (deactivated) until a catch-all
// flush - ScriptUnit::doCleanup() via Host::incomingStreamProcessor()/
// slot_purgeTemps(), or the doCleanup() Host::uninstallPackage()'s queued save runs
// - collects it, which must happen without leaking or double-freeing.
void test_scriptBodySelfUninstallFromSetScript()
{
const QString packageName = qsl("selfuninstall-script-setscript");
mpHost->mInstalledPackages << packageName;
auto pUninstaller = new TScript(nullptr, mpHost);
mpHost->getScriptUnit()->registerScript(pUninstaller);
pUninstaller->mPackageName = packageName;
pUninstaller->setName(qsl("selfUninstallFromSetScript"));
// mBlockScriptCompile is false, so setScript() compiles and runs the top-level
// body immediately - uninstallPackage() fires from inside compileScript():
pUninstaller->setScript(qsl("uninstallPackage(\"%1\")").arg(packageName));
QVERIFY2(!mpHost->mInstalledPackages.contains(packageName), "package was not uninstalled");
// No synchronous flush point covers this entry, so the script is still
// registered (deferred, deactivated) right after setScript() returns:
QVERIFY2(!mpHost->getScriptUnit()->findItems(qsl("selfUninstallFromSetScript")).empty(), "self-uninstalling script should still be deferred, not yet deleted");
// The catch-all flush (as wired into incomingStreamProcessor()/slot_purgeTemps())
// must then collect it cleanly - no leak, no double-free:
mpHost->getScriptUnit()->doCleanup();
QVERIFY2(mpHost->getScriptUnit()->findItems(qsl("selfUninstallFromSetScript")).empty(), "deferred uninstalled script was not flushed");
}
// A package's timer script uninstalls its own package while
// TTimer::execute() is still on the call stack for that timer. Pre-fix,
// execute() would resume on a freed `this`.
void test_timerScriptSelfUninstall()
{
const QString packageName = qsl("selfuninstall-timer");
mpHost->mInstalledPackages << packageName;
auto pTimer = new TTimer(qsl("selfUninstallTimer"), QTime(0, 0, 0, 250), mpHost);
mpHost->getTimerUnit()->registerTimer(pTimer);
pTimer->mPackageName = packageName;
// The trailing error() is what makes this a genuine regression test: after
// uninstallPackage() has (pre-fix) freed this timer, the error aborts the
// Lua call so it returns false, and TTimer::execute() then resumes past the
// call and reads the freed `this` at `mpQTimer->stop()` - the heap-use-after-free
// the deferral prevents. Without the error the call returns cleanly and
// execute() never touches `this` again, so the bug would go undetected.
QVERIFY2(pTimer->setScript(qsl("uninstallPackage(\"%1\")\nerror(\"boom\")").arg(packageName)), "timer script failed to compile");
pTimer->setIsActive(true);
pTimer->enableTimer();
// Let the timer fire and take its package (and itself) down:
QTRY_VERIFY_WITH_TIMEOUT(!mpHost->mInstalledPackages.contains(packageName), 5000);
// Allow any queued activity (the declined deferred save, further timer
// ticks) to surface problems:
QTest::qWait(500);
// mudlet::slot_timerFires() flushes the deferred delete as soon as the
// uninstalling timer's execute() has finished, so by now the timer must
// be properly gone - not lingering deactivated where the next profile
// save would serialize it back in:
QVERIFY2(!mpHost->getTimerUnit()->findFirstTimer(qsl("selfUninstallTimer")), "uninstalled package timer is still registered");
}
// The trigger route, driven the whole way: the package's own trigger fires,
// uninstalls its package and saves, all inside the pass. Its export is the
// only one here that includes the variables, so it doubles as the check that
// the variable tree can be built from inside a live Lua call frame.
void test_saveFromTriggerScriptDoesNotResurrectItsPackage()
{
const QString packageName = qsl("resurrect-trigger");
mpHost->mInstalledPackages << packageName;
gpMidPassExportHost = mpHost;
gMidPassExportPath = qsl("%1/mid-pass-export.xml").arg(mConfigDir.path());
lua_State* L = mpHost->mLuaInterpreter.getLuaGlobalState();
lua_register(L, "qaExportProfileMidPass", exportProfileMidPass);
QCOMPARE(luaL_dostring(L, "midPassSavedVar = 'saved from inside the pass'"), 0);
mpHost->getLuaInterface()->getVarUnit()->savedVars.insert(qsl("midPassSavedVar"));
auto pGroup = new TTrigger(nullptr, mpHost);
pGroup->setIsFolder(true);
pGroup->registerTrigger();
pGroup->setName(qsl("resurrectTriggerGroup"));
pGroup->mPackageName = packageName;
pGroup->setIsActive(true);
auto pKicker = new TTrigger(pGroup, mpHost);
pKicker->setRegexCodeList({qsl("^resurrect me$")}, {REGEX_PERL});
pKicker->registerTrigger();
QVERIFY2(pKicker->setScript(qsl("uninstallPackage(\"%1\")\nqaExportProfileMidPass()").arg(packageName)), "trigger script failed to compile");
pKicker->setName(qsl("resurrectTriggerKicker"));
pKicker->setIsActive(true);
// a sibling that never runs: the whole group must go, not just the one
// that fired
auto pBystander = new TTrigger(pGroup, mpHost);
pBystander->setRegexCodeList({qsl("^never matched$")}, {REGEX_PERL});
pBystander->registerTrigger();
pBystander->setName(qsl("resurrectTriggerBystander"));
pBystander->setIsActive(true);
QVERIFY2(exportedProfileXml().contains(qsl("resurrectTriggerGroup")), "the trigger group should be in the profile before its package is uninstalled");
mpHost->getTriggerUnit()->processDataStream(qsl("resurrect me"), -1);
QVERIFY2(!mpHost->mInstalledPackages.contains(packageName), "package was not uninstalled");
QVERIFY2(!gMidPassExportedXml.isEmpty(), "the mid-pass export produced nothing to check");
QVERIFY2(!gMidPassExportedXml.contains(qsl("resurrectTriggerGroup")), "a save taken mid-pass wrote the uninstalled package's trigger group back into the profile");
QVERIFY2(!gMidPassExportedXml.contains(qsl("resurrectTriggerKicker")), "a save taken mid-pass wrote the uninstalled package's trigger back into the profile");
QVERIFY2(!gMidPassExportedXml.contains(qsl("resurrectTriggerBystander")), "a save taken mid-pass wrote the uninstalled package's trigger back into the profile");
const QString keeperError = keepersMissingFrom(gMidPassExportedXml);
QVERIFY2(keeperError.isEmpty(), qPrintable(keeperError));
QVERIFY2(gMidPassExportedXml.contains(qsl("saved from inside the pass")), "the variables were not read out of Lua by a save taken from inside a script");
QVERIFY2(mpHost->getTriggerUnit()->findItems(qsl("resurrectTriggerKicker")).empty(), "uninstalled trigger is still registered");
mpHost->getLuaInterface()->getVarUnit()->savedVars.remove(qsl("midPassSavedVar"));
gpMidPassExportHost = nullptr;
}
// The alias route: a package shipping its own "uninstall" alias.
void test_saveFromAliasScriptDoesNotResurrectItsPackage()
{
const QString packageName = qsl("resurrect-alias");
mpHost->mInstalledPackages << packageName;
gpMidPassExportHost = mpHost;
gMidPassExportPath = qsl("%1/mid-pass-alias-export.xml").arg(mConfigDir.path());
lua_register(mpHost->mLuaInterpreter.getLuaGlobalState(), "qaExportProfileMidPass", exportProfileMidPass);
auto pAlias = new TAlias(qsl("resurrectAlias"), mpHost);
pAlias->setRegexCode(qsl("^resurrect me$"));
mpHost->getAliasUnit()->registerAlias(pAlias);
pAlias->mPackageName = packageName;
QVERIFY2(pAlias->setScript(qsl("uninstallPackage(\"%1\")\nqaExportProfileMidPass()").arg(packageName)), "alias script failed to compile");
pAlias->setIsActive(true);
QVERIFY2(exportedProfileXml().contains(qsl("resurrectAlias")), "the alias should be in the profile before its package is uninstalled");
mpHost->getAliasUnit()->processDataStream(qsl("resurrect me"));
QVERIFY2(!mpHost->mInstalledPackages.contains(packageName), "package was not uninstalled");
QVERIFY2(!gMidPassExportedXml.isEmpty(), "the mid-pass export produced nothing to check");
QVERIFY2(!gMidPassExportedXml.contains(qsl("resurrectAlias")), "a save taken mid-pass wrote the uninstalled package's alias back into the profile");
const QString keeperError = keepersMissingFrom(gMidPassExportedXml);
QVERIFY2(keeperError.isEmpty(), qPrintable(keeperError));
QVERIFY2(!mpHost->getAliasUnit()->findFirstAlias(qsl("resurrectAlias")), "uninstalled alias is still registered");
gpMidPassExportHost = nullptr;
}
// The timer route. beginProcessing()/endProcessing() below are the calls
// TTimer::execute() wraps its whole callback in.
void test_saveDuringTimerCallbackDoesNotResurrectItsPackage()
{
const QString packageName = qsl("resurrect-timer");
mpHost->mInstalledPackages << packageName;
auto pTimer = new TTimer(qsl("resurrectTimer"), QTime(0, 0, 30), mpHost);
mpHost->getTimerUnit()->registerTimer(pTimer);
pTimer->mPackageName = packageName;
QVERIFY2(pTimer->setScript(qsl("local noop = true\n")), "timer script failed to compile");
QVERIFY2(exportedProfileXml().contains(qsl("resurrectTimer")), "the timer should be in the profile before its package is uninstalled");
QString xml;
{
mpHost->getTimerUnit()->beginProcessing();
// a failed QVERIFY returns from the slot; a level left on would
// wedge every later test's doCleanup()
const auto depthGuard = qScopeGuard([this]() {
mpHost->getTimerUnit()->endProcessing();
mpHost->getTimerUnit()->doCleanup();
});
QVERIFY(mpHost->uninstallPackage(packageName, enums::PackageModuleType::Package));
xml = exportedProfileXml();
}
QVERIFY2(!xml.isEmpty(), "the export produced nothing to check");
QVERIFY2(!xml.contains(qsl("resurrectTimer")), "a save taken during a timer callback wrote the uninstalled package's timer back into the profile");
const QString keeperError = keepersMissingFrom(xml);
QVERIFY2(keeperError.isEmpty(), qPrintable(keeperError));
QVERIFY2(!mpHost->getTimerUnit()->findFirstTimer(qsl("resurrectTimer")), "uninstalled timer is still registered");
}
// The button route: TAction::execute() holds ActionUnit's depth the same way.
void test_saveDuringButtonScriptDoesNotResurrectItsPackage()
{
const QString packageName = qsl("resurrect-action");
mpHost->mInstalledPackages << packageName;
auto pAction = new TAction(qsl("resurrectAction"), mpHost);
mpHost->getActionUnit()->registerAction(pAction);
pAction->mPackageName = packageName;
QVERIFY2(pAction->setScript(qsl("local noop = true\n")), "button script failed to compile");
QVERIFY2(exportedProfileXml().contains(qsl("resurrectAction")), "the button should be in the profile before its package is uninstalled");
QString xml;
{
mpHost->getActionUnit()->beginProcessing();
const auto depthGuard = qScopeGuard([this]() {
mpHost->getActionUnit()->endProcessing();
mpHost->getActionUnit()->doCleanup();
});
QVERIFY(mpHost->uninstallPackage(packageName, enums::PackageModuleType::Package));
xml = exportedProfileXml();
}
QVERIFY2(!xml.isEmpty(), "the export produced nothing to check");
QVERIFY2(!xml.contains(qsl("resurrectAction")), "a save taken during a button script wrote the uninstalled package's button back into the profile");
const QString keeperError = keepersMissingFrom(xml);
QVERIFY2(keeperError.isEmpty(), qPrintable(keeperError));
QVERIFY2(!mpHost->getActionUnit()->findAction(qsl("resurrectAction")), "uninstalled button is still registered");
}
// The event-handler route: Host::raiseEvent() holds ScriptUnit's depth.
void test_saveDuringEventDispatchDoesNotResurrectItsPackage()
{
const QString packageName = qsl("resurrect-script");
mpHost->mInstalledPackages << packageName;
auto pScript = new TScript(nullptr, mpHost);
mpHost->getScriptUnit()->registerScript(pScript);
pScript->mPackageName = packageName;
pScript->setName(qsl("resurrectScript"));
QVERIFY2(pScript->setScript(qsl("local noop = true\n")), "script failed to compile");
QVERIFY2(exportedProfileXml().contains(qsl("resurrectScript")), "the script should be in the profile before its package is uninstalled");
QString xml;
{
mpHost->getScriptUnit()->beginProcessing();
const auto depthGuard = qScopeGuard([this]() {
mpHost->getScriptUnit()->endProcessing();
mpHost->getScriptUnit()->doCleanup();
});
QVERIFY(mpHost->uninstallPackage(packageName, enums::PackageModuleType::Package));
xml = exportedProfileXml();
}
QVERIFY2(!xml.isEmpty(), "the export produced nothing to check");
QVERIFY2(!xml.contains(qsl("resurrectScript")), "a save taken during an event dispatch wrote the uninstalled package's script back into the profile");
const QString keeperError = keepersMissingFrom(xml);
QVERIFY2(keeperError.isEmpty(), qPrintable(keeperError));
QVERIFY2(mpHost->getScriptUnit()->findItems(qsl("resurrectScript")).empty(), "uninstalled script is still registered");
}
// Host::reloadModule() - reachable from Lua - uninstalls and reinstalls a
// module back to back, so from a script the old items are still registered
// when the new ones arrive.
void test_moduleSaveDuringReloadDoesNotDuplicateItsItems()
{
const QString moduleName = qsl("resurrect-module");
registerModuleAs(moduleName);
QVERIFY2(importModuleTimerNamed(moduleName, qsl("moduleTimerBeforeReload")), "could not import the module's timer");
QVERIFY2(exportedModuleXml(moduleName).contains(qsl("moduleTimerBeforeReload")), "the module's timer should be in its file before the reload");
QString xml;
{
mpHost->getTimerUnit()->beginProcessing();
const auto depthGuard = qScopeGuard([this]() {
mpHost->getTimerUnit()->endProcessing();
mpHost->getTimerUnit()->doCleanup();
});
// the uninstall half: at depth the old timer only gets deactivated
QVERIFY(mpHost->uninstallPackage(moduleName, enums::PackageModuleType::ModuleSync));
// ... and the reinstall half brings the module back with fresh items
registerModuleAs(moduleName);
QVERIFY2(importModuleTimerNamed(moduleName, qsl("moduleTimerAfterReload")), "could not re-import the module's timer");
xml = exportedModuleXml(moduleName);
}
QVERIFY2(!xml.isEmpty(), "the module export produced nothing to check");
QVERIFY2(xml.contains(qsl("moduleTimerAfterReload")), "the reloaded module's timer must be written to its file");
QVERIFY2(!xml.contains(qsl("moduleTimerBeforeReload")), "a module save taken mid-reload wrote the pre-reload copy of the timer back into the module file");
}
private:
// Items of a package that is never uninstalled. Every other assertion here
// is an absence, so without these an over-broad filter passes the whole file
// while emptying the user's profile.
void createKeeperItems()
{
const QString keeperPackage = qsl("keeper-package");
mpHost->mInstalledPackages << keeperPackage;
auto pTrigger = new TTrigger(nullptr, mpHost);
pTrigger->setRegexCodeList({qsl("^never matched$")}, {REGEX_PERL});
pTrigger->registerTrigger();
pTrigger->setName(qsl("keeperTrigger"));
pTrigger->mPackageName = keeperPackage;
auto pAlias = new TAlias(qsl("keeperAlias"), mpHost);
pAlias->setRegexCode(qsl("^never matched$"));
mpHost->getAliasUnit()->registerAlias(pAlias);
pAlias->mPackageName = keeperPackage;
auto pTimer = new TTimer(qsl("keeperTimer"), QTime(0, 0, 30), mpHost);
mpHost->getTimerUnit()->registerTimer(pTimer);
pTimer->mPackageName = keeperPackage;
auto pAction = new TAction(qsl("keeperAction"), mpHost);
mpHost->getActionUnit()->registerAction(pAction);
pAction->mPackageName = keeperPackage;
auto pScript = new TScript(nullptr, mpHost);
mpHost->getScriptUnit()->registerScript(pScript);
pScript->setName(qsl("keeperScript"));
pScript->mPackageName = keeperPackage;
}
QString keepersMissingFrom(const QString& xml) const
{
for (const auto& name : {qsl("keeperTrigger"), qsl("keeperAlias"), qsl("keeperTimer"), qsl("keeperAction"), qsl("keeperScript")}) {
if (!xml.contains(name)) {
return qsl("a save taken while a delete was outstanding dropped \"%1\", which belongs to a package that is still installed").arg(name);
}
}
return {};
}
void registerModuleAs(const QString& moduleName)
{
mpHost->mInstalledModules[moduleName] = QStringList{qsl("%1/%2.xml").arg(mConfigDir.path(), moduleName), qsl("0")};
mpHost->mModulesLoadedOk << moduleName;
}
// The module-member flag is private to XMLimport, so a genuine module item
// can only be made by importing one: that creates the module's master folder
// per unit, and renaming the timer's tells the two copies apart.
bool importModuleTimerNamed(const QString& moduleName, const QString& itemName)
{
const QString path = qsl("%1/%2-import.xml").arg(mConfigDir.path(), itemName);
auto* pSeed = new TTimer(itemName, QTime(0, 0, 30), mpHost);
mpHost->getTimerUnit()->registerTimer(pSeed);
pSeed->setScript(qsl("local noop = true\n"));
const bool exported = XMLexport(pSeed).exportTimer(path);
mpHost->getTimerUnit()->unregisterTimer(pSeed);
delete pSeed;
if (!exported) {
return false;
}
QFile file(path);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
return false;
}
XMLimport importer(mpHost);
const bool imported = importer.importPackage(&file, moduleName, 1).first;
file.close();
QFile::remove(path);
if (!imported) {
return false;
}
TTimer* pTimer = mpHost->getTimerUnit()->findFirstTimer(moduleName);
if (!pTimer) {
return false;
}
pTimer->setName(itemName);
return true;
}
// Builds the document writeModuleXML() produces for a save and reads it back.
QString exportedModuleXml(const QString& moduleName)
{
const QString path = qsl("%1/module-export.xml").arg(mConfigDir.path());
XMLexport writer(mpHost);
writer.writeModuleXML(moduleName);
if (!XMLexport::saveXmlDocToFile(path, *writer.takeExportDocument())) {
return {};
}
return readBack(path);
}
// The writers a profile save uses, without the console Host::saveProfile()
// would need.
QString exportedProfileXml()
{
const QString path = qsl("%1/profile-export.xml").arg(mConfigDir.path());
auto writer = std::make_shared<XMLexport>(mpHost);
if (!writer->exportPackage(path, true, true)) {
return {};
}
return readBack(path);
}
static QString readBack(const QString& path)
{
QFile file(path);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
return {};
}
const QString xml = QString::fromUtf8(file.readAll());
file.close();
QFile::remove(path);
return xml;
}
};
void initializeQRCResourcesForPackageSelfRemovalTest()
{
#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 "PackageSelfRemovalTest.moc"
QTEST_MAIN(PackageSelfRemovalTest)