mudlet/test/functional_tests/ProfileDeletionSafetyTest.cpp
Vadim Peretokin ed37c8eec2
infrastructure: fix two tests still opting in with an empty XDG config dir (#9810)
#### Brief overview of PR changes/additions

- `61c2afd40` (#9712) made `$XDG_CONFIG_HOME/mudlet/profiles` the opt-in
marker for an isolated config root, and an empty
`$XDG_CONFIG_HOME/mudlet` no longer qualifies. It updated three
functional tests to the new recipe and missed two, so
`ProfileDeletionSafetyTest` and `ConnectionDialogCrashTest` resolved to
the real `~/.config/mudlet` and failed in `initTestCase()` on every leg
(run 31428353403). Every open PR inherits that red, because PR builds
merge the dev tip.
- Both now pre-create `mudlet/profiles`, matching the ten sibling tests
and `src/mudlet-lua/tests/README.md`. Product code is untouched.
- An empty `profiles/` still reads as a fresh install
(`anyProfilesExist()` counts subdirectories), so neither test's
first-launch expectations move.

Test case: `ctest -R
"ProfileDeletionSafetyTest|ConnectionDialogCrashTest"` against a
`~/.config/mudlet` that holds profiles - both fail on `development`,
both pass here; the full functional suite is otherwise unchanged.

Assisted-by: Claude:claude-opus-5
2026-08-11 01:50:14 +02:00

524 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. *
***************************************************************************/
/*
* Removing a profile from the connection dialog must never reach outside that
* one profile's folder. A name like "." addresses the profiles directory
* itself and ".." the whole Mudlet configuration directory, so a name that is
* not a folder of its own must never reach removeRecursively(). Also covers
* the confirmation the user gets before any of their data goes, and the names
* that must keep working.
*
* Run with: ctest -R ProfileDeletionSafetyTest -V
*/
#include <QtTest/QtTest>
#include "MudletInstanceCoordinator.h"
#include "dlgConnectionProfiles.h"
#include "mudlet.h"
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 initializeQRCResourcesForProfileDeletionSafetyTest();
class ProfileDeletionSafetyTest : public QObject
{
Q_OBJECT
private:
QTemporaryDir mConfigDir;
QByteArray mSavedXdg;
const QString mKeeper = qsl("QA Keeper");
QString profilePath(const QString& profile) const { return mudlet::getMudletPath(enums::profileHomePath, profile); }
// setupConfig() consults portable.txt ahead of the XDG logic; skip rather
// than run against an unexpected config dir (see ConfigDirOverrideTest)
bool portableMarkerPresent() const
{
return QFileInfo::exists(qsl("%1/portable.txt").arg(QCoreApplication::applicationDirPath())) || QFileInfo::exists(qsl("%1/.config/mudlet/portable.txt").arg(QDir::homePath()));
}
void makeProfileWithSavedGame(const QString& profile) const
{
QVERIFY(QDir().mkpath(mudlet::getMudletPath(enums::profileXmlFilesPath, profile)));
QFile savedGame(qsl("%1/%2.xml").arg(mudlet::getMudletPath(enums::profileXmlFilesPath, profile), profile));
QVERIFY(savedGame.open(QIODevice::WriteOnly));
savedGame.write("<MudletPackage></MudletPackage>");
savedGame.close();
}
// The list takes ownership, and rebuilding it drops the entry again
void addListEntry(dlgConnectionProfiles* dlg, const QString& profile) const
{
auto* item = new QListWidgetItem();
item->setData(dlgConnectionProfiles::csmNameRole, profile);
dlg->listWidget_profiles->insertItem(0, item);
dlg->listWidget_profiles->setCurrentItem(item);
}
// slot_itemClicked() ignores a repeat of the profile it was last given
// within 100ms, and that guard is static, so it outlives the dialog
void selectProfile(dlgConnectionProfiles* dlg, const QString& profile) const
{
const auto items = dlg->findData(*dlg->listWidget_profiles, profile, dlgConnectionProfiles::csmNameRole);
QVERIFY2(!items.isEmpty(), qPrintable(qsl("profile '%1' was not listed").arg(profile)));
dlg->listWidget_profiles->setCurrentItem(items.first());
QTest::qWait(120);
dlg->slot_itemClicked(items.first());
}
QDialog* confirmation(dlgConnectionProfiles* dlg) const { return dlg->findChild<QDialog*>(qsl("delete_profile_confirmation")); }
// The .ui wires the delete button's clicked() to the dialog's accept()
void confirmRemovalOf(dlgConnectionProfiles* dlg, const QString& profile) const
{
auto* confirmationDialog = confirmation(dlg);
QVERIFY(confirmationDialog);
auto* nameEntry = confirmationDialog->findChild<QLineEdit*>(qsl("delete_profile_lineedit"));
auto* deleteButton = confirmationDialog->findChild<QPushButton*>(qsl("delete_button"));
QVERIFY(nameEntry && deleteButton);
nameEntry->setText(profile.left(profile.size() - 1));
QVERIFY2(!deleteButton->isEnabled(), "a partial profile name enabled the delete button");
nameEntry->setText(profile);
QVERIFY2(deleteButton->isEnabled(), "typing the profile name did not enable this confirmation's delete button");
deleteButton->click();
}
void removeProfileAndConfirm(dlgConnectionProfiles* dlg, const QString& profile) const
{
dlg->slot_deleteProfile();
if (confirmation(dlg)) {
confirmRemovalOf(dlg, profile);
}
}
dlgConnectionProfiles* openDialog() const
{
auto* dlg = new dlgConnectionProfiles();
dlg->show();
dlg->fillout_form();
return dlg;
}
void closeDialog(dlgConnectionProfiles* dlg) const
{
dlg->deleteLater();
QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete);
}
// The Connect and Offline buttons are the dialog's only AcceptRole buttons
bool acceptButtonsEnabled(dlgConnectionProfiles* dlg) const
{
for (auto* button : dlg->dialog_buttonbox->buttons()) {
if (dlg->dialog_buttonbox->buttonRole(button) == QDialogButtonBox::AcceptRole && !button->isEnabled()) {
return false;
}
}
return true;
}
private slots:
void initTestCase()
{
if (portableMarkerPresent()) {
QSKIP("portable.txt present - cannot redirect the config dir for this test");
}
initializeQRCResourcesForProfileDeletionSafetyTest();
QVERIFY(mConfigDir.isValid());
// $XDG_CONFIG_HOME/mudlet/profiles is the opt-in that makes setupConfig()
// adopt it, so the test never goes near the user's own profiles
QVERIFY(QDir().mkpath(qsl("%1/mudlet/profiles").arg(mConfigDir.path())));
mSavedXdg = qgetenv("XDG_CONFIG_HOME");
qputenv("XDG_CONFIG_HOME", mConfigDir.path().toUtf8());
mudlet::start();
mudlet::self()->setupConfig();
QCOMPARE(mudlet::getMudletPath(enums::mainPath), qsl("%1/mudlet").arg(mConfigDir.path()));
mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator"));
mudlet::self()->init();
mudlet::self()->setStorePasswordsSecurely(false);
makeProfileWithSavedGame(mKeeper);
mudlet::self()->writeProfileData(mKeeper, qsl("url"), qsl("mudlet.org"));
mudlet::self()->writeProfileData(mKeeper, qsl("port"), qsl("23"));
}
void cleanupTestCase()
{
delete mudlet::self();
mSavedXdg.isNull() ? qunsetenv("XDG_CONFIG_HOME") : qputenv("XDG_CONFIG_HOME", mSavedXdg);
}
void test_removingCurrentDirectoryProfileKeepsEveryProfile()
{
auto* dlg = openDialog();
addListEntry(dlg, qsl("."));
dlg->slot_deleteProfile();
QVERIFY2(confirmation(dlg), "removal went ahead without asking");
confirmRemovalOf(dlg, qsl("."));
QVERIFY2(QDir(mudlet::getMudletPath(enums::profilesPath)).exists(), "the profiles directory was deleted");
QVERIFY2(QDir(profilePath(mKeeper)).exists(), "an unrelated profile was deleted");
QVERIFY2(QFile::exists(qsl("%1/%2.xml").arg(mudlet::getMudletPath(enums::profileXmlFilesPath, mKeeper), mKeeper)), "an unrelated profile's saved game was deleted");
QVERIFY2(!dlg->notificationAreaMessageBox->text().isEmpty(), "the refusal was not reported to the user");
closeDialog(dlg);
}
void test_removingParentDirectoryProfileKeepsTheConfigurationDirectory()
{
auto* dlg = openDialog();
addListEntry(dlg, qsl(".."));
dlg->slot_deleteProfile();
QVERIFY2(confirmation(dlg), "removal went ahead without asking");
confirmRemovalOf(dlg, qsl(".."));
QVERIFY2(QDir(mudlet::getMudletPath(enums::mainPath)).exists(), "Mudlet's configuration directory was deleted");
QVERIFY2(QDir(mudlet::getMudletPath(enums::profilesPath)).exists(), "the profiles directory was deleted");
QVERIFY2(QDir(profilePath(mKeeper)).exists(), "an unrelated profile was deleted");
QVERIFY2(!dlg->notificationAreaMessageBox->text().isEmpty(), "the refusal was not reported to the user");
closeDialog(dlg);
}
void test_onlyDirectChildrenOfTheProfilesDirectoryAreProfiles()
{
const QString profilesPath = mudlet::getMudletPath(enums::profilesPath);
QCOMPARE(dlgConnectionProfiles::profileFolderPath(profilesPath, mKeeper), qsl("%1/%2").arg(profilesPath, mKeeper));
QVERIFY(dlgConnectionProfiles::profileFolderPath(profilesPath, qsl(".")).isEmpty());
QVERIFY(dlgConnectionProfiles::profileFolderPath(profilesPath, qsl("..")).isEmpty());
QVERIFY(dlgConnectionProfiles::profileFolderPath(profilesPath, qsl("%1/current").arg(mKeeper)).isEmpty());
// a predefined game has no folder until it is saved
const QString neverPlayed = qsl("QA Never Played");
QVERIFY(!QDir(profilePath(neverPlayed)).exists());
QCOMPARE(dlgConnectionProfiles::profileFolderPath(profilesPath, neverPlayed), qsl("%1/%2").arg(profilesPath, neverPlayed));
}
void test_profileWithOnlyAMapIsConfirmedBeforeRemoval()
{
const QString mapped = qsl("QA Mapped");
QVERIFY(QDir().mkpath(mudlet::getMudletPath(enums::profileMapsPath, mapped)));
QVERIFY(!QDir(mudlet::getMudletPath(enums::profileXmlFilesPath, mapped)).exists());
auto* dlg = openDialog();
selectProfile(dlg, mapped);
dlg->slot_deleteProfile();
QVERIFY2(confirmation(dlg), "removal went ahead without asking");
QVERIFY2(QDir(profilePath(mapped)).exists(), "the profile was deleted before the user confirmed");
confirmation(dlg)->reject();
QVERIFY2(QDir(profilePath(mapped)).exists(), "the profile was deleted after the user cancelled");
closeDialog(dlg);
}
void test_profileWithOnlyConnectionDetailsIsRemovedWithoutConfirmation()
{
const QString unplayed = qsl("QA Unplayed");
QVERIFY(QDir().mkpath(profilePath(unplayed)));
mudlet::self()->writeProfileData(unplayed, qsl("url"), qsl("mudlet.org"));
mudlet::self()->writeProfileData(unplayed, qsl("port"), qsl("23"));
auto* dlg = openDialog();
selectProfile(dlg, unplayed);
dlg->slot_deleteProfile();
QVERIFY2(!confirmation(dlg), "a profile with nothing but connection details should not need confirming");
QVERIFY2(!QDir(profilePath(unplayed)).exists(), "the profile was not removed");
closeDialog(dlg);
}
// test_profileWithOnlyConnectionDetailsIsRemovedWithoutConfirmation only
// holds while dlgConnectionProfiles::scmConnectionDetailFiles still covers
// what the connection form writes, so fill one in the way a user does and
// hold what lands on disk against that list
void test_newProfileOnlyWritesListedConnectionDetails()
{
const QString unplayed = qsl("QA Just Set Up");
QVERIFY(!QDir(profilePath(unplayed)).exists());
auto* dlg = openDialog();
dlg->slot_addProfile();
dlg->profile_name_entry->setText(unplayed);
// what leaving the name field does, and what creates the profile's
// folder - it only gets that far synchronously because initTestCase()
// turned secure password storage off, else it waits on the keychain
dlg->slot_saveName();
QVERIFY2(QDir(profilePath(unplayed)).exists(), "naming a new profile did not create its folder");
// the rest of the connection form. The character name and the password
// are left out on purpose: they are the user's own, so a profile
// holding either is still confirmed - see the two cases below
dlg->host_name_entry->setText(qsl("mudlet.org"));
dlg->port_entry->setText(qsl("23"));
dlg->port_ssl_tsl->setChecked(true);
dlg->autologin_checkBox->setChecked(true);
dlg->auto_reconnect->setChecked(true);
dlg->mud_description_textedit->setPlainText(qsl("a game to try later"));
// refilling the form by selecting the profile writes through the same
// field signals, so the selection path is covered as well
selectProfile(dlg, unplayed);
const QStringList written = QDir(profilePath(unplayed)).entryList(QDir::Files | QDir::Hidden);
// a field that stops saving would otherwise quietly shrink what the
// check below covers, while still passing it
for (const QString& expected : {qsl("url"), qsl("port"), qsl("ssl_tsl"), qsl("autologin"), qsl("autoreconnect"), qsl("description")}) {
QVERIFY2(written.contains(expected), qPrintable(qsl("filling the form in no longer writes '%1', so this case has stopped covering it").arg(expected)));
}
for (const QString& fileName : written) {
QVERIFY2(dlgConnectionProfiles::scmConnectionDetailFiles.contains(fileName),
qPrintable(qsl("setting a profile up wrote '%1', which dlgConnectionProfiles::scmConnectionDetailFiles does not list - add it there, or "
"reconsider removing such a profile without confirmation")
.arg(fileName)));
}
dlg->slot_deleteProfile();
QVERIFY2(!confirmation(dlg), "a profile that was only ever set up should not need confirming");
QVERIFY2(!QDir(profilePath(unplayed)).exists(), "the profile was not removed");
closeDialog(dlg);
}
// A stored password sits loose in the folder rather than in a sub-directory
void test_profileWithOnlyAStoredPasswordIsConfirmedBeforeRemoval()
{
const QString secretive = qsl("QA Secretive");
QVERIFY(QDir().mkpath(profilePath(secretive)));
mudlet::self()->writeProfileData(secretive, qsl("password"), qsl("hunter2"));
QVERIFY(QDir(profilePath(secretive)).entryList(QDir::Dirs | QDir::Hidden | QDir::NoDotAndDotDot).isEmpty());
auto* dlg = openDialog();
selectProfile(dlg, secretive);
dlg->slot_deleteProfile();
QVERIFY2(confirmation(dlg), "a profile holding a stored password was removed without asking");
QVERIFY2(QDir(profilePath(secretive)).exists(), "the profile was deleted before the user confirmed");
confirmation(dlg)->reject();
closeDialog(dlg);
}
// The character name typed into the login field is the user's own text
// rather than one of the connection details the shortcut waves through
void test_profileWithACharacterNameIsConfirmedBeforeRemoval()
{
const QString named = qsl("QA Named");
QVERIFY(QDir().mkpath(profilePath(named)));
mudlet::self()->writeProfileData(named, qsl("url"), qsl("mudlet.org"));
mudlet::self()->writeProfileData(named, qsl("login"), qsl("Aurelius"));
auto* dlg = openDialog();
selectProfile(dlg, named);
dlg->slot_deleteProfile();
QVERIFY2(confirmation(dlg), "a profile holding a character name was removed without asking");
QVERIFY2(QDir(profilePath(named)).exists(), "the profile was deleted before the user confirmed");
confirmation(dlg)->reject();
closeDialog(dlg);
}
// Nothing stops a second confirmation being raised over the first
void test_eachConfirmationRemovesItsOwnProfile()
{
const QString first = qsl("QA First");
const QString second = qsl("QA Second");
makeProfileWithSavedGame(first);
makeProfileWithSavedGame(second);
auto* dlg = openDialog();
selectProfile(dlg, first);
dlg->slot_deleteProfile();
auto* firstConfirmation = confirmation(dlg);
QVERIFY(firstConfirmation);
selectProfile(dlg, second);
dlg->slot_deleteProfile();
const auto confirmations = dlg->findChildren<QDialog*>(qsl("delete_profile_confirmation"));
QCOMPARE(confirmations.size(), 2);
auto* secondConfirmation = confirmations.first() == firstConfirmation ? confirmations.last() : confirmations.first();
auto* nameEntry = firstConfirmation->findChild<QLineEdit*>(qsl("delete_profile_lineedit"));
auto* deleteButton = firstConfirmation->findChild<QPushButton*>(qsl("delete_button"));
QVERIFY(nameEntry && deleteButton);
nameEntry->setText(first);
QVERIFY2(deleteButton->isEnabled(), "the first confirmation did not accept its own profile name");
deleteButton->click();
QVERIFY2(!QDir(profilePath(first)).exists(), "the first confirmation did not remove its own profile");
QVERIFY2(QDir(profilePath(second)).exists(), "the first confirmation removed the second profile instead");
secondConfirmation->reject();
closeDialog(dlg);
}
void test_profileWithDataIsStillRemovable()
{
const QString doomed = qsl("QA Doomed");
makeProfileWithSavedGame(doomed);
auto* dlg = openDialog();
selectProfile(dlg, doomed);
dlg->slot_deleteProfile();
QVERIFY2(confirmation(dlg), "removal of a profile with data went ahead without asking");
QVERIFY2(QDir(profilePath(doomed)).exists(), "the profile was deleted before the user confirmed");
confirmRemovalOf(dlg, doomed);
QVERIFY2(!QDir(profilePath(doomed)).exists(), "the confirmed profile was not removed");
QVERIFY2(QDir(profilePath(mKeeper)).exists(), "an unrelated profile was removed too");
closeDialog(dlg);
}
// A name Mudlet would turn down as a new profile is still a profile on disk
void test_nonAsciiProfileIsStillRemovable()
{
const QString doomed = qsl("Мудлет café");
makeProfileWithSavedGame(doomed);
auto* dlg = openDialog();
selectProfile(dlg, doomed);
removeProfileAndConfirm(dlg, doomed);
QVERIFY2(!QDir(profilePath(doomed)).exists(), "the confirmed profile was not removed");
QVERIFY2(QDir(profilePath(mKeeper)).exists(), "an unrelated profile was removed too");
closeDialog(dlg);
}
// The confirmation is not modal, so the selection can move on behind it
void test_confirmationRemovesTheProfileItNamed()
{
const QString doomed = qsl("QA Doomed Too");
makeProfileWithSavedGame(doomed);
auto* dlg = openDialog();
selectProfile(dlg, doomed);
dlg->slot_deleteProfile();
QVERIFY(confirmation(dlg));
selectProfile(dlg, mKeeper);
confirmRemovalOf(dlg, doomed);
QVERIFY2(QDir(profilePath(mKeeper)).exists(), "the newly selected profile was removed instead");
QVERIFY2(!QDir(profilePath(doomed)).exists(), "the confirmed profile was not removed");
closeDialog(dlg);
}
void test_typedNamesThatAreNotProfilesAreRejected_data()
{
QTest::addColumn<QString>("name");
QTest::newRow("current directory") << qsl(".");
QTest::newRow("parent directory") << qsl("..");
QTest::newRow("embedded parent directory") << qsl("Achaea..Beta");
}
void test_typedNamesThatAreNotProfilesAreRejected()
{
QFETCH(QString, name);
auto* dlg = openDialog();
selectProfile(dlg, mKeeper);
// else the assertion below also holds for an unrelated reason
QVERIFY2(acceptButtonsEnabled(dlg), "the profile was already unusable before the name was edited");
// setText() drives the same textChanged path as typing does
dlg->profile_name_entry->setText(name);
QVERIFY2(!acceptButtonsEnabled(dlg), qPrintable(qsl("'%1' was accepted as a profile name").arg(name)));
QVERIFY(QDir(profilePath(mKeeper)).exists());
dlg->profile_name_entry->setText(mKeeper);
closeDialog(dlg);
}
// A folder on disk is the user's data whatever it is called
void test_folderOnDiskWithATurnedDownNameIsStillUsable()
{
const QString awkward = qsl("QA..Dots");
QVERIFY(!dlgConnectionProfiles::profileNameUsableAsIs(awkward));
makeProfileWithSavedGame(awkward);
mudlet::self()->writeProfileData(awkward, qsl("url"), qsl("mudlet.org"));
mudlet::self()->writeProfileData(awkward, qsl("port"), qsl("23"));
auto* dlg = openDialog();
selectProfile(dlg, awkward);
QCOMPARE(dlg->profile_name_entry->text(), awkward);
QVERIFY2(acceptButtonsEnabled(dlg), "a profile folder already on disk was refused");
QVERIFY2(QDir(profilePath(awkward)).exists(), "the folder was renamed behind the user's back");
closeDialog(dlg);
}
void test_typedNamesThatAreProfilesAreAccepted_data()
{
QTest::addColumn<QString>("name");
QTest::newRow("version number") << qsl("QA Game 2.0");
QTest::newRow("parentheses") << qsl("QA Keeper (2)");
}
void test_typedNamesThatAreProfilesAreAccepted()
{
QFETCH(QString, name);
auto* dlg = openDialog();
selectProfile(dlg, mKeeper);
dlg->profile_name_entry->setText(name);
QCOMPARE(dlg->profile_name_entry->text(), name);
QVERIFY2(acceptButtonsEnabled(dlg), qPrintable(qsl("'%1' was refused as a profile name").arg(name)));
// ~QDialog fires editingFinished() into slot_saveName(), which renames
dlg->profile_name_entry->setText(mKeeper);
closeDialog(dlg);
}
};
void initializeQRCResourcesForProfileDeletionSafetyTest()
{
#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 "ProfileDeletionSafetyTest.moc"
QTEST_MAIN(ProfileDeletionSafetyTest)