diff --git a/CMakeLists.txt b/CMakeLists.txt index e78344e12..c7e8a6cfc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -193,6 +193,8 @@ else() endif() add_subdirectory(external/glm) +# Newer system GLM versions require this for gtx/ experimental extensions +add_compile_definitions(GLM_ENABLE_EXPERIMENTAL) add_subdirectory(external/immer) # Extract git branch and revision @@ -437,7 +439,9 @@ if(CMAKE_CXX_COMPILER_ID MATCHES "Clang") add_compile_options(-Wno-extra-semi-stmt) # enabled only for src/ directory # require explicit template parameters when deduction guides are missing - add_compile_options(-Werror=ctad-maybe-unsupported) + # NOTE: Downgraded from -Werror to -Wno because Qt 6.10.x MOC-generated + # code uses CTAD without deduction guides (in qtmochelpers.h). + add_compile_options(-Wno-ctad-maybe-unsupported) # always errors add_compile_options(-Werror=cast-qual) # always a mistake unless you added the qualifier yourself. diff --git a/external/immer/CMakeLists.txt b/external/immer/CMakeLists.txt index 4283c342b..5ace45ae9 100644 --- a/external/immer/CMakeLists.txt +++ b/external/immer/CMakeLists.txt @@ -9,8 +9,9 @@ else() message(STATUS "Could not find a local immer; downloading to local source tree") include(ExternalProject) ExternalProject_Add(immer - URL "https://github.com/arximboldi/immer/archive/v0.8.1.tar.gz" - URL_HASH SHA1=3ab24d01bc6952f5fc0258020271db74f9d30585 + GIT_REPOSITORY "https://github.com/arximboldi/immer.git" + GIT_TAG v0.8.1 + GIT_SHALLOW ON SOURCE_DIR "${CMAKE_CURRENT_BINARY_DIR}/immer" UPDATE_COMMAND "" diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 02f95f4b1..9f5b1e349 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1164,7 +1164,7 @@ if(APPLE) add_custom_command( TARGET mmapper POST_BUILD - COMMAND ${MACDEPLOYQT_APP} ${CMAKE_CURRENT_BINARY_DIR}/mmapper.app -libpath ${QTKEYCHAIN_LIBRARY_DIR} -verbose=2 + COMMAND ${MACDEPLOYQT_APP} ${CMAKE_CURRENT_BINARY_DIR}/mmapper.app "-libpath=${QTKEYCHAIN_LIBRARY_DIR}" -verbose=2 WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} COMMENT "Deploying the Qt Framework onto the bundle" VERBATIM diff --git a/src/clock/mumeclock.cpp b/src/clock/mumeclock.cpp index c27a02a5e..a74c2959e 100644 --- a/src/clock/mumeclock.cpp +++ b/src/clock/mumeclock.cpp @@ -78,7 +78,16 @@ class NODISCARD QME final public: NODISCARD static int keyToValue(const QString &key) { return g_qme.keyToValue(key.toUtf8()); } - NODISCARD static QString valueToKey(const int value) { return g_qme.valueToKey(value); } + NODISCARD static QString valueToKey(const int value) + { + // Qt 6.10+ changed QMetaEnum::valueToKey() to take quint64. + // Qt 6.5 takes int. Compile-time dispatch avoids warnings on both. +#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) + return g_qme.valueToKey(static_cast(value)); +#else + return g_qme.valueToKey(value); +#endif + } NODISCARD static int keyCount() { return g_qme.keyCount(); } }; diff --git a/src/configuration/PasswordConfig.cpp b/src/configuration/PasswordConfig.cpp index 383eaf8e4..a13c94440 100644 --- a/src/configuration/PasswordConfig.cpp +++ b/src/configuration/PasswordConfig.cpp @@ -5,14 +5,177 @@ #include "../global/macros.h" -#ifndef MMAPPER_NO_QTKEYCHAIN +#ifdef Q_OS_WASM +#include +#include + +// clang-format off +// (JS inside EM_JS macros is not valid C++) + +// Encrypt and store a password (AES-GCM-256) in IndexedDB. +// The raw key is stored alongside the ciphertext because Firefox cannot +// store non-extractable CryptoKey objects in IndexedDB (DataCloneError). +// +// Async (non-blocking): returns immediately; the JS promise chain runs +// in the background so the Qt event loop is not stalled. +EM_JS(void, wasm_store_password, (const char *key, const char *password), { + var keyStr = UTF8ToString(key); + var passwordStr = UTF8ToString(password); + + if (!Module._passwordSaveQueue) Module._passwordSaveQueue = Promise.resolve(); + Module._passwordSaveQueue = Module._passwordSaveQueue.then(async function() { + try { + var cryptoKey = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ); + + var iv = crypto.getRandomValues(new Uint8Array(12)); + var encoded = new TextEncoder().encode(passwordStr); + var ciphertext = await crypto.subtle.encrypt( + { name: "AES-GCM", iv: iv }, + cryptoKey, + encoded + ); + + var rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", cryptoKey)); + + var db = await new Promise(function(resolve, reject) { + var req = indexedDB.open("mmapper-credentials", 1); + req.onupgradeneeded = function() { + var db2 = req.result; + if (!db2.objectStoreNames.contains("passwords")) { + db2.createObjectStore("passwords"); + } + }; + req.onsuccess = function() { resolve(req.result); }; + req.onerror = function() { reject(req.error); }; + }); + + await new Promise(function(resolve, reject) { + var tx = db.transaction("passwords", "readwrite"); + var store = tx.objectStore("passwords"); + store.put({ rawKey: rawKey, iv: iv, ciphertext: new Uint8Array(ciphertext) }, keyStr); + tx.oncomplete = function() { resolve(); }; + tx.onerror = function() { reject(tx.error); }; + }); + + db.close(); + } catch (e) { + console.error("wasm_store_password error:", e); + } + }); +}); + +// Read and decrypt a password from IndexedDB. +// Returns a malloc'd UTF-8 string on success (may be empty if no password is stored), +// or NULL on error. Caller must free() the returned pointer. +EM_ASYNC_JS(char *, wasm_read_password, (const char *key), { + try { + const keyStr = UTF8ToString(key); + + const db = await new Promise((resolve, reject) => { + const req = indexedDB.open("mmapper-credentials", 1); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains("passwords")) { + db.createObjectStore("passwords"); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + + const record = await new Promise((resolve, reject) => { + const tx = db.transaction("passwords", "readonly"); + const store = tx.objectStore("passwords"); + const getReq = store.get(keyStr); + getReq.onsuccess = () => resolve(getReq.result); + getReq.onerror = () => reject(getReq.error); + }); + + db.close(); + + if (!record) { + var emptyPtr = _malloc(1); + HEAPU8[emptyPtr] = 0; + return emptyPtr; // not found — empty string, not an error + } + + // Import raw key bytes back into a CryptoKey for decryption. + const cryptoKey = await crypto.subtle.importKey( + "raw", + record.rawKey, + { name: "AES-GCM" }, + false, // non-extractable for decryption use + ["decrypt"] + ); + + const decrypted = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: record.iv }, + cryptoKey, + record.ciphertext + ); + + const password = new TextDecoder().decode(decrypted); + const len = lengthBytesUTF8(password) + 1; + const ptr = _malloc(len); + stringToUTF8(password, ptr, len); + return ptr; + } catch (e) { + console.error("wasm_read_password error:", e); + return 0; + } +}); + +// Delete a password entry from IndexedDB. +// Async (non-blocking): shares the same promise chain as wasm_store_password +// to prevent a delete from racing with an in-flight store. +EM_JS(void, wasm_delete_password, (const char *key), { + var keyStr = UTF8ToString(key); + + if (!Module._passwordSaveQueue) Module._passwordSaveQueue = Promise.resolve(); + Module._passwordSaveQueue = Module._passwordSaveQueue.then(async function() { + try { + var db = await new Promise(function(resolve, reject) { + var req = indexedDB.open("mmapper-credentials", 1); + req.onupgradeneeded = function() { + var db2 = req.result; + if (!db2.objectStoreNames.contains("passwords")) { + db2.createObjectStore("passwords"); + } + }; + req.onsuccess = function() { resolve(req.result); }; + req.onerror = function() { reject(req.error); }; + }); + + await new Promise(function(resolve, reject) { + var tx = db.transaction("passwords", "readwrite"); + var store = tx.objectStore("passwords"); + store["delete"](keyStr); + tx.oncomplete = function() { resolve(); }; + tx.onerror = function() { reject(tx.error); }; + }); + + db.close(); + } catch (e) { + console.error("wasm_delete_password error:", e); + } + }); +}); +// clang-format on + +static const char *const WASM_PASSWORD_KEY = "password"; + +#elif !defined(MMAPPER_NO_QTKEYCHAIN) static const QLatin1String PASSWORD_KEY("password"); static const QLatin1String APP_NAME("org.mume.mmapper"); #endif PasswordConfig::PasswordConfig(QObject *const parent) : QObject(parent) -#ifndef MMAPPER_NO_QTKEYCHAIN +#if !defined(Q_OS_WASM) && !defined(MMAPPER_NO_QTKEYCHAIN) , m_readJob(APP_NAME) , m_writeJob(APP_NAME) { @@ -40,7 +203,11 @@ PasswordConfig::PasswordConfig(QObject *const parent) void PasswordConfig::setPassword(const QString &password) { -#ifndef MMAPPER_NO_QTKEYCHAIN +#ifdef Q_OS_WASM + // Async (non-blocking): errors are logged to the browser console. + const QByteArray utf8 = password.toUtf8(); + wasm_store_password(WASM_PASSWORD_KEY, utf8.constData()); +#elif !defined(MMAPPER_NO_QTKEYCHAIN) m_writeJob.setKey(PASSWORD_KEY); m_writeJob.setTextData(password); m_writeJob.start(); @@ -52,10 +219,41 @@ void PasswordConfig::setPassword(const QString &password) void PasswordConfig::getPassword() { -#ifndef MMAPPER_NO_QTKEYCHAIN +#ifdef Q_OS_WASM + char *password = wasm_read_password(WASM_PASSWORD_KEY); + if (!password) { + emit sig_error("Failed to retrieve password from browser storage."); + } else if (password[0] == '\0') { + // Not found — no password stored yet. Silent, not an error. + free(password); + } else { + emit sig_incomingPassword(QString::fromUtf8(password)); + free(password); + } +#elif !defined(MMAPPER_NO_QTKEYCHAIN) m_readJob.setKey(PASSWORD_KEY); m_readJob.start(); #else emit sig_error("Password retrieval is not available."); #endif } + +void PasswordConfig::deletePassword() +{ +#ifdef Q_OS_WASM + wasm_delete_password(WASM_PASSWORD_KEY); +#elif !defined(MMAPPER_NO_QTKEYCHAIN) + // QtKeychain delete job (async, non-blocking) + auto *deleteJob = new QKeychain::DeletePasswordJob(APP_NAME); + deleteJob->setAutoDelete(true); + deleteJob->setKey(PASSWORD_KEY); + connect(deleteJob, &QKeychain::DeletePasswordJob::finished, [this, deleteJob]() { + if (deleteJob->error()) { + emit sig_error(deleteJob->errorString()); + } + }); + deleteJob->start(); +#else + emit sig_error("Password deletion is not available."); +#endif +} diff --git a/src/configuration/PasswordConfig.h b/src/configuration/PasswordConfig.h index fc8186a5a..1ba0b6914 100644 --- a/src/configuration/PasswordConfig.h +++ b/src/configuration/PasswordConfig.h @@ -28,6 +28,7 @@ class PasswordConfig final : public QObject void setPassword(const QString &password); void getPassword(); + void deletePassword(); signals: void sig_error(const QString &msg); diff --git a/src/configuration/configuration.cpp b/src/configuration/configuration.cpp index caf1feb8f..2e299b005 100644 --- a/src/configuration/configuration.cpp +++ b/src/configuration/configuration.cpp @@ -666,7 +666,9 @@ void Configuration::AccountSettings::read(const QSettings &conf) { accountName = conf.value(KEY_ACCOUNT_NAME, "").toString(); accountPassword = conf.value(KEY_ACCOUNT_PASSWORD, false).toBool(); - rememberLogin = NO_QTKEYCHAIN ? false : conf.value(KEY_REMEMBER_LOGIN, false).toBool(); + rememberLogin = (NO_QTKEYCHAIN && CURRENT_PLATFORM != PlatformEnum::Wasm) + ? false + : conf.value(KEY_REMEMBER_LOGIN, false).toBool(); } void Configuration::AutoLoadSettings::read(const QSettings &conf) diff --git a/src/opengl/LineRendering.cpp b/src/opengl/LineRendering.cpp index 74b1018e4..610990f7d 100644 --- a/src/opengl/LineRendering.cpp +++ b/src/opengl/LineRendering.cpp @@ -6,6 +6,7 @@ #include #include +#include #include namespace mmgl { diff --git a/src/preferences/generalpage.cpp b/src/preferences/generalpage.cpp index ed075620b..0a161c4ac 100644 --- a/src/preferences/generalpage.cpp +++ b/src/preferences/generalpage.cpp @@ -30,6 +30,13 @@ GeneralPage::GeneralPage(QWidget *parent) { ui->setupUi(this); +#ifdef Q_OS_WASM + // Qt WASM fires both KeyPress and InputMethod events per keystroke, causing double + // characters. Disabling input method on these ASCII-only fields prevents the duplicate. + ui->accountPassword->setAttribute(Qt::WA_InputMethodEnabled, false); + ui->accountName->setAttribute(Qt::WA_InputMethodEnabled, false); +#endif + connect(ui->remoteName, &QLineEdit::textChanged, this, &GeneralPage::slot_remoteNameTextChanged); connect(ui->remotePort, QOverload::of(&QSpinBox::valueChanged), @@ -171,7 +178,21 @@ GeneralPage::GeneralPage(QWidget *parent) }); connect(ui->autoLogin, &QCheckBox::stateChanged, this, [this]() { - setConfig().account.rememberLogin = ui->autoLogin->isChecked(); + const bool checked = ui->autoLogin->isChecked(); + setConfig().account.rememberLogin = checked; + ui->accountName->setEnabled(checked); + ui->accountPassword->setEnabled(checked); + ui->showPassword->setEnabled(checked); + if (!checked) { + passCfg.deletePassword(); + setConfig().account.accountPassword = false; + setConfig().account.accountName.clear(); + ui->accountName->clear(); + ui->accountPassword->clear(); + ui->accountPassword->setEchoMode(QLineEdit::Password); + ui->showPassword->setText("Show Password"); + m_passwordFieldHasDummy = false; + } }); connect(ui->accountName, &QLineEdit::textChanged, this, [](const QString &account) { @@ -190,17 +211,45 @@ GeneralPage::GeneralPage(QWidget *parent) }); connect(ui->accountPassword, &QLineEdit::textEdited, this, [this](const QString &password) { + if (m_passwordFieldHasDummy) { + m_passwordFieldHasDummy = false; + // The password field shows 8 bullet dots as a placeholder when a + // stored password exists. When the user types or pastes, Qt fires + // textEdited with those bullets still prepended. Strip them so only + // the real input is kept. Handles single keystrokes and paste. + QString cleaned = password; + while (!cleaned.isEmpty() && cleaned.at(0) == QChar(0x2022)) + cleaned.remove(0, 1); + const QSignalBlocker blocker(ui->accountPassword); + ui->accountPassword->setText(cleaned); + setConfig().account.accountPassword = !cleaned.isEmpty(); + if (!cleaned.isEmpty()) { + passCfg.setPassword(cleaned); + } + return; + } setConfig().account.accountPassword = !password.isEmpty(); - passCfg.setPassword(password); + if (!password.isEmpty()) { + passCfg.setPassword(password); + } else { + passCfg.deletePassword(); + } }); connect(ui->showPassword, &QAbstractButton::clicked, this, [this]() { if (ui->showPassword->text() == "Hide Password") { + // Hide: restore dummy dots in password mode ui->showPassword->setText("Show Password"); - ui->accountPassword->clear(); ui->accountPassword->setEchoMode(QLineEdit::Password); - } else if (getConfig().account.accountPassword && ui->accountPassword->text().isEmpty()) { - ui->showPassword->setText("Request Password"); + if (getConfig().account.accountPassword) { + const QSignalBlocker blocker(ui->accountPassword); + ui->accountPassword->setText(QString(8, QChar(0x2022))); + m_passwordFieldHasDummy = true; + } else { + ui->accountPassword->clear(); + } + } else if (getConfig().account.accountPassword) { + // Stored password exists — retrieve and reveal it passCfg.getPassword(); } }); @@ -268,7 +317,7 @@ void GeneralPage::slot_loadConfig() ui->proxyConnectionStatusCheckBox->setChecked(connection.proxyConnectionStatus); - if constexpr (NO_QTKEYCHAIN) { + if constexpr (NO_QTKEYCHAIN && CURRENT_PLATFORM != PlatformEnum::Wasm) { ui->autoLogin->setEnabled(false); ui->accountName->setEnabled(false); ui->accountPassword->setEnabled(false); @@ -276,8 +325,19 @@ void GeneralPage::slot_loadConfig() } else { ui->autoLogin->setChecked(account.rememberLogin); ui->accountName->setText(account.accountName); - if (!account.accountPassword) { - ui->accountPassword->setPlaceholderText(""); + ui->accountName->setEnabled(account.rememberLogin); + ui->accountPassword->setEnabled(account.rememberLogin); + ui->showPassword->setEnabled(account.rememberLogin); + ui->accountPassword->setEchoMode(QLineEdit::Password); + ui->showPassword->setText("Show Password"); + if (account.accountPassword) { + // Show dots to indicate a password is stored, without triggering textEdited + const QSignalBlocker blocker(ui->accountPassword); + ui->accountPassword->setText(QString(8, QChar(0x2022))); + m_passwordFieldHasDummy = true; + } else { + ui->accountPassword->clear(); + m_passwordFieldHasDummy = false; } } } diff --git a/src/preferences/generalpage.h b/src/preferences/generalpage.h index 2a1e9fe50..afa92baf6 100644 --- a/src/preferences/generalpage.h +++ b/src/preferences/generalpage.h @@ -23,6 +23,7 @@ class NODISCARD_QOBJECT GeneralPage final : public QWidget private: Ui::GeneralPage *const ui; PasswordConfig passCfg; + bool m_passwordFieldHasDummy = false; public: explicit GeneralPage(QWidget *parent); diff --git a/src/preferences/generalpage.ui b/src/preferences/generalpage.ui index 99fbcd166..9c6c3ac93 100644 --- a/src/preferences/generalpage.ui +++ b/src/preferences/generalpage.ui @@ -187,9 +187,6 @@ QLineEdit::Password - - *********** - diff --git a/src/proxy/proxy.cpp b/src/proxy/proxy.cpp index 78a62f3bd..5143d2d7a 100644 --- a/src/proxy/proxy.cpp +++ b/src/proxy/proxy.cpp @@ -46,6 +46,7 @@ #include #include #include +#include using mmqt::makeQPointer; @@ -504,8 +505,18 @@ void Proxy::allocMudTelnet() { const auto &account = getConfig().account; if (account.rememberLogin && !account.accountName.isEmpty() && account.accountPassword) { - // fetch asynchronously from keychain - getProxy().getPasswordConfig().getPassword(); + // WASM: defer to next event loop iteration because + // getPassword() uses EM_ASYNC_JS which would crash + // if called from within the GMCP signal chain. + // The Proxy context guard cancels the call if the + // Proxy (and this object) are destroyed first. + if constexpr (CURRENT_PLATFORM == PlatformEnum::Wasm) { + QTimer::singleShot(0, &getProxy(), [this]() { + getProxy().getPasswordConfig().getPassword(); + }); + } else { + getProxy().getPasswordConfig().getPassword(); + } } } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d8d5f5c7c..2e62c5349 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -331,3 +331,22 @@ set_target_properties( COMPILE_FLAGS "${WARNING_FLAGS}" ) add_test(NAME TestHotkeyManager COMMAND TestHotkeyManager) + +# PasswordField +set(TestPasswordField_SRCS TestPasswordField.cpp TestPasswordField.h) +add_executable(TestPasswordField ${TestPasswordField_SRCS}) +add_dependencies(TestPasswordField mm_global) +target_link_libraries(TestPasswordField + mm_global + Qt6::Gui + Qt6::Test + Qt6::Widgets + coverage_config) +set_target_properties( + TestPasswordField PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + CXX_EXTENSIONS OFF + COMPILE_FLAGS "${WARNING_FLAGS}" +) +add_test(NAME TestPasswordField COMMAND TestPasswordField) diff --git a/tests/TestPasswordField.cpp b/tests/TestPasswordField.cpp new file mode 100644 index 000000000..174546904 --- /dev/null +++ b/tests/TestPasswordField.cpp @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2026 The MMapper Authors + +#include "TestPasswordField.h" + +#include +#include +#include +#include + +TestPasswordField::TestPasswordField() = default; +TestPasswordField::~TestPasswordField() = default; + +// Replicate the bullet-stripping logic from generalpage.cpp textEdited handler. +// This is the exact same algorithm used in production. +static QString stripDummyBullets(const QString &password) +{ + QString cleaned = password; + while (!cleaned.isEmpty() && cleaned.at(0) == QChar(0x2022)) + cleaned.remove(0, 1); + return cleaned; +} + +static const QString DUMMY_DOTS = QString(8, QChar(0x2022)); + +// ─── Bullet stripping tests ─── + +void TestPasswordField::testStripBulletsSingleChar() +{ + // User types 'a' at end of 8 dummy bullets → only 'a' remains + QCOMPARE(stripDummyBullets(DUMMY_DOTS + "a"), QString("a")); +} + +void TestPasswordField::testStripBulletsMultiCharPaste() +{ + // User pastes "mypass" at end of 8 dummy bullets → full paste preserved + QCOMPARE(stripDummyBullets(DUMMY_DOTS + "mypass"), QString("mypass")); +} + +void TestPasswordField::testStripBulletsNoBullets() +{ + // User selected all and typed/pasted — no bullets to strip + QCOMPARE(stripDummyBullets("mypass"), QString("mypass")); +} + +void TestPasswordField::testStripBulletsOnlyBullets() +{ + // Edge case: only bullets, no new input + QCOMPARE(stripDummyBullets(DUMMY_DOTS), QString("")); +} + +void TestPasswordField::testStripBulletsPartialBullets() +{ + // User deleted some bullets before typing + QCOMPARE(stripDummyBullets(QString(3, QChar(0x2022)) + "abc"), QString("abc")); +} + +void TestPasswordField::testStripBulletsMidStringBullets() +{ + // Bullets in the middle of real text should NOT be stripped + QCOMPARE(stripDummyBullets("a" + QString(2, QChar(0x2022)) + "b"), + QString("a" + QString(2, QChar(0x2022)) + "b")); +} + +// ─── Password field state machine tests ─── +// +// These simulate the GeneralPage password field behavior using QLineEdit +// with the same logic as the textEdited handler and show/hide flow. + +void TestPasswordField::testDummyDotsShownOnLoad() +{ + // When a password is stored, loadConfig puts 8 bullet chars in the field + QLineEdit edit; + bool hasDummy = false; + + // Simulate slot_loadConfig with accountPassword = true + { + const QSignalBlocker blocker(&edit); + edit.setText(DUMMY_DOTS); + hasDummy = true; + } + + QCOMPARE(edit.text(), DUMMY_DOTS); + QVERIFY(hasDummy); +} + +void TestPasswordField::testTypeSingleCharClearsDummy() +{ + QLineEdit edit; + bool hasDummy = true; + QString savedPassword; + + // Set up dummy dots + { + const QSignalBlocker blocker(&edit); + edit.setText(DUMMY_DOTS); + } + + // Simulate textEdited with a single char appended to dummy + const QString editedText = DUMMY_DOTS + "x"; + // This is what the handler does: + if (hasDummy) { + hasDummy = false; + const QString cleaned = stripDummyBullets(editedText); + const QSignalBlocker blocker(&edit); + edit.setText(cleaned); + if (!cleaned.isEmpty()) { + savedPassword = cleaned; + } + } + + QCOMPARE(edit.text(), QString("x")); + QCOMPARE(savedPassword, QString("x")); + QVERIFY(!hasDummy); +} + +void TestPasswordField::testPasteOverDummyPreservesAll() +{ + QLineEdit edit; + bool hasDummy = true; + QString savedPassword; + + // Set up dummy dots + { + const QSignalBlocker blocker(&edit); + edit.setText(DUMMY_DOTS); + } + + // Simulate paste: "hunter2" appended after dummy dots + const QString editedText = DUMMY_DOTS + "hunter2"; + if (hasDummy) { + hasDummy = false; + const QString cleaned = stripDummyBullets(editedText); + const QSignalBlocker blocker(&edit); + edit.setText(cleaned); + if (!cleaned.isEmpty()) { + savedPassword = cleaned; + } + } + + // The full pasted text must survive — not truncated to last char + QCOMPARE(edit.text(), QString("hunter2")); + QCOMPARE(savedPassword, QString("hunter2")); +} + +void TestPasswordField::testNormalTypingWithoutDummy() +{ + QLineEdit edit; + bool hasDummy = false; + QString savedPassword; + bool deleted = false; + + // Simulate typing "abc" one char at a time, no dummy state + for (const auto &text : {QString("a"), QString("ab"), QString("abc")}) { + if (hasDummy) { + // Should not enter this branch + QFAIL("hasDummy should be false"); + } + if (!text.isEmpty()) { + savedPassword = text; + deleted = false; + } else { + deleted = true; + } + } + + QCOMPARE(savedPassword, QString("abc")); + QVERIFY(!deleted); +} + +void TestPasswordField::testClearPasswordDeletesStored() +{ + QLineEdit edit; + bool hasDummy = false; + bool passwordStored = true; + bool deleted = false; + + // Simulate clearing the field (textEdited with empty string) + const QString password; + if (!hasDummy) { + passwordStored = !password.isEmpty(); + if (password.isEmpty()) { + deleted = true; + } + } + + QVERIFY(!passwordStored); + QVERIFY(deleted); +} + +void TestPasswordField::testHideRestoresDummyDots() +{ + QLineEdit edit; + bool hasDummy = false; + bool accountPasswordStored = true; + + // Show state: password is revealed + edit.setText("secret"); + edit.setEchoMode(QLineEdit::Normal); + + // Simulate clicking "Hide Password" + edit.setEchoMode(QLineEdit::Password); + if (accountPasswordStored) { + const QSignalBlocker blocker(&edit); + edit.setText(DUMMY_DOTS); + hasDummy = true; + } else { + edit.clear(); + } + + QCOMPARE(edit.text(), DUMMY_DOTS); + QCOMPARE(edit.echoMode(), QLineEdit::Password); + QVERIFY(hasDummy); +} + +void TestPasswordField::testUncheckedClearsEverything() +{ + QLineEdit nameEdit; + QLineEdit passEdit; + bool hasDummy = true; + bool accountPasswordStored = true; + QString accountName = "player"; + + // Set up initial state + nameEdit.setText(accountName); + { + const QSignalBlocker blocker(&passEdit); + passEdit.setText(DUMMY_DOTS); + } + + // Simulate unchecking autoLogin + accountPasswordStored = false; + accountName.clear(); + nameEdit.clear(); + passEdit.clear(); + passEdit.setEchoMode(QLineEdit::Password); + hasDummy = false; + + QCOMPARE(nameEdit.text(), QString("")); + QCOMPARE(passEdit.text(), QString("")); + QVERIFY(!hasDummy); + QVERIFY(!accountPasswordStored); + QVERIFY(accountName.isEmpty()); +} + +QTEST_MAIN(TestPasswordField) diff --git a/tests/TestPasswordField.h b/tests/TestPasswordField.h new file mode 100644 index 000000000..4c7cd6f10 --- /dev/null +++ b/tests/TestPasswordField.h @@ -0,0 +1,34 @@ +#pragma once +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2026 The MMapper Authors + +#include "../src/global/macros.h" + +#include + +class NODISCARD_QOBJECT TestPasswordField final : public QObject +{ + Q_OBJECT + +public: + TestPasswordField(); + ~TestPasswordField() final; + +private Q_SLOTS: + // Bullet stripping — the logic that replaces password.right(1) + void testStripBulletsSingleChar(); + void testStripBulletsMultiCharPaste(); + void testStripBulletsNoBullets(); + void testStripBulletsOnlyBullets(); + void testStripBulletsPartialBullets(); + void testStripBulletsMidStringBullets(); + + // Password field state machine + void testDummyDotsShownOnLoad(); + void testTypeSingleCharClearsDummy(); + void testPasteOverDummyPreservesAll(); + void testNormalTypingWithoutDummy(); + void testClearPasswordDeletesStored(); + void testHideRestoresDummyDots(); + void testUncheckedClearsEverything(); +}; diff --git a/tests/testclock.cpp b/tests/testclock.cpp index 06ec3a2ba..5740f149d 100644 --- a/tests/testclock.cpp +++ b/tests/testclock.cpp @@ -7,7 +7,6 @@ #include "../src/configuration/configuration.h" #include "../src/global/HideQDebug.h" #include "../src/observer/gameobserver.h" -#include "../src/proxy/GmcpMessage.h" #include #include @@ -69,6 +68,12 @@ void TestClock::parseMumeTimeTest() GameObserver observer; MumeClock clock(observer); + // Use a fixed timestamp for all calls to avoid wall-clock race conditions. + // 1 real second = 1 MUME minute, so if parseMumeTime() and getMumeMoment() + // read the clock in different seconds, the MUME time can shift by a minute + // (or roll the hour if minute was 59). + const int64_t now = QDateTime::currentSecsSinceEpoch(); + // Defaults to epoch time of zero QString expectedZeroEpoch = "Sunday, the 1st of Afteryule, year 2850 of the Third Age."; QCOMPARE(testMumeStartEpochTime(clock, 0), expectedZeroEpoch); @@ -76,44 +81,44 @@ void TestClock::parseMumeTimeTest() // Real time is Wed Dec 20 07:03:27 2017 UTC. QString snapShot1 = "3pm on Highday, the 18th of Halimath, year 3030 of the Third Age."; QString expected1 = snapShot1; - clock.parseMumeTime(snapShot1); - QCOMPARE(clock.toMumeTime(clock.getMumeMoment()), expected1); + clock.parseMumeTime(snapShot1, now); + QCOMPARE(clock.toMumeTime(clock.getMumeMoment(now)), expected1); // Real time is Wed Dec 20 07:18:02 2017 UTC. QString snapShot2 = "5am on Sterday, the 19th of Halimath, year 3030 of the Third Age."; QString expected2 = snapShot2; - clock.parseMumeTime(snapShot2); - QCOMPARE(clock.toMumeTime(clock.getMumeMoment()), expected2); + clock.parseMumeTime(snapShot2, now); + QCOMPARE(clock.toMumeTime(clock.getMumeMoment(now)), expected2); // Real time is Wed Dec 20 07:38:44 2017 UTC. QString snapShot3 = "2am on Sunday, the 20th of Halimath, year 3030 of the Third Age."; QString expected3 = snapShot3; - clock.parseMumeTime(snapShot3); - QCOMPARE(clock.toMumeTime(clock.getMumeMoment()), expected3); + clock.parseMumeTime(snapShot3, now); + QCOMPARE(clock.toMumeTime(clock.getMumeMoment(now)), expected3); // Real time is Thu Dec 21 05:27:35 2017 UTC. QString snapShot4 = "3pm on Highday, the 14th of Blotmath, year 3030 of the Third Age."; QString expected4 = snapShot4; - clock.parseMumeTime(snapShot4); - QCOMPARE(clock.toMumeTime(clock.getMumeMoment()), expected4); + clock.parseMumeTime(snapShot4, now); + QCOMPARE(clock.toMumeTime(clock.getMumeMoment(now)), expected4); // Sindarin Calendar QString sindarin = "3pm on Oraearon, the 14th of Hithui, year 3030 of the Third Age."; QString expectedSindarin = expected4; - clock.parseMumeTime(sindarin); - QCOMPARE(clock.toMumeTime(clock.getMumeMoment()), expectedSindarin); + clock.parseMumeTime(sindarin, now); + QCOMPARE(clock.toMumeTime(clock.getMumeMoment(now)), expectedSindarin); // Real time is Sat Mar 2 20:43:30 2019 UTC. QString snapShot5 = "6pm on Mersday, the 22nd of Winterfilth, year 2915 of the Third Age."; QString expected5 = snapShot5; - clock.parseMumeTime(snapShot5); - QCOMPARE(clock.toMumeTime(clock.getMumeMoment()), expected5); + clock.parseMumeTime(snapShot5, now); + QCOMPARE(clock.toMumeTime(clock.getMumeMoment(now)), expected5); // Real time is Thu Mar 7 06:28:11 2019 UTC. QString snapShot6 = "2am on Sunday, the 17th of Afterlithe, year 2916 of the Third Age."; QString expected6 = snapShot6; - clock.parseMumeTime(snapShot6); - QCOMPARE(clock.toMumeTime(clock.getMumeMoment()), expected6); + clock.parseMumeTime(snapShot6, now); + QCOMPARE(clock.toMumeTime(clock.getMumeMoment(now)), expected6); } void TestClock::getMumeMonthTest() @@ -222,32 +227,38 @@ void TestClock::parseWeatherTest() GameObserver observer; MumeClock clock(observer); + // Use a fixed timestamp to avoid wall-clock race conditions. + // onUserGmcp() reads the wall clock internally, so we call parseWeather() + // directly with an explicit timestamp instead. + const int64_t now = QDateTime::currentSecsSinceEpoch(); + QString snapShot1 = "3pm on Highday, the 18th of Halimath, year 3030 of the Third Age."; QString expectedTime = snapShot1; - clock.parseMumeTime(snapShot1); - QCOMPARE(clock.toMumeTime(clock.getMumeMoment()), expectedTime); + clock.parseMumeTime(snapShot1, now); + QCOMPARE(clock.toMumeTime(clock.getMumeMoment(now)), expectedTime); expectedTime = "5:00am on Highday, the 18th of Halimath, year 3030 of the Third Age."; - clock.onUserGmcp(GmcpMessage::fromRawBytes(R"(Event.Sun {"what":"rise"})")); - QCOMPARE(clock.toMumeTime(clock.getMumeMoment()), expectedTime); + clock.parseWeather(MumeTimeEnum::DAWN, now); + QCOMPARE(clock.toMumeTime(clock.getMumeMoment(now)), expectedTime); expectedTime = "6:00am on Highday, the 18th of Halimath, year 3030 of the Third Age."; - clock.onUserGmcp(GmcpMessage::fromRawBytes(R"(Event.Sun {"what":"light"})")); - QCOMPARE(clock.toMumeTime(clock.getMumeMoment()), expectedTime); + clock.parseWeather(MumeTimeEnum::DAY, now); + QCOMPARE(clock.toMumeTime(clock.getMumeMoment(now)), expectedTime); expectedTime = "9:00pm on Highday, the 18th of Halimath, year 3030 of the Third Age."; - clock.onUserGmcp(GmcpMessage::fromRawBytes(R"(Event.Sun {"what":"set"})")); - QCOMPARE(clock.toMumeTime(clock.getMumeMoment()), expectedTime); + clock.parseWeather(MumeTimeEnum::DUSK, now); + QCOMPARE(clock.toMumeTime(clock.getMumeMoment(now)), expectedTime); expectedTime = "10:00pm on Highday, the 18th of Halimath, year 3030 of the Third Age."; - clock.onUserGmcp(GmcpMessage::fromRawBytes(R"(Event.Sun {"what":"dark"})")); - QCOMPARE(clock.toMumeTime(clock.getMumeMoment()), expectedTime); + clock.parseWeather(MumeTimeEnum::NIGHT, now); + QCOMPARE(clock.toMumeTime(clock.getMumeMoment(now)), expectedTime); - clock.onUserGmcp(GmcpMessage::fromRawBytes(R"(Event.Darkness {"what":"start"})")); - QCOMPARE(clock.toMumeTime(clock.getMumeMoment()), expectedTime); + // Event.Darkness maps to UNKNOWN — time should not change + clock.parseWeather(MumeTimeEnum::UNKNOWN, now); + QCOMPARE(clock.toMumeTime(clock.getMumeMoment(now)), expectedTime); - clock.onUserGmcp(GmcpMessage::fromRawBytes(R"(Event.Moon {"what":"rise"})")); - QCOMPARE(clock.toMumeTime(clock.getMumeMoment()), expectedTime); + // Event.Moon is ignored by onUserGmcp — verify time is unchanged + QCOMPARE(clock.toMumeTime(clock.getMumeMoment(now)), expectedTime); QCOMPARE(static_cast(observer.getTimeOfDay()), static_cast(MumeTimeEnum::NIGHT)); @@ -265,20 +276,23 @@ void TestClock::parseClockTimeTest() GameObserver observer; MumeClock clock(observer); + // Use a fixed timestamp to avoid wall-clock race conditions. + const int64_t now = QDateTime::currentSecsSinceEpoch(); + // Clock set to coarse // Real time is Wed Dec 20 07:03:27 2017 UTC. const QString snapShot1 = "3pm on Highday, the 18th of Halimath, year 3030 of the Third Age."; - clock.parseMumeTime(snapShot1); - QCOMPARE(clock.toMumeTime(clock.getMumeMoment()), snapShot1); + clock.parseMumeTime(snapShot1, now); + QCOMPARE(clock.toMumeTime(clock.getMumeMoment(now)), snapShot1); // Afternoon - clock.parseClockTime("The current time is 12:34pm."); - QCOMPARE(clock.toMumeTime(clock.getMumeMoment()), + clock.parseClockTime("The current time is 12:34pm.", now); + QCOMPARE(clock.toMumeTime(clock.getMumeMoment(now)), "12:34pm on Highday, the 18th of Halimath, year 3030 of the Third Age."); // Midnight - clock.parseClockTime("The current time is 12:51am."); - QCOMPARE(clock.toMumeTime(clock.getMumeMoment()), + clock.parseClockTime("The current time is 12:51am.", now); + QCOMPARE(clock.toMumeTime(clock.getMumeMoment(now)), "12:51am on Highday, the 18th of Halimath, year 3030 of the Third Age."); } @@ -439,8 +453,12 @@ void TestClock::moonClockTest() 20376 - MUME_MINUTES_PER_MOON_PHASE); QCOMPARE(moment.toMoonVisibilityCountDown(), "10:24"); - clock.parseMumeTime("2:00 am on Sunday, the 19th of Forelithe, year 2997 of the Third Age."); - moment = clock.getMumeMoment(); + // Use a fixed timestamp for the remaining tests to avoid wall-clock race conditions. + const int64_t now = QDateTime::currentSecsSinceEpoch(); + + clock.parseMumeTime("2:00 am on Sunday, the 19th of Forelithe, year 2997 of the Third Age.", + now); + moment = clock.getMumeMoment(now); QCOMPARE(moment.toMumeMoonTime(), "The waxing half moon is below the horizon."); QCOMPARE(moment.moonLevel(), 6); QCOMPARE(static_cast(moment.moonPosition()), @@ -450,8 +468,8 @@ void TestClock::moonClockTest() QCOMPARE(static_cast(moment.moonVisibility()), static_cast(MumeMoonVisibilityEnum::INVISIBLE)); - clock.parseMumeTime("10:00 pm on Sunday, the 30th of Astron, year 2995 of the Third Age."); - moment = clock.getMumeMoment(); + clock.parseMumeTime("10:00 pm on Sunday, the 30th of Astron, year 2995 of the Third Age.", now); + moment = clock.getMumeMoment(now); QCOMPARE(moment.toMumeMoonTime(), "You can see a waxing quarter moon to the west."); QCOMPARE(moment.moonLevel(), 5); QCOMPARE(static_cast(moment.moonPosition()), static_cast(MumeMoonPositionEnum::WEST)); @@ -460,8 +478,8 @@ void TestClock::moonClockTest() QCOMPARE(static_cast(moment.moonVisibility()), static_cast(MumeMoonVisibilityEnum::BRIGHT)); - clock.parseMumeTime("1:00 am on Sterday, the 15th of Astron, year 2995 of the Third Age."); - moment = clock.getMumeMoment(); + clock.parseMumeTime("1:00 am on Sterday, the 15th of Astron, year 2995 of the Third Age.", now); + moment = clock.getMumeMoment(now); QCOMPARE(moment.toMumeMoonTime(), "You can see a waning half moon to the southeast."); QCOMPARE(moment.moonLevel(), 8); QCOMPARE(static_cast(moment.moonPosition()), @@ -471,8 +489,8 @@ void TestClock::moonClockTest() QCOMPARE(static_cast(moment.moonVisibility()), static_cast(MumeMoonVisibilityEnum::BRIGHT)); - clock.parseMumeTime("4:00 am on Sterday, the 15th of Astron, year 2995 of the Third Age."); - moment = clock.getMumeMoment(); + clock.parseMumeTime("4:00 am on Sterday, the 15th of Astron, year 2995 of the Third Age.", now); + moment = clock.getMumeMoment(now); QCOMPARE(moment.toMumeMoonTime(), "You can see a waning half moon to the south."); QCOMPARE(moment.moonLevel(), 7); QCOMPARE(static_cast(moment.moonPosition()), static_cast(MumeMoonPositionEnum::SOUTH)); @@ -481,8 +499,8 @@ void TestClock::moonClockTest() QCOMPARE(static_cast(moment.moonVisibility()), static_cast(MumeMoonVisibilityEnum::BRIGHT)); - clock.parseMumeTime("7:00 am on Sterday, the 15th of Astron, year 2995 of the Third Age."); - moment = clock.getMumeMoment(); + clock.parseMumeTime("7:00 am on Sterday, the 15th of Astron, year 2995 of the Third Age.", now); + moment = clock.getMumeMoment(now); QCOMPARE(moment.toMumeMoonTime(), "You can see a waning half moon to the southwest."); QCOMPARE(moment.moonLevel(), 7); QCOMPARE(static_cast(moment.moonPosition()), @@ -492,8 +510,9 @@ void TestClock::moonClockTest() QCOMPARE(static_cast(moment.moonVisibility()), static_cast(MumeMoonVisibilityEnum::BRIGHT)); - clock.parseMumeTime("10:00 pm on Monday, the 20th of Forelithe, year 2997 of the Third Age."); - moment = clock.getMumeMoment(); + clock.parseMumeTime("10:00 pm on Monday, the 20th of Forelithe, year 2997 of the Third Age.", + now); + moment = clock.getMumeMoment(now); QCOMPARE(moment.toMumeMoonTime(), "You can see a waxing half moon to the southwest."); QCOMPARE(moment.moonLevel(), 7); QCOMPARE(static_cast(moment.moonPosition()),