mudlet/test/functional_tests/ProfileFolderNameTest.cpp
Vadim Peretokin 61c2afd406
Fix an empty XDG config directory hiding every profile (#9712)
#### Brief overview of PR changes/additions

- An empty `$XDG_CONFIG_HOME/mudlet` silently beat a populated
`~/.config/mudlet`, so a stray `mkdir` hid every profile and Mudlet ran
its first-launch onboarding as though the user were new. It also stuck:
the first such launch wrote `Mudlet.ini` into that directory, which then
kept it winning.
- The two candidate roots are now ranked (`profiles/` > `Mudlet.ini` >
exists > absent) and the stronger claim wins, with
`$XDG_CONFIG_HOME/mudlet` taking ties so a fresh install and a
deliberate opt-in both still land there. A directory that cannot be
listed counts as populated rather than empty, so a permission bit cannot
re-enter the bug.
- Creating `profiles/` is now the opt-in a test harness uses; the
`mudlet` directory alone is not, because other tooling creates that by
accident. Where both roots hold profiles, `setupConfig()` names the one
it is ignoring instead of leaving those profiles apparently gone.

#### Motivation for adding to Mudlet

Data-loss-shaped regression from #9552 "improve: honor XDG_CONFIG_HOME
for Mudlet's config directory" (`e6c268cb0`). The profiles are orphaned
rather than destroyed, but a returning user sees "5.0 wiped my
profiles". `src/mudlet-lua/tests/README.md` itself instructed `mkdir -p
"$CONFIG_DIR/mudlet"`, so following Mudlet's own test docs triggered it.

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

Test case: create `~/.config/mudlet/profiles/{AlphaGame,BetaGame}`,
`mkdir -p $XDG_CONFIG_HOME/mudlet`, launch. Before: no profiles and the
onboarding dialog. After: both profiles listed.

`ConfigDirOverrideTest` covers the resolution table including the sticky
`Mudlet.ini` state, both-populated, symlinked and unreadable
directories; each new guard was mutation-checked. The busted suite
passes 2422/0 against an isolated `$XDG_CONFIG_HOME/mudlet/profiles`
root.

Not fixed here, and pre-existing rather than 5.0 regressions:
`CredentialManager` stores passwords and the OAuth reconnect token under
`AppConfigLocation` while the config root is `confPath`, so exporting
`XDG_CONFIG_HOME` strands them, and the plaintext-password migration
reads one path, writes the other and deletes the original. Both
reproduce identically on the 4.22.0 binary and need their own migration
path.

Assisted-by: Claude:claude-opus-5
2026-08-10 22:17:09 +02:00

221 lines
9.2 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. *
***************************************************************************/
/*
* Drives the real connection dialog against profile folders created outside
* of Mudlet, the way a file manager makes them (e.g. copying a profile to
* "test (2)"). Guards the on-disk exemption in
* dlgConnectionProfiles::validateProfile(): selecting such a folder must keep
* its name intact (no silent character stripping, which used to rename the
* folder on disk and lose its stored password) and must leave the
* Connect/Offline buttons enabled - while a freshly typed name must still
* have disallowed characters filtered out.
*
* Run with: ctest -R ProfileFolderNameTest -V
*/
#include "MudletInstanceCoordinator.h"
#include "dlgConnectionProfiles.h"
#include "mudlet.h"
#include <QtTest/QtTest>
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();
static void initializeQRCResources()
{
#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();
}
class ProfileFolderNameTest : public QObject
{
Q_OBJECT
private:
QTemporaryDir mXdgDir;
QByteArray mSavedXdg;
// The name a file manager typically produces when copying a folder; every
// character is in the allowed set now that parentheses are permitted:
const QString mCopiedName = qsl("test (2)");
// Contains a character that is NOT in the allowed set, so only the
// on-disk exemption lets it through unmangled:
const QString mForeignName = qsl("café");
// Trailing whitespace: the entered name arrives trimmed, so the exemption
// has to match against the trimmed folder name to cover this one:
const QString mPaddedName = qsl("padded café ");
// setupConfig() consults portable.txt before 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 makeExternalProfileFolder(const QString& name) const
{
QVERIFY(QDir().mkpath(mudlet::getMudletPath(enums::profileHomePath, name)));
// A folder copied by a file manager carries the original's connection
// data files with it:
QVERIFY(mudlet::self()->writeProfileData(name, qsl("url"), qsl("mudlet.org")).first);
QVERIFY(mudlet::self()->writeProfileData(name, qsl("port"), qsl("23")).first);
}
// The Connect and Offline buttons are the only AcceptRole buttons in the
// dialog's button box (they are private members of the dialog itself):
QList<QAbstractButton*> acceptButtons(dlgConnectionProfiles* dialog) const
{
QList<QAbstractButton*> buttons;
for (auto* button : dialog->dialog_buttonbox->buttons()) {
if (dialog->dialog_buttonbox->buttonRole(button) == QDialogButtonBox::AcceptRole) {
buttons << button;
}
}
return buttons;
}
dlgConnectionProfiles* selectProfile(const QString& name)
{
auto* dialog = mudlet::self()->mpConnectionDialog.data();
if (!dialog) {
return nullptr;
}
const auto items = dialog->findData(*dialog->listWidget_profiles, name, dlgConnectionProfiles::csmNameRole);
if (items.isEmpty()) {
return nullptr;
}
dialog->listWidget_profiles->setCurrentItem(items.first());
dialog->slot_itemClicked(items.first());
return dialog;
}
private slots:
void initTestCase()
{
if (portableMarkerPresent()) {
QSKIP("portable.txt present - cannot redirect the config dir for this test");
}
initializeQRCResources();
mSavedXdg = qgetenv("XDG_CONFIG_HOME");
QVERIFY(mXdgDir.isValid());
QVERIFY(QDir().mkpath(qsl("%1/mudlet/profiles").arg(mXdgDir.path()))); // profiles/ = XDG opt-in
qputenv("XDG_CONFIG_HOME", mXdgDir.path().toUtf8());
mudlet::start();
mudlet::self()->setupConfig();
// never touch the user's real profiles:
QVERIFY(mudlet::getMudletPath(enums::profilesPath).startsWith(mXdgDir.path()));
mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator"));
mudlet::self()->init();
mudlet::self()->setStorePasswordsSecurely(false);
makeExternalProfileFolder(mCopiedName);
makeExternalProfileFolder(mForeignName);
makeExternalProfileFolder(mPaddedName);
mudlet::self()->startAutoLogin({});
QVERIFY(QTest::qWaitFor(
[]() {
return mudlet::self()->mpConnectionDialog != nullptr;
},
5000));
}
void cleanupTestCase()
{
mSavedXdg.isNull() ? qunsetenv("XDG_CONFIG_HOME") : qputenv("XDG_CONFIG_HOME", mSavedXdg);
delete mudlet::self();
}
void test_copiedFolderWithParenthesesIsUsable()
{
auto* dialog = selectProfile(mCopiedName);
QVERIFY2(dialog, "profile folder with parentheses was not listed in the dialog");
QCOMPARE(dialog->profile_name_entry->text(), mCopiedName);
const auto buttons = acceptButtons(dialog);
QCOMPARE(buttons.size(), 2);
for (auto* button : buttons) {
QVERIFY2(button->isEnabled(), qPrintable(qsl("'%1' button is disabled for profile '%2'").arg(button->text(), mCopiedName)));
}
// the folder must not have been renamed behind the user's back:
QVERIFY(QDir(mudlet::getMudletPath(enums::profileHomePath, mCopiedName)).exists());
}
void test_folderWithDisallowedCharacterIsNotMangled()
{
auto* dialog = selectProfile(mForeignName);
QVERIFY2(dialog, "profile folder with a disallowed character was not listed in the dialog");
QCOMPARE(dialog->profile_name_entry->text(), mForeignName);
const auto buttons = acceptButtons(dialog);
QCOMPARE(buttons.size(), 2);
for (auto* button : buttons) {
QVERIFY2(button->isEnabled(), qPrintable(qsl("'%1' button is disabled for profile '%2'").arg(button->text(), mForeignName)));
}
QVERIFY(QDir(mudlet::getMudletPath(enums::profileHomePath, mForeignName)).exists());
}
void test_folderWithTrailingWhitespaceIsNotMangled()
{
auto* dialog = selectProfile(mPaddedName);
QVERIFY2(dialog, "profile folder with trailing whitespace was not listed in the dialog");
QCOMPARE(dialog->profile_name_entry->text(), mPaddedName);
const auto buttons = acceptButtons(dialog);
QCOMPARE(buttons.size(), 2);
for (auto* button : buttons) {
QVERIFY2(button->isEnabled(), qPrintable(qsl("'%1' button is disabled for profile '%2'").arg(button->text(), mPaddedName)));
}
QVERIFY(QDir(mudlet::getMudletPath(enums::profileHomePath, mPaddedName)).exists());
}
// The exemption must not disable validation of names the user types:
void test_editedNameIsStillValidated()
{
auto* dialog = selectProfile(mCopiedName);
QVERIFY(dialog);
// setText() drives the same textChanged path as typing does
dialog->profile_name_entry->setText(qsl("test |2"));
QVERIFY(!dialog->profile_name_entry->text().contains(QLatin1Char('|')));
// restore the on-disk selection so no rename can be left pending
dialog->profile_name_entry->setText(mCopiedName);
QCOMPARE(dialog->profile_name_entry->text(), mCopiedName);
}
};
QTEST_MAIN(ProfileFolderNameTest)
#include "ProfileFolderNameTest.moc"