mudlet/test/SecureStringUtilsTest.cpp
Mike Conley 9ce7f72e3d
Infrastructure: fix intermittent SecureStringUtilsTest failure in CI builds (#8194)
#### Brief overview of PR changes/additions

Fixes an intermittent test failure in the password security system that
was causing CI builds to randomly fail. The test now properly validates
that corrupted password data is correctly rejected by the security
mechanisms.

#### Motivation for adding to Mudlet

The `testCorruptedDataDecryption` test was occasionally failing in CI
builds, which could mask real security issues and create unreliable
build results. This fix ensures the test consistently validates that the
password encryption system properly detects and rejects corrupted data.

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

Requested by @vadi2. The intermittent failure was appearing in some
builds with this error:

```text
FAIL!  : SecureStringUtilsTest::testCorruptedDataDecryption() 'decrypted2.isEmpty() || decrypted2 != password' returned FALSE. ()
   Loc: [/Users/runner/work/Mudlet/Mudlet/test/SecureStringUtilsTest.cpp(264)]
2025-09-07 21:15:08 +02:00

411 lines
17 KiB
C++

/***************************************************************************
* Copyright (C) 2025 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. *
***************************************************************************/
#include <SecureStringUtils.h>
#include <QtTest/QtTest>
#include <QVersionNumber>
class SecureStringUtilsTest : public QObject {
Q_OBJECT
private slots:
void initTestCase();
void testProfileBasedEncryption();
void testDifferentProfilesUseDifferentKeys();
void testEncryptedFormatDetection();
void testEmptyStrings();
void testNonDeterministicEncryption();
void testSpecialCharacters();
void testSecureMemoryClearing();
void testProfileKeyPersistence();
void testPortableModeFileStorage();
void testInvalidInputHandling();
void testLargeDataEncryption();
void testCorruptedDataDecryption();
void testVersionCompatibility();
void testXMLImportProxyPasswordLogic();
void testConveniencePasswordMethods();
void cleanupTestCase();
};
void SecureStringUtilsTest::initTestCase()
{
}
void SecureStringUtilsTest::testProfileBasedEncryption()
{
QString plaintext = "mypassword";
QString profileName = "TestProfile";
QString encrypted = SecureStringUtils::encryptStringForProfile(plaintext, profileName);
QVERIFY(!encrypted.isEmpty());
QVERIFY(encrypted != plaintext);
QString decrypted = SecureStringUtils::decryptStringForProfile(encrypted, profileName);
QCOMPARE(decrypted, plaintext);
}
void SecureStringUtilsTest::testDifferentProfilesUseDifferentKeys()
{
QString plaintext = "samepassword";
QString profile1 = "Profile1";
QString profile2 = "Profile2";
QString encrypted1 = SecureStringUtils::encryptStringForProfile(plaintext, profile1);
QString encrypted2 = SecureStringUtils::encryptStringForProfile(plaintext, profile2);
// Should be different due to different profile keys
QVERIFY(encrypted1 != encrypted2);
// Each should decrypt correctly with its own profile
QCOMPARE(SecureStringUtils::decryptStringForProfile(encrypted1, profile1), plaintext);
QCOMPARE(SecureStringUtils::decryptStringForProfile(encrypted2, profile2), plaintext);
// Cross-profile decryption should fail
QVERIFY(SecureStringUtils::decryptStringForProfile(encrypted1, profile2) != plaintext);
QVERIFY(SecureStringUtils::decryptStringForProfile(encrypted2, profile1) != plaintext);
}
void SecureStringUtilsTest::testEncryptedFormatDetection()
{
// Test plaintext passwords (should NOT be detected as encrypted)
QVERIFY(!SecureStringUtils::isEncryptedFormat("mypassword"));
QVERIFY(!SecureStringUtils::isEncryptedFormat("secret123!@#"));
QVERIFY(!SecureStringUtils::isEncryptedFormat(""));
// Test actual encrypted passwords (should be detected as encrypted)
QString plaintext = "testpassword";
QString encrypted = SecureStringUtils::encryptStringForProfile(plaintext, "TestProfile");
QVERIFY(SecureStringUtils::isEncryptedFormat(encrypted));
// Test invalid formats
QVERIFY(!SecureStringUtils::isEncryptedFormat("not-base64!"));
QVERIFY(!SecureStringUtils::isEncryptedFormat("invalid=base64="));
}
void SecureStringUtilsTest::testEmptyStrings()
{
// Test empty string handling
QCOMPARE(SecureStringUtils::encryptStringForProfile("", "Profile"), QString());
QCOMPARE(SecureStringUtils::encryptStringForProfile("password", ""), QString());
QCOMPARE(SecureStringUtils::decryptStringForProfile("", "Profile"), QString());
QCOMPARE(SecureStringUtils::decryptStringForProfile("encrypted", ""), QString());
QVERIFY(!SecureStringUtils::isEncryptedFormat(""));
}
void SecureStringUtilsTest::testNonDeterministicEncryption()
{
// Same plaintext should produce DIFFERENT encrypted results each time (due to random nonces)
QString plaintext = "consistent_password";
QString profile = "TestProfile";
QString encrypted1 = SecureStringUtils::encryptStringForProfile(plaintext, profile);
QString encrypted2 = SecureStringUtils::encryptStringForProfile(plaintext, profile);
// Should be different due to random nonces
QVERIFY(encrypted1 != encrypted2);
// But both should decrypt to the same plaintext
QCOMPARE(SecureStringUtils::decryptStringForProfile(encrypted1, profile), plaintext);
QCOMPARE(SecureStringUtils::decryptStringForProfile(encrypted2, profile), plaintext);
}
void SecureStringUtilsTest::testSpecialCharacters()
{
// Test passwords with special characters
QString specialPassword = "pássw0rd!@#$%^&*()";
QString profile = "TestProfile";
QString encrypted = SecureStringUtils::encryptStringForProfile(specialPassword, profile);
QString decrypted = SecureStringUtils::decryptStringForProfile(encrypted, profile);
QCOMPARE(decrypted, specialPassword);
QVERIFY(SecureStringUtils::isEncryptedFormat(encrypted));
}
void SecureStringUtilsTest::testSecureMemoryClearing()
{
QString testString = "sensitive_data";
QString originalContent = testString;
// Clear the string
SecureStringUtils::secureStringClear(testString);
// String should be empty after clearing
QVERIFY(testString.isEmpty());
QVERIFY(testString != originalContent);
// Test QByteArray clearing
QByteArray testArray = "sensitive_bytes";
QByteArray originalArray = testArray;
SecureStringUtils::secureByteArrayClear(testArray);
QVERIFY(testArray.isEmpty());
QVERIFY(testArray != originalArray);
}
void SecureStringUtilsTest::testProfileKeyPersistence()
{
// Test that the same profile uses consistent keys
QString password = "testpassword";
QString profile = "PersistentProfile";
QString encrypted1 = SecureStringUtils::encryptStringForProfile(password, profile);
QString encrypted2 = SecureStringUtils::encryptStringForProfile(password, profile);
// Both should decrypt correctly (proving key consistency)
QCOMPARE(SecureStringUtils::decryptStringForProfile(encrypted1, profile), password);
QCOMPARE(SecureStringUtils::decryptStringForProfile(encrypted2, profile), password);
}
void SecureStringUtilsTest::testPortableModeFileStorage()
{
// Test that profile-specific encryption/decryption works consistently
// This exercises the file-based key storage in portable mode
QString profileName = "PortableTestProfile";
QString plaintext = "portable_test_password";
// First encryption - this will trigger key generation and file storage
QString encrypted1 = SecureStringUtils::encryptStringForProfile(plaintext, profileName);
QVERIFY(SecureStringUtils::isEncryptedFormat(encrypted1));
// Second encryption with same profile - should use same key from file
QString encrypted2 = SecureStringUtils::encryptStringForProfile(plaintext, profileName);
QVERIFY(SecureStringUtils::isEncryptedFormat(encrypted2));
// Both should decrypt correctly
QCOMPARE(SecureStringUtils::decryptStringForProfile(encrypted1, profileName), plaintext);
QCOMPARE(SecureStringUtils::decryptStringForProfile(encrypted2, profileName), plaintext);
// Test that different profiles use different keys
QString otherProfile = "AnotherPortableProfile";
QString encrypted3 = SecureStringUtils::encryptStringForProfile(plaintext, otherProfile);
QVERIFY(SecureStringUtils::isEncryptedFormat(encrypted3));
// Should decrypt correctly with its own profile
QCOMPARE(SecureStringUtils::decryptStringForProfile(encrypted3, otherProfile), plaintext);
// Cross-profile decryption should fail (different keys)
QString crossDecrypt = SecureStringUtils::decryptStringForProfile(encrypted3, profileName);
QVERIFY(crossDecrypt.isEmpty() || crossDecrypt != plaintext);
}
void SecureStringUtilsTest::testInvalidInputHandling()
{
// Test null/empty profile names
QString password = "testpassword";
QString encrypted1 = SecureStringUtils::encryptStringForProfile(password, "");
QVERIFY(encrypted1.isEmpty()); // Should return empty for empty profile
QString encrypted2 = SecureStringUtils::encryptStringForProfile(password, QString());
QVERIFY(encrypted2.isEmpty()); // Should return empty for null profile
// Test with empty password
QString validProfile = "ValidProfile";
QString encrypted3 = SecureStringUtils::encryptStringForProfile("", validProfile);
QVERIFY(encrypted3.isEmpty()); // Should return empty for empty password
// Test decryption with mismatched profiles
QString profile1 = "Profile1";
QString profile2 = "Profile2";
QString encrypted = SecureStringUtils::encryptStringForProfile(password, profile1);
QString decrypted = SecureStringUtils::decryptStringForProfile(encrypted, profile2);
QVERIFY(decrypted.isEmpty() || decrypted != password); // Should fail or return wrong data
}
void SecureStringUtilsTest::testLargeDataEncryption()
{
// Test with larger strings to ensure robustness
QString largeString = QString("A").repeated(10000); // 10KB string
QString profile = "LargeDataProfile";
QString encrypted = SecureStringUtils::encryptStringForProfile(largeString, profile);
QVERIFY(!encrypted.isEmpty());
QVERIFY(SecureStringUtils::isEncryptedFormat(encrypted));
QString decrypted = SecureStringUtils::decryptStringForProfile(encrypted, profile);
QCOMPARE(decrypted, largeString);
}
void SecureStringUtilsTest::testCorruptedDataDecryption()
{
QString password = "testpassword";
QString profile = "CorruptionTestProfile";
QString encrypted = SecureStringUtils::encryptStringForProfile(password, profile);
QVERIFY(SecureStringUtils::isEncryptedFormat(encrypted));
// Test with completely invalid format
QString invalid1 = "notencrypted";
QVERIFY(!SecureStringUtils::isEncryptedFormat(invalid1));
QString decrypted1 = SecureStringUtils::decryptStringForProfile(invalid1, profile);
QVERIFY(decrypted1.isEmpty()); // Should return empty for invalid format
// Test with corrupted encrypted data (corrupt the raw binary data, not Base64)
if (encrypted.length() > 10) {
// Decode to binary, corrupt a byte, re-encode to Base64
QByteArray binaryData = QByteArray::fromBase64(encrypted.toLatin1());
if (binaryData.size() > 10) {
// Corrupt a byte in the middle of the binary data
int corruptIndex = binaryData.size() / 2;
char originalByte = binaryData[corruptIndex];
binaryData[corruptIndex] = static_cast<char>(originalByte ^ 0xFF); // Flip all bits
QString corrupted = QString::fromLatin1(binaryData.toBase64());
QString decrypted2 = SecureStringUtils::decryptStringForProfile(corrupted, profile);
QVERIFY(decrypted2.isEmpty() || decrypted2 != password); // Should fail due to corruption
}
}
// Test with truncated encrypted data
if (encrypted.length() > 5) {
QString truncated = encrypted.left(encrypted.length() - 5);
QString decrypted3 = SecureStringUtils::decryptStringForProfile(truncated, profile);
QVERIFY(decrypted3.isEmpty() || decrypted3 != password); // Should fail due to truncation
}
}
void SecureStringUtilsTest::testVersionCompatibility()
{
// Test that version-based compatibility logic works correctly
// Test version comparison for major version differences
QVERIFY(4 > 3); // Version 4.x should be newer than 3.x
QVERIFY(5 > 4); // Version 5.x should be newer than 4.x
// Test version comparison for minor version differences within major version 4
int majorVersion = 4;
// Test cases for version 4.x.x
struct {
int minorVersion;
bool shouldUseSecureStorage;
QString description;
} testCases[] = {
{19, false, "Version 4.19.x should use legacy mode"},
{20, true, "Version 4.20.x should use secure storage"},
{21, true, "Version 4.21.x should use secure storage"},
{50, true, "Version 4.50.x should use secure storage"}
};
for (const auto& testCase : testCases) {
// Simulate the version check logic from XMLimport
bool useSecureStorage = (majorVersion > 4) || (majorVersion == 4 && testCase.minorVersion >= 20);
if (useSecureStorage != testCase.shouldUseSecureStorage) {
QFAIL(qPrintable(QString("Version compatibility test failed for %1: expected %2, got %3")
.arg(testCase.description)
.arg(testCase.shouldUseSecureStorage ? "true" : "false")
.arg(useSecureStorage ? "true" : "false")));
}
QCOMPARE(useSecureStorage, testCase.shouldUseSecureStorage);
}
// Test major version transitions
QVERIFY((5 > 4) || (5 == 4 && 0 >= 20)); // Version 5.0.x should use secure storage
QVERIFY((6 > 4) || (6 == 4 && 0 >= 20)); // Version 6.0.x should use secure storage
qDebug() << "Version compatibility tests passed";
}
void SecureStringUtilsTest::testXMLImportProxyPasswordLogic()
{
// Test that XMLimport now uses application version, not profile version
// This simulates the fixed logic in XMLimport.cpp
// Simulate different APP_VERSION values
struct TestCase {
QString appVersion;
bool shouldUseSecureStorage;
QString description;
};
const QList<TestCase> testCases = {
{"4.19.0", false, "App version 4.19.0 (before secure storage)"},
{"4.20.0", true, "App version 4.20.0 (secure storage introduced)"},
{"4.21.0", true, "App version 4.21.0 (after secure storage)"},
{"5.0.0", true, "App version 5.0.0 (major version after secure storage)"},
{"3.15.0", false, "App version 3.15.0 (old version)"}
};
for (const auto& testCase : testCases) {
// Simulate the new XMLimport logic
const QVersionNumber appVersion = QVersionNumber::fromString(testCase.appVersion);
const QVersionNumber secureStorageVersion = QVersionNumber(4, 20, 0);
const bool useSecureStorage = appVersion >= secureStorageVersion;
QCOMPARE(useSecureStorage, testCase.shouldUseSecureStorage);
if (useSecureStorage != testCase.shouldUseSecureStorage) {
QFAIL(qPrintable(QString("XMLimport proxy password test failed for %1: expected %2, got %3")
.arg(testCase.description)
.arg(testCase.shouldUseSecureStorage ? "true" : "false")
.arg(useSecureStorage ? "true" : "false")));
}
}
qDebug() << "XMLimport proxy password logic tests passed";
}
void SecureStringUtilsTest::testConveniencePasswordMethods()
{
QString testProfile = "ConvenienceTestProfile";
QString testKey = "test_password";
QString testPassword = "MyConvenienceTestPassword123!";
// Ensure clean state
SecureStringUtils::removePassword(testProfile, testKey);
QVERIFY(!SecureStringUtils::hasPassword(testProfile, testKey));
// Test storing password
bool stored = SecureStringUtils::storePassword(testProfile, testKey, testPassword);
QVERIFY(stored);
// Test password exists
bool exists = SecureStringUtils::hasPassword(testProfile, testKey);
QVERIFY(exists);
// Test retrieving password
QString retrieved = SecureStringUtils::retrievePassword(testProfile, testKey);
QCOMPARE(retrieved, testPassword);
// Test removing password
bool removed = SecureStringUtils::removePassword(testProfile, testKey);
QVERIFY(removed);
// Test password no longer exists
bool existsAfterRemoval = SecureStringUtils::hasPassword(testProfile, testKey);
QVERIFY(!existsAfterRemoval);
// Test retrieving non-existent password
QString nonExistent = SecureStringUtils::retrievePassword(testProfile, testKey);
QVERIFY(nonExistent.isEmpty());
// Test invalid inputs
QVERIFY(!SecureStringUtils::storePassword("", testKey, testPassword));
QVERIFY(!SecureStringUtils::storePassword(testProfile, "", testPassword));
QVERIFY(!SecureStringUtils::storePassword(testProfile, "invalid/key", testPassword));
qDebug() << "Convenience password methods tests passed";
}
void SecureStringUtilsTest::cleanupTestCase()
{
}
#include "SecureStringUtilsTest.moc"
QTEST_MAIN(SecureStringUtilsTest)