mudlet/test/PasswordMigrationTest.cpp
Mike Conley fa61c537a7
Fix: Restore password loading from portable file in connection dialog (#8993)
#### Brief overview of PR changes/additions

Restores the ability for the connection dialog to load saved passwords
from the portable password file on disk, which stopped working after PR
#7956 introduced secure credential management.

#### Motivation for adding to Mudlet

After #7956, users who had passwords saved in the old portable file
format (`~/.config/mudlet/profiles/<name>/password`) could no longer see
their passwords pre-filled when selecting a profile in the connection
dialog. This affected users who had not yet migrated to the system
keychain.

The fix ensures all three password sources are checked in order: system
keychain, QSettings, and finally the portable file on disk. It also
moves the password migration step to run before the connection dialog
opens, so passwords are migrated in time to be found.

Includes the profile password collision fix from #8948 and adds new unit
tests for the password migration and fallback paths.

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

Reported by @SlySven — PR #7956 (commit 79ab7be4) broke password loading
for profiles that only had passwords in the portable file.

---------

Co-authored-by: Stephen Lyons <slysven@virginmedia.com>
2026-03-08 14:38:15 +01:00

241 lines
9.8 KiB
C++

/***************************************************************************
* Copyright (C) 2025-2026 by Mike Conley - mike.conley@stickmud.com *
* *
* 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. *
***************************************************************************/
// Tests for the portable password file → secure storage migration path.
//
// PR #7956 introduced a regression where the connection-profiles dialog
// stopped consulting the portable password file on disk when neither the
// system keychain nor QSettings contained a password for the selected
// profile. These tests verify that:
//
// 1. A password stored in the old portable file format (QDataStream) can
// be read back correctly.
// 2. The CredentialManager correctly stores and retrieves passwords that
// have been migrated from the portable format.
// 3. If the CredentialManager has no password, the portable file is still
// a valid source of truth (simulating the fallback path).
#include <CredentialManager.h>
#include <QDataStream>
#include <QDir>
#include <QSaveFile>
#include <QTemporaryDir>
#include <QtTest/QtTest>
class PasswordMigrationTest : public QObject {
Q_OBJECT
private:
QTemporaryDir mTempDir;
// Mirrors the portable password file write logic from
// dlgConnectionProfiles::writeProfileData / mudlet::writeProfileData
bool writePortablePasswordFile(const QString &profileDir, const QString &item,
const QString &password) {
const QString filePath = QStringLiteral("%1/%2").arg(profileDir, item);
QSaveFile file(filePath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Unbuffered)) {
return false;
}
QDataStream ofs(&file);
// Match the version used by Mudlet for Qt >= 5.13
ofs.setVersion(QDataStream::Qt_5_12);
ofs << password;
return file.commit();
}
// Mirrors the portable password file read logic from
// dlgConnectionProfiles::readProfileData / mudlet::readProfileData
QString readPortablePasswordFile(const QString &profileDir,
const QString &item) {
const QString filePath = QStringLiteral("%1/%2").arg(profileDir, item);
QFile file(filePath);
if (!file.open(QIODevice::ReadOnly)) {
return QString();
}
QDataStream ifs(&file);
ifs.setVersion(QDataStream::Qt_5_12);
QString ret;
ifs >> ret;
file.close();
return ret;
}
private slots:
void initTestCase() {
qputenv("MUDLET_TEST_MODE", "1");
QVERIFY(mTempDir.isValid());
}
// Verify the portable file round-trip: write → read is lossless
void testPortableFileRoundTrip() {
const QString profile = QStringLiteral("RoundTripProfile");
const QString profileDir =
QStringLiteral("%1/%2").arg(mTempDir.path(), profile);
QVERIFY(QDir().mkpath(profileDir));
const QString password = QStringLiteral("s3cret!P@ss");
QVERIFY(writePortablePasswordFile(profileDir, QStringLiteral("password"),
password));
const QString retrieved =
readPortablePasswordFile(profileDir, QStringLiteral("password"));
QCOMPARE(retrieved, password);
}
// Verify empty file → empty string (no crash, no garbage)
void testPortableFileEmptyPassword() {
const QString profile = QStringLiteral("EmptyPassProfile");
const QString profileDir =
QStringLiteral("%1/%2").arg(mTempDir.path(), profile);
QVERIFY(QDir().mkpath(profileDir));
// Write an empty password
QVERIFY(writePortablePasswordFile(profileDir, QStringLiteral("password"),
QString()));
const QString retrieved =
readPortablePasswordFile(profileDir, QStringLiteral("password"));
QVERIFY(retrieved.isEmpty());
}
// Verify no file → empty string (graceful fallback)
void testPortableFileMissing() {
const QString profile = QStringLiteral("MissingPassProfile");
const QString profileDir =
QStringLiteral("%1/%2").arg(mTempDir.path(), profile);
QVERIFY(QDir().mkpath(profileDir));
// Don't create a password file
const QString retrieved =
readPortablePasswordFile(profileDir, QStringLiteral("password"));
QVERIFY(retrieved.isEmpty());
}
// Simulate the migration path: portable file exists, CredentialManager
// is empty, migration reads portable file and stores into CredentialManager.
void testMigrationPattern() {
const QString profile = QStringLiteral("MigrationTestProfile");
const QString profileDir =
QStringLiteral("%1/%2").arg(mTempDir.path(), profile);
QVERIFY(QDir().mkpath(profileDir));
const QString password = QStringLiteral("migr@t1on_P@ss");
// Step 1: CredentialManager has nothing for this profile
const QString beforeMigration = CredentialManager::retrieveCredential(
profile, QStringLiteral("character"));
QVERIFY(beforeMigration.isEmpty());
// Step 2: Write password to the portable file (simulating legacy state)
QVERIFY(writePortablePasswordFile(profileDir, QStringLiteral("password"),
password));
// Step 3: Read from portable file (simulating what migration does)
const QString portablePassword =
readPortablePasswordFile(profileDir, QStringLiteral("password"));
QCOMPARE(portablePassword, password);
// Step 4: Store into CredentialManager (simulating
// migratePasswordsToSecureStorage)
QVERIFY(CredentialManager::storeCredential(
profile, QStringLiteral("character"), portablePassword));
// Step 5: Now CredentialManager has it
const QString afterMigration = CredentialManager::retrieveCredential(
profile, QStringLiteral("character"));
QCOMPARE(afterMigration, password);
// Cleanup
CredentialManager::removeCredential(profile, QStringLiteral("character"));
}
// The core regression scenario: CredentialManager is empty, QSettings is
// empty, but the portable file exists. The fallback should find it.
void testFallbackToPortableFile() {
const QString profile = QStringLiteral("FallbackTestProfile");
const QString profileDir =
QStringLiteral("%1/%2").arg(mTempDir.path(), profile);
QVERIFY(QDir().mkpath(profileDir));
const QString password = QStringLiteral("f@llb@ck_P@ss!");
// Credential manager has nothing
QVERIFY(CredentialManager::retrieveCredential(profile,
QStringLiteral("character"))
.isEmpty());
// QSettings has nothing (we don't set anything)
// Portable file has the password
QVERIFY(writePortablePasswordFile(profileDir, QStringLiteral("password"),
password));
// The fallback logic should: check CredentialManager → empty,
// check QSettings → empty, check portable file → found.
// Here we verify each step of the chain independently.
// CredentialManager: empty
const QString fromCredMgr = CredentialManager::retrieveCredential(
profile, QStringLiteral("character"));
QVERIFY(fromCredMgr.isEmpty());
// Portable file: has password (this is what the fix restores)
const QString fromFile =
readPortablePasswordFile(profileDir, QStringLiteral("password"));
QCOMPARE(fromFile, password);
// After finding it, the migration should store it for next time
QVERIFY(CredentialManager::storeCredential(
profile, QStringLiteral("character"), fromFile));
QCOMPARE(CredentialManager::retrieveCredential(profile,
QStringLiteral("character")),
password);
// Cleanup
CredentialManager::removeCredential(profile, QStringLiteral("character"));
}
// Verify that Unicode passwords survive the portable file round-trip
void testPortableFileUnicodePassword() {
const QString profile = QStringLiteral("UnicodePassProfile");
const QString profileDir =
QStringLiteral("%1/%2").arg(mTempDir.path(), profile);
QVERIFY(QDir().mkpath(profileDir));
const QString password = QStringLiteral("пароль密码パスワード🔑");
QVERIFY(writePortablePasswordFile(profileDir, QStringLiteral("password"),
password));
const QString retrieved =
readPortablePasswordFile(profileDir, QStringLiteral("password"));
QCOMPARE(retrieved, password);
}
void cleanupTestCase() {
// Defensive cleanup
CredentialManager::removeCredential(QStringLiteral("MigrationTestProfile"),
QStringLiteral("character"));
CredentialManager::removeCredential(QStringLiteral("FallbackTestProfile"),
QStringLiteral("character"));
}
};
#include "PasswordMigrationTest.moc"
QTEST_MAIN(PasswordMigrationTest)