/*************************************************************************** * 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 #include #include #include #include #include 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)