keepassxc/tests/gui/TestGui.cpp
Carlo Teubner 24dc07897b Search entry: respect shortcut config on Copy key
If the system Copy key sequence (i.e. Ctrl+C or Cmd+C) is pressed while
inside the search entry without any text being selected, previously we
would copy the currently selected entry's password. This made sense when
keyboard shortcuts were fixed. Now that they are configurable, change it
to re-route the event to the main window, which can then take the
appropriate action (i.e. Ctrl+C might be bound to some other action).
2024-06-16 17:38:29 -04:00

2303 lines
99 KiB
C++

/*
* Copyright (C) 2010 Felix Geyer <debfx@fobos.de>
* Copyright (C) 2020 KeePassXC Team <team@keepassxc.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 or (at your option)
* version 3 of the License.
*
* 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, see <http://www.gnu.org/licenses/>.
*/
#include "TestGui.h"
#include "gui/Application.h"
#include <QCheckBox>
#include <QClipboard>
#include <QListWidget>
#include <QMenu>
#include <QMenuBar>
#include <QMimeData>
#include <QPlainTextEdit>
#include <QPushButton>
#include <QRadioButton>
#include <QSignalSpy>
#include <QSpinBox>
#include <QTest>
#include <QToolBar>
#include "config-keepassx-tests.h"
#include "core/PasswordHealth.h"
#include "core/Tools.h"
#include "crypto/Crypto.h"
#include "gui/ActionCollection.h"
#include "gui/ApplicationSettingsWidget.h"
#include "gui/CategoryListWidget.h"
#include "gui/CloneDialog.h"
#include "gui/DatabaseTabWidget.h"
#include "gui/EntryPreviewWidget.h"
#include "gui/FileDialog.h"
#include "gui/MessageBox.h"
#include "gui/PasswordGeneratorWidget.h"
#include "gui/PasswordWidget.h"
#include "gui/SearchWidget.h"
#include "gui/ShortcutSettingsPage.h"
#include "gui/TotpDialog.h"
#include "gui/TotpSetupDialog.h"
#include "gui/databasekey/KeyFileEditWidget.h"
#include "gui/databasekey/PasswordEditWidget.h"
#include "gui/dbsettings/DatabaseSettingsDialog.h"
#include "gui/entry/EditEntryWidget.h"
#include "gui/entry/EntryView.h"
#include "gui/group/EditGroupWidget.h"
#include "gui/group/GroupModel.h"
#include "gui/group/GroupView.h"
#include "gui/remote/RemoteHandler.h"
#include "gui/tag/TagsEdit.h"
#include "gui/wizard/NewDatabaseWizard.h"
#include "keys/FileKey.h"
#include "mock/MockRemoteProcess.h"
#define TEST_MODAL_NO_WAIT(TEST_CODE) \
bool dialogFinished = false; \
QTimer::singleShot(0, [&]() { TEST_CODE dialogFinished = true; })
#define TEST_MODAL(TEST_CODE) \
TEST_MODAL_NO_WAIT(TEST_CODE); \
QTRY_VERIFY(dialogFinished)
int main(int argc, char* argv[])
{
#if QT_VERSION >= QT_VERSION_CHECK(5, 6, 0)
QApplication::setAttribute(Qt::AA_EnableHighDpiScaling);
QGuiApplication::setAttribute(Qt::AA_UseHighDpiPixmaps);
#endif
Application app(argc, argv);
app.setApplicationName("KeePassXC");
app.setApplicationVersion(KEEPASSXC_VERSION);
app.setQuitOnLastWindowClosed(false);
app.setAttribute(Qt::AA_Use96Dpi, true);
app.applyTheme();
QTEST_DISABLE_KEYPAD_NAVIGATION
TestGui tc;
QTEST_SET_MAIN_SOURCE_PATH
return QTest::qExec(&tc, argc, argv);
}
void TestGui::initTestCase()
{
QVERIFY(Crypto::init());
Config::createTempFileInstance();
QLocale::setDefault(QLocale::c());
Application::bootstrap();
m_mainWindow.reset(new MainWindow());
m_tabWidget = m_mainWindow->findChild<DatabaseTabWidget*>("tabWidget");
m_statusBarLabel = m_mainWindow->findChild<QLabel*>("statusBarLabel");
m_mainWindow->show();
m_mainWindow->resize(1024, 768);
}
// Every test starts with resetting config settings and opening the temp database
void TestGui::init()
{
// Reset config to defaults
config()->resetToDefaults();
// Disable autosave so we can test the modified file indicator
config()->set(Config::AutoSaveAfterEveryChange, false);
config()->set(Config::AutoSaveOnExit, false);
// Enable the tray icon so we can test hiding/restoring the windowQByteArray
config()->set(Config::GUI_ShowTrayIcon, true);
// Disable the update check first time alert
config()->set(Config::UpdateCheckMessageShown, true);
// Disable quick unlock
config()->set(Config::Security_QuickUnlock, false);
// Disable atomic saves to prevent transient errors on some platforms
config()->set(Config::UseAtomicSaves, false);
// Disable showing expired entries on unlock
config()->set(Config::GUI_ShowExpiredEntriesOnDatabaseUnlock, false);
// Copy the test database file to the temporary file
auto origFilePath = QDir(KEEPASSX_TEST_DATA_DIR).absoluteFilePath("NewDatabase.kdbx");
QVERIFY(m_dbFile.copyFromFile(origFilePath));
m_dbFileName = QFileInfo(m_dbFile.fileName()).fileName();
m_dbFilePath = m_dbFile.fileName();
// make sure window is activated or focus tests may fail
m_mainWindow->activateWindow();
QApplication::processEvents();
fileDialog()->setNextFileName(m_dbFilePath);
triggerAction("actionDatabaseOpen");
QApplication::processEvents();
m_dbWidget = m_tabWidget->currentDatabaseWidget();
auto* databaseOpenWidget = m_tabWidget->currentDatabaseWidget()->findChild<QWidget*>("databaseOpenWidget");
QVERIFY(databaseOpenWidget);
// editPassword is not QLineEdit anymore but PasswordWidget
auto* editPassword =
databaseOpenWidget->findChild<PasswordWidget*>("editPassword")->findChild<QLineEdit*>("passwordEdit");
QVERIFY(editPassword);
editPassword->setFocus();
QTRY_VERIFY(editPassword->hasFocus());
QTest::keyClicks(editPassword, "a");
QTest::keyClick(editPassword, Qt::Key_Enter);
QTRY_VERIFY(!m_dbWidget->isLocked());
m_db = m_dbWidget->database();
QApplication::processEvents();
}
// Every test ends with closing the temp database without saving
void TestGui::cleanup()
{
// DO NOT save the database
m_db->markAsClean();
MessageBox::setNextAnswer(MessageBox::No);
triggerAction("actionDatabaseClose");
QApplication::processEvents();
MessageBox::setNextAnswer(MessageBox::NoButton);
if (m_dbWidget) {
delete m_dbWidget;
}
}
void TestGui::cleanupTestCase()
{
m_dbFile.remove();
}
void TestGui::testSettingsDefaultTabOrder()
{
// check application settings default tab order
triggerAction("actionSettings");
auto* settingsWidget = m_mainWindow->findChild<ApplicationSettingsWidget*>();
QVERIFY(settingsWidget->isVisible());
QCOMPARE(settingsWidget->findChild<CategoryListWidget*>("categoryList")->currentCategory(), 0);
for (auto* w : settingsWidget->findChildren<QTabWidget*>()) {
if (w->currentIndex() != 0) {
QFAIL("Application settings contain QTabWidgets whose default index is not 0");
}
}
QTest::keyClick(settingsWidget, Qt::Key::Key_Escape);
// check database settings default tab order
triggerAction("actionDatabaseSettings");
auto* dbSettingsWidget = m_mainWindow->findChild<DatabaseSettingsDialog*>();
QVERIFY(dbSettingsWidget->isVisible());
QCOMPARE(dbSettingsWidget->findChild<CategoryListWidget*>("categoryList")->currentCategory(), 0);
for (auto* w : dbSettingsWidget->findChildren<QTabWidget*>()) {
if (w->currentIndex() != 0 && w->objectName() != "encryptionSettingsTabWidget") {
QFAIL("Database settings contain QTabWidgets whose default index is not 0");
}
}
QTest::keyClick(dbSettingsWidget, Qt::Key::Key_Escape);
}
void TestGui::testCreateDatabase()
{
TEST_MODAL_NO_WAIT(
NewDatabaseWizard * wizard; QTRY_VERIFY(wizard = m_tabWidget->findChild<NewDatabaseWizard*>());
QTest::keyClicks(wizard->currentPage()->findChild<QLineEdit*>("databaseName"), "Test Name");
QTest::keyClicks(wizard->currentPage()->findChild<QLineEdit*>("databaseDescription"), "Test Description");
QCOMPARE(wizard->currentId(), 0);
QTest::keyClick(wizard, Qt::Key_Enter);
QCOMPARE(wizard->currentId(), 1);
// Check that basic encryption settings are visible
auto decryptionTimeSlider = wizard->currentPage()->findChild<QSlider*>("decryptionTimeSlider");
auto algorithmComboBox = wizard->currentPage()->findChild<QComboBox*>("algorithmComboBox");
QTRY_VERIFY(decryptionTimeSlider->isVisible());
QVERIFY(!algorithmComboBox->isVisible());
// Set the encryption settings to the advanced view
auto encryptionSettings = wizard->currentPage()->findChild<QTabWidget*>("encryptionSettingsTabWidget");
auto advancedTab = encryptionSettings->findChild<QWidget*>("advancedTab");
encryptionSettings->setCurrentWidget(advancedTab);
QTRY_VERIFY(!decryptionTimeSlider->isVisible());
QVERIFY(algorithmComboBox->isVisible());
auto rounds = wizard->currentPage()->findChild<QSpinBox*>("transformRoundsSpinBox");
QVERIFY(rounds);
QVERIFY(rounds->isVisible());
QTest::mouseClick(rounds, Qt::MouseButton::LeftButton);
QTest::keyClick(rounds, Qt::Key_A, Qt::ControlModifier);
QTest::keyClicks(rounds, "2");
QTest::keyClick(rounds, Qt::Key_Tab);
QTest::keyClick(rounds, Qt::Key_Tab);
auto memory = wizard->currentPage()->findChild<QSpinBox*>("memorySpinBox");
QVERIFY(memory);
QVERIFY(memory->isVisible());
QTest::mouseClick(memory, Qt::MouseButton::LeftButton);
QTest::keyClick(memory, Qt::Key_A, Qt::ControlModifier);
QTest::keyClicks(memory, "50");
QTest::keyClick(memory, Qt::Key_Tab);
auto parallelism = wizard->currentPage()->findChild<QSpinBox*>("parallelismSpinBox");
QVERIFY(parallelism);
QVERIFY(parallelism->isVisible());
QTest::mouseClick(parallelism, Qt::MouseButton::LeftButton);
QTest::keyClick(parallelism, Qt::Key_A, Qt::ControlModifier);
QTest::keyClicks(parallelism, "1");
QTest::keyClick(parallelism, Qt::Key_Enter);
QCOMPARE(wizard->currentId(), 2);
// enter password
auto* passwordWidget = wizard->currentPage()->findChild<PasswordEditWidget*>();
QCOMPARE(passwordWidget->visiblePage(), KeyFileEditWidget::Page::Edit);
auto* passwordEdit =
passwordWidget->findChild<PasswordWidget*>("enterPasswordEdit")->findChild<QLineEdit*>("passwordEdit");
auto* passwordRepeatEdit =
passwordWidget->findChild<PasswordWidget*>("repeatPasswordEdit")->findChild<QLineEdit*>("passwordEdit");
QTRY_VERIFY(passwordEdit->isVisible());
QTRY_VERIFY(passwordEdit->hasFocus());
QTest::keyClicks(passwordEdit, "test");
QTest::keyClick(passwordEdit, Qt::Key::Key_Tab);
QTest::keyClicks(passwordRepeatEdit, "test");
// add key file
auto* additionalOptionsButton = wizard->currentPage()->findChild<QPushButton*>("additionalKeyOptionsToggle");
auto* keyFileWidget = wizard->currentPage()->findChild<KeyFileEditWidget*>();
QVERIFY(additionalOptionsButton->isVisible());
QTest::mouseClick(additionalOptionsButton, Qt::MouseButton::LeftButton);
QTRY_VERIFY(keyFileWidget->isVisible());
QTRY_VERIFY(!additionalOptionsButton->isVisible());
QCOMPARE(passwordWidget->visiblePage(), KeyFileEditWidget::Page::Edit);
QTest::mouseClick(keyFileWidget->findChild<QPushButton*>("addButton"), Qt::MouseButton::LeftButton);
auto* fileEdit = keyFileWidget->findChild<QLineEdit*>("keyFileLineEdit");
QTRY_VERIFY(fileEdit);
QTRY_VERIFY(fileEdit->isVisible());
fileDialog()->setNextFileName(QString("%1/%2").arg(QString(KEEPASSX_TEST_DATA_DIR), "FileKeyHashed.key"));
QTest::keyClick(keyFileWidget->findChild<QPushButton*>("addButton"), Qt::Key::Key_Enter);
QVERIFY(fileEdit->hasFocus());
auto* browseButton = keyFileWidget->findChild<QPushButton*>("browseKeyFileButton");
QTest::keyClick(browseButton, Qt::Key::Key_Enter);
QCOMPARE(fileEdit->text(), QString("%1/%2").arg(QString(KEEPASSX_TEST_DATA_DIR), "FileKeyHashed.key"));
// save database to temporary file
TemporaryFile tmpFile;
QVERIFY(tmpFile.open());
tmpFile.close();
fileDialog()->setNextFileName(tmpFile.fileName());
// click Continue on the warning due to weak password
MessageBox::setNextAnswer(MessageBox::ContinueWithWeakPass);
QTest::keyClick(fileEdit, Qt::Key::Key_Enter);
tmpFile.remove(););
triggerAction("actionDatabaseNew");
QCOMPARE(m_tabWidget->count(), 2);
checkStatusBarText("0 Ent");
// there is a new empty db
m_db = m_tabWidget->currentDatabaseWidget()->database();
QCOMPARE(m_db->rootGroup()->children().size(), 0);
// check meta data
QCOMPARE(m_db->metadata()->name(), QString("Test Name"));
QCOMPARE(m_db->metadata()->description(), QString("Test Description"));
// check key and encryption
QCOMPARE(m_db->key()->keys().size(), 2);
QCOMPARE(m_db->kdf()->rounds(), 2);
QCOMPARE(m_db->kdf()->uuid(), KeePass2::KDF_ARGON2D);
QCOMPARE(m_db->cipher(), KeePass2::CIPHER_AES256);
auto compositeKey = QSharedPointer<CompositeKey>::create();
compositeKey->addKey(QSharedPointer<PasswordKey>::create("test"));
auto fileKey = QSharedPointer<FileKey>::create();
fileKey->load(QString("%1/%2").arg(QString(KEEPASSX_TEST_DATA_DIR), "FileKeyHashed.key"));
compositeKey->addKey(fileKey);
QCOMPARE(m_db->key()->rawKey(), compositeKey->rawKey());
checkStatusBarText("0 Ent");
// Test the switching to other DB tab
m_tabWidget->setCurrentIndex(0);
checkStatusBarText("1 Ent");
m_tabWidget->setCurrentIndex(1);
checkStatusBarText("0 Ent");
// close the new database
MessageBox::setNextAnswer(MessageBox::No);
triggerAction("actionDatabaseClose");
// Wait for dialog to terminate
QTRY_VERIFY(dialogFinished);
}
void TestGui::testMergeDatabase()
{
// It is safe to ignore the warning this line produces
QSignalSpy dbMergeSpy(m_dbWidget.data(), SIGNAL(databaseMerged(QSharedPointer<Database>)));
QApplication::processEvents();
// set file to merge from
fileDialog()->setNextFileName(QString(KEEPASSX_TEST_DATA_DIR).append("/MergeDatabase.kdbx"));
triggerAction("actionDatabaseMerge");
QTRY_COMPARE(QApplication::focusWidget()->objectName(), QString("passwordEdit"));
auto* editPasswordMerge = QApplication::focusWidget();
QVERIFY(editPasswordMerge->isVisible());
QTest::keyClicks(editPasswordMerge, "a");
QTest::keyClick(editPasswordMerge, Qt::Key_Enter);
QTRY_COMPARE(dbMergeSpy.count(), 1);
QTRY_VERIFY(m_tabWidget->tabText(m_tabWidget->currentIndex()).contains("*"));
m_db = m_tabWidget->currentDatabaseWidget()->database();
// there are seven child groups of the root group
QCOMPARE(m_db->rootGroup()->children().size(), 7);
// the merged group should contain an entry
QCOMPARE(m_db->rootGroup()->children().at(6)->entries().size(), 1);
// the General group contains one entry merged from the other db
QCOMPARE(m_db->rootGroup()->findChildByName("General")->entries().size(), 1);
}
void TestGui::prepareAndTriggerRemoteSync(const QString& sourceToSync)
{
auto* menuRemoteSync = m_mainWindow->findChild<QMenu*>("menuRemoteSync");
QSignalSpy remoteAboutToShow(menuRemoteSync, &QMenu::aboutToShow);
QApplication::processEvents();
// create remote settings in settings dialog
triggerAction("actionDatabaseSettings");
auto* dbSettingsDialog = m_dbWidget->findChild<QWidget*>("databaseSettingsDialog");
auto* dbSettingsCategoryList = dbSettingsDialog->findChild<CategoryListWidget*>("categoryList");
auto* dbSettingsStackedWidget = dbSettingsDialog->findChild<QStackedWidget*>("stackedWidget");
dbSettingsCategoryList->setCurrentCategory(2); // go into remote category
auto name = "testCommand";
auto* nameEdit = dbSettingsStackedWidget->findChild<QLineEdit*>("nameLineEdit");
auto* downloadCommandEdit = dbSettingsStackedWidget->findChild<QLineEdit*>("downloadCommand");
QVERIFY(downloadCommandEdit != nullptr);
downloadCommandEdit->setText(sourceToSync);
nameEdit->setText(name);
auto* saveSettingsButton = dbSettingsStackedWidget->findChild<QPushButton*>("saveSettingsButton");
QVERIFY(saveSettingsButton != nullptr);
QTest::mouseClick(saveSettingsButton, Qt::LeftButton);
// find and click dialog OK button
auto buttons = dbSettingsDialog->findChild<QDialogButtonBox*>()->findChildren<QPushButton*>();
for (QPushButton* b : buttons) {
if (b->text() == "OK") {
QTest::mouseClick(b, Qt::LeftButton);
break;
}
}
QTRY_COMPARE(m_dbWidget->getRemoteParams().size(), 1);
// trigger aboutToShow to create remote actions
menuRemoteSync->popup(QPoint(0, 0));
QApplication::processEvents();
QTRY_COMPARE(remoteAboutToShow.count(), 1);
// close the opened menu
QTest::keyClick(menuRemoteSync, Qt::Key::Key_Escape);
// trigger remote sync action
for (auto* remoteAction : menuRemoteSync->actions()) {
if (remoteAction->text() == name) {
remoteAction->trigger();
break;
}
}
QApplication::processEvents();
}
void TestGui::testRemoteSyncDatabaseSameKey()
{
QString sourceToSync = "sftp user@server:Database.kdbx";
RemoteHandler::setRemoteProcessFunc([sourceToSync](QObject* parent) {
return QScopedPointer<RemoteProcess>(
new MockRemoteProcess(parent, QString(KEEPASSX_TEST_DATA_DIR).append("/SyncDatabase.kdbx")));
});
QSignalSpy dbSyncSpy(m_dbWidget.data(), &DatabaseWidget::databaseSyncCompleted);
prepareAndTriggerRemoteSync(sourceToSync);
QTRY_COMPARE(dbSyncSpy.count(), 1);
m_db = m_tabWidget->currentDatabaseWidget()->database();
// there are seven child groups of the root group
QCOMPARE(m_db->rootGroup()->children().size(), 7);
// the merged group should contain an entry
QCOMPARE(m_db->rootGroup()->children().at(6)->entries().size(), 1);
// the General group contains one entry merged from the other db
QCOMPARE(m_db->rootGroup()->findChildByName("General")->entries().size(), 1);
}
void TestGui::testRemoteSyncDatabaseRequiresPassword()
{
QString sourceToSync = "sftp user@server:Database.kdbx";
RemoteHandler::setRemoteProcessFunc([sourceToSync](QObject* parent) {
return QScopedPointer<RemoteProcess>(new MockRemoteProcess(
parent, QString(KEEPASSX_TEST_DATA_DIR).append("/SyncDatabaseDifferentPassword.kdbx")));
});
QSignalSpy dbSyncSpy(m_dbWidget.data(), &DatabaseWidget::databaseSyncCompleted);
prepareAndTriggerRemoteSync(sourceToSync);
// need to process more events as opening with the same key did not work and more events have been fired
QApplication::processEvents(QEventLoop::WaitForMoreEvents);
QTRY_COMPARE(QApplication::focusWidget()->objectName(), QString("passwordEdit"));
auto* editPasswordSync = QApplication::focusWidget();
QVERIFY(editPasswordSync->isVisible());
QTest::keyClicks(editPasswordSync, "b");
QTest::keyClick(editPasswordSync, Qt::Key_Enter);
QTRY_COMPARE(dbSyncSpy.count(), 1);
m_db = m_tabWidget->currentDatabaseWidget()->database();
// there are seven child groups of the root group
QCOMPARE(m_db->rootGroup()->children().size(), 7);
// the merged group should contain an entry
QCOMPARE(m_db->rootGroup()->children().at(6)->entries().size(), 1);
// the General group contains one entry merged from the other db
QCOMPARE(m_db->rootGroup()->findChildByName("General")->entries().size(), 1);
}
void TestGui::testAutoreloadDatabase()
{
config()->set(Config::AutoReloadOnChange, false);
// Test accepting new file in autoreload
MessageBox::setNextAnswer(MessageBox::Yes);
// Overwrite the current database with the temp data
QVERIFY(m_dbFile.copyFromFile(QString(KEEPASSX_TEST_DATA_DIR).append("/MergeDatabase.kdbx")));
QTRY_VERIFY(m_db != m_dbWidget->database());
m_db = m_dbWidget->database();
// the General group contains one entry from the new db data
QCOMPARE(m_db->rootGroup()->findChildByName("General")->entries().size(), 1);
QVERIFY(!m_tabWidget->tabText(m_tabWidget->currentIndex()).endsWith("*"));
// Reset the state
cleanup();
init();
config()->set(Config::AutoReloadOnChange, false);
// Test rejecting new file in autoreload
MessageBox::setNextAnswer(MessageBox::No);
// Overwrite the current database with the temp data
QVERIFY(m_dbFile.copyFromFile(QString(KEEPASSX_TEST_DATA_DIR).append("/MergeDatabase.kdbx")));
// Ensure the merge did not take place
QCOMPARE(m_db->rootGroup()->findChildByName("General")->entries().size(), 0);
QTRY_VERIFY(m_tabWidget->tabText(m_tabWidget->currentIndex()).endsWith("*"));
// Reset the state
cleanup();
init();
// Test accepting a merge of edits into autoreload
// Turn on autoload so we only get one messagebox (for the merge)
config()->set(Config::AutoReloadOnChange, true);
// Modify some entries
testEditEntry();
// This is saying yes to merging the entries
MessageBox::setNextAnswer(MessageBox::Merge);
// Overwrite the current database with the temp data
QVERIFY(m_dbFile.copyFromFile(QString(KEEPASSX_TEST_DATA_DIR).append("/MergeDatabase.kdbx")));
QTRY_VERIFY(m_db != m_dbWidget->database());
m_db = m_dbWidget->database();
QCOMPARE(m_db->rootGroup()->findChildByName("General")->entries().size(), 1);
QTRY_VERIFY(m_tabWidget->tabText(m_tabWidget->currentIndex()).endsWith("*"));
}
void TestGui::testTabs()
{
QCOMPARE(m_tabWidget->count(), 1);
QCOMPARE(m_tabWidget->tabText(m_tabWidget->currentIndex()), m_dbFileName);
}
void TestGui::testEditEntry()
{
auto* toolBar = m_mainWindow->findChild<QToolBar*>("toolBar");
auto* entryView = m_dbWidget->findChild<EntryView*>("entryView");
entryView->setFocus();
QVERIFY(entryView->hasFocus());
// Select the first entry in the database
QModelIndex entryItem = entryView->model()->index(0, 1);
Entry* entry = entryView->entryFromIndex(entryItem);
clickIndex(entryItem, entryView, Qt::LeftButton);
// Confirm the edit action button is enabled
auto* entryEditAction = m_mainWindow->findChild<QAction*>("actionEntryEdit");
QVERIFY(entryEditAction->isEnabled());
QWidget* entryEditWidget = toolBar->widgetForAction(entryEditAction);
QVERIFY(entryEditWidget->isVisible());
QVERIFY(entryEditWidget->isEnabled());
// Record current history count
int editCount = entry->historyItems().size();
// Edit the first entry ("Sample Entry")
QTest::mouseClick(entryEditWidget, Qt::LeftButton);
QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::EditMode);
auto* editEntryWidget = m_dbWidget->findChild<EditEntryWidget*>("editEntryWidget");
auto* titleEdit = editEntryWidget->findChild<QLineEdit*>("titleEdit");
QTest::keyClicks(titleEdit, "_test");
auto* editEntryWidgetButtonBox = editEntryWidget->findChild<QDialogButtonBox*>("buttonBox");
QVERIFY(editEntryWidgetButtonBox);
auto* okButton = editEntryWidgetButtonBox->button(QDialogButtonBox::Ok);
QVERIFY(okButton);
auto* applyButton = editEntryWidgetButtonBox->button(QDialogButtonBox::Apply);
QVERIFY(applyButton);
// Apply the edit
QTRY_VERIFY(applyButton->isEnabled());
QTest::mouseClick(applyButton, Qt::LeftButton);
QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::EditMode);
QCOMPARE(entry->title(), QString("Sample Entry_test"));
QCOMPARE(entry->historyItems().size(), ++editCount);
QVERIFY(!applyButton->isEnabled());
// Test the "known bad" checkbox
editEntryWidget->setCurrentPage(1);
auto excludeReportsCheckBox = editEntryWidget->findChild<QCheckBox*>("excludeReportsCheckBox");
QVERIFY(excludeReportsCheckBox);
QCOMPARE(excludeReportsCheckBox->isChecked(), false);
excludeReportsCheckBox->setChecked(true);
QTest::mouseClick(applyButton, Qt::LeftButton);
QCOMPARE(entry->historyItems().size(), ++editCount);
QVERIFY(entry->excludeFromReports());
// Test tags
auto* tags = editEntryWidget->findChild<TagsEdit*>("tagsList");
QTest::keyClicks(tags, "_tag1");
QTest::keyClick(tags, Qt::Key_Return);
QCOMPARE(tags->tags().last(), QString("_tag1"));
QTest::keyClicks(tags, "tag 2"); // adds another tag
QTest::keyClick(tags, Qt::Key_Return);
QCOMPARE(tags->tags().last(), QString("tag 2"));
QTest::keyClick(tags, Qt::Key_Backspace); // Back into editing last tag
QTest::keyClicks(tags, "_is!awesome");
QTest::keyClick(tags, Qt::Key_Return);
QCOMPARE(tags->tags().last(), QString("tag 2_is!awesome"));
// Test entry colors (simulate choosing a color)
editEntryWidget->setCurrentPage(1);
auto fgColor = QString("#FF0000");
auto bgColor = QString("#0000FF");
// Set foreground color
auto colorButton = editEntryWidget->findChild<QPushButton*>("fgColorButton");
auto colorCheckBox = editEntryWidget->findChild<QCheckBox*>("fgColorCheckBox");
colorButton->setProperty("color", fgColor);
colorCheckBox->setChecked(true);
// Set background color
colorButton = editEntryWidget->findChild<QPushButton*>("bgColorButton");
colorCheckBox = editEntryWidget->findChild<QCheckBox*>("bgColorCheckBox");
colorButton->setProperty("color", bgColor);
colorCheckBox->setChecked(true);
QTest::mouseClick(applyButton, Qt::LeftButton);
QCOMPARE(entry->historyItems().size(), ++editCount);
// Test protected attributes
editEntryWidget->setCurrentPage(1);
auto* attrTextEdit = editEntryWidget->findChild<QPlainTextEdit*>("attributesEdit");
QTest::mouseClick(editEntryWidget->findChild<QAbstractButton*>("addAttributeButton"), Qt::LeftButton);
QString attrText = "TEST TEXT";
QTest::keyClicks(attrTextEdit, attrText);
QCOMPARE(attrTextEdit->toPlainText(), attrText);
QTest::mouseClick(editEntryWidget->findChild<QAbstractButton*>("protectAttributeButton"), Qt::LeftButton);
QVERIFY(attrTextEdit->toPlainText().contains("PROTECTED"));
QTest::mouseClick(editEntryWidget->findChild<QAbstractButton*>("revealAttributeButton"), Qt::LeftButton);
QCOMPARE(attrTextEdit->toPlainText(), attrText);
editEntryWidget->setCurrentPage(0);
// Save the edit (press OK)
QTest::mouseClick(okButton, Qt::LeftButton);
QApplication::processEvents();
// Confirm edit was made
QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::ViewMode);
QCOMPARE(entry->title(), QString("Sample Entry_test"));
QCOMPARE(entry->foregroundColor().toUpper(), fgColor.toUpper());
QCOMPARE(entryItem.data(Qt::ForegroundRole), QVariant(fgColor));
QCOMPARE(entry->backgroundColor().toUpper(), bgColor.toUpper());
QCOMPARE(entryItem.data(Qt::BackgroundRole), QVariant(bgColor));
QCOMPARE(entry->historyItems().size(), ++editCount);
// Confirm modified indicator is showing
QTRY_COMPARE(m_tabWidget->tabText(m_tabWidget->currentIndex()), QString("%1*").arg(m_dbFileName));
// Test copy & paste newline sanitization
QTest::mouseClick(entryEditWidget, Qt::LeftButton);
okButton = editEntryWidgetButtonBox->button(QDialogButtonBox::Ok);
QVERIFY(okButton);
QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::EditMode);
titleEdit->setText("multiline\ntitle");
editEntryWidget->findChild<QComboBox*>("usernameComboBox")->lineEdit()->setText("multiline\nusername");
editEntryWidget->findChild<PasswordWidget*>("passwordEdit")->setText("multiline\npassword");
editEntryWidget->findChild<QLineEdit*>("urlEdit")->setText("multiline\nurl");
QTest::mouseClick(okButton, Qt::LeftButton);
QCOMPARE(entry->title(), QString("multiline title"));
QCOMPARE(entry->username(), QString("multiline username"));
// here we keep newlines, so users can't lock themselves out accidentally
QCOMPARE(entry->password(), QString("multiline\npassword"));
QCOMPARE(entry->url(), QString("multiline url"));
}
void TestGui::testSearchEditEntry()
{
// Regression test for Issue #1447 -- Uses example from issue description
// Find buttons for group creation
auto* editGroupWidget = m_dbWidget->findChild<EditGroupWidget*>("editGroupWidget");
auto* nameEdit = editGroupWidget->findChild<QLineEdit*>("editName");
auto* editGroupWidgetButtonBox = editGroupWidget->findChild<QDialogButtonBox*>("buttonBox");
// Add groups "Good" and "Bad"
m_dbWidget->createGroup();
QTest::keyClicks(nameEdit, "Good");
QTest::mouseClick(editGroupWidgetButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton);
m_dbWidget->groupView()->setCurrentGroup(m_db->rootGroup()); // Makes "Good" and "Bad" on the same level
m_dbWidget->createGroup();
QTest::keyClicks(nameEdit, "Bad");
QTest::mouseClick(editGroupWidgetButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton);
m_dbWidget->groupView()->setCurrentGroup(m_db->rootGroup());
// Find buttons for entry creation
auto* toolBar = m_mainWindow->findChild<QToolBar*>("toolBar");
QWidget* entryNewWidget = toolBar->widgetForAction(m_mainWindow->findChild<QAction*>("actionEntryNew"));
auto* editEntryWidget = m_dbWidget->findChild<EditEntryWidget*>("editEntryWidget");
auto* titleEdit = editEntryWidget->findChild<QLineEdit*>("titleEdit");
auto* editEntryWidgetButtonBox = editEntryWidget->findChild<QDialogButtonBox*>("buttonBox");
// Create "Doggy" in "Good"
Group* goodGroup = m_dbWidget->currentGroup()->findChildByName(QString("Good"));
m_dbWidget->groupView()->setCurrentGroup(goodGroup);
QTest::mouseClick(entryNewWidget, Qt::LeftButton);
QTest::keyClicks(titleEdit, "Doggy");
QTest::mouseClick(editEntryWidgetButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton);
// Select "Bad" group in groupView
Group* badGroup = m_db->rootGroup()->findChildByName(QString("Bad"));
m_dbWidget->groupView()->setCurrentGroup(badGroup);
// Search for "Doggy" entry
auto* searchWidget = toolBar->findChild<SearchWidget*>("SearchWidget");
auto* searchTextEdit = searchWidget->findChild<QLineEdit*>("searchEdit");
QTest::mouseClick(searchTextEdit, Qt::LeftButton);
QTest::keyClicks(searchTextEdit, "Doggy");
QTRY_VERIFY(m_dbWidget->isSearchActive());
// Goto "Doggy"'s edit view
QTest::keyClick(searchTextEdit, Qt::Key_Return);
QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::EditMode);
// Check the path in header is "parent-group > entry"
QCOMPARE(m_dbWidget->findChild<EditEntryWidget*>("editEntryWidget")->findChild<QLabel*>("headerLabel")->text(),
QStringLiteral("Good \u2022 Doggy \u2022 Edit entry"));
}
void TestGui::testAddEntry()
{
auto* toolBar = m_mainWindow->findChild<QToolBar*>("toolBar");
auto* entryView = m_dbWidget->findChild<EntryView*>("entryView");
// Given the status bar label with initial number of entries.
checkStatusBarText("1 Ent");
// Find the new entry action
auto* entryNewAction = m_mainWindow->findChild<QAction*>("actionEntryNew");
QVERIFY(entryNewAction->isEnabled());
// Find the button associated with the new entry action
QWidget* entryNewWidget = toolBar->widgetForAction(entryNewAction);
QVERIFY(entryNewWidget->isVisible());
QVERIFY(entryNewWidget->isEnabled());
// Click the new entry button and check that we enter edit mode
QTest::mouseClick(entryNewWidget, Qt::LeftButton);
QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::EditMode);
// Add entry "test" and confirm added
auto* editEntryWidget = m_dbWidget->findChild<EditEntryWidget*>("editEntryWidget");
auto* titleEdit = editEntryWidget->findChild<QLineEdit*>("titleEdit");
QTest::keyClicks(titleEdit, "test");
auto* usernameComboBox = editEntryWidget->findChild<QComboBox*>("usernameComboBox");
QVERIFY(usernameComboBox);
QTest::mouseClick(usernameComboBox, Qt::LeftButton);
QTest::keyClicks(usernameComboBox, "AutocompletionUsername");
auto* editEntryWidgetButtonBox = editEntryWidget->findChild<QDialogButtonBox*>("buttonBox");
QTest::mouseClick(editEntryWidgetButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton);
QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::ViewMode);
QModelIndex item = entryView->model()->index(1, 1);
Entry* entry = entryView->entryFromIndex(item);
QCOMPARE(entry->title(), QString("test"));
QCOMPARE(entry->username(), QString("AutocompletionUsername"));
QCOMPARE(entry->historyItems().size(), 0);
m_db->updateCommonUsernames();
// Then the status bar label should be updated with incremented number of entries.
checkStatusBarText("2 Ent");
// Add entry "something 2"
QTest::mouseClick(entryNewWidget, Qt::LeftButton);
QTest::keyClicks(titleEdit, "something 2");
QTest::mouseClick(usernameComboBox, Qt::LeftButton);
QTest::keyClicks(usernameComboBox, "Auto");
QTest::keyPress(usernameComboBox, Qt::Key_Right);
auto* passwordEdit =
editEntryWidget->findChild<PasswordWidget*>("passwordEdit")->findChild<QLineEdit*>("passwordEdit");
QTest::keyClicks(passwordEdit, "something 2");
QTest::mouseClick(editEntryWidgetButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton);
QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::ViewMode);
item = entryView->model()->index(1, 1);
entry = entryView->entryFromIndex(item);
QCOMPARE(entry->title(), QString("something 2"));
QCOMPARE(entry->username(), QString("AutocompletionUsername"));
QCOMPARE(entry->historyItems().size(), 0);
// Add entry "something 5" but click cancel button (does NOT add entry)
QTest::mouseClick(entryNewWidget, Qt::LeftButton);
QTest::keyClicks(titleEdit, "something 5");
MessageBox::setNextAnswer(MessageBox::Discard);
QTest::mouseClick(editEntryWidgetButtonBox->button(QDialogButtonBox::Cancel), Qt::LeftButton);
QApplication::processEvents();
// Confirm no changed entry count
QTRY_COMPARE(entryView->model()->rowCount(), 3);
}
void TestGui::testPasswordEntryEntropy_data()
{
QTest::addColumn<QString>("password");
QTest::addColumn<QString>("expectedStrengthLabel");
QTest::newRow("Empty password") << ""
<< "Password Quality: Poor";
QTest::newRow("Well-known password") << "hello"
<< "Password Quality: Poor";
QTest::newRow("Password composed of well-known words.") << "helloworld"
<< "Password Quality: Poor";
QTest::newRow("Password composed of well-known words with number.") << "password1"
<< "Password Quality: Poor";
QTest::newRow("Password out of small character space.") << "D0g.................."
<< "Password Quality: Poor";
QTest::newRow("XKCD, easy substitutions.") << "Tr0ub4dour&3"
<< "Password Quality: Poor";
QTest::newRow("XKCD, word generator.") << "correcthorsebatterystaple"
<< "Password Quality: Weak";
QTest::newRow("Random characters, medium length.") << "YQC3kbXbjC652dTDH"
<< "Password Quality: Good";
QTest::newRow("Random characters, long.") << "Bs5ZFfthWzR8DGFEjaCM6bGqhmCT4km"
<< "Password Quality: Excellent";
QTest::newRow("Long password using Zxcvbn chunk estimation")
<< "quintet-tamper-kinswoman-humility-vengeful-haven-tastiness-aspire-widget-ipad-cussed-reaffirm-ladylike-"
"ashamed-anatomy-daybed-jam-swear-strudel-neatness-stalemate-unbundle-flavored-relation-emergency-underrate-"
"registry-getting-award-unveiled-unshaken-stagnate-cartridge-magnitude-ointment-hardener-enforced-scrubbed-"
"radial-fiddling-envelope-unpaved-moisture-unused-crawlers-quartered-crushed-kangaroo-tiptop-doily"
<< "Password Quality: Excellent";
QTest::newRow("Longer password above Zxcvbn threshold")
<< "quintet-tamper-kinswoman-humility-vengeful-haven-tastiness-aspire-widget-ipad-cussed-reaffirm-ladylike-"
"ashamed-anatomy-daybed-jam-swear-strudel-neatness-stalemate-unbundle-flavored-relation-emergency-underrate-"
"registry-getting-award-unveiled-unshaken-stagnate-cartridge-magnitude-ointment-hardener-enforced-scrubbed-"
"radial-fiddling-envelope-unpaved-moisture-unused-crawlers-quartered-crushed-kangaroo-tiptop-doily-hefty-"
"untie-fidgeting-radiance-twilight-freebase-sulphuric-parrot-decree-monotype-nautical-pout-sip-geometric-"
"crunching-deviancy-festival-hacking-rage-unify-coronary-zigzagged-dwindle-possum-lilly-exhume-daringly-"
"barbell-rage-animate-lapel-emporium-renounce-justifier-relieving-gauze-arrive-alive-collected-immobile-"
"unleash-snowman-gift-expansion-marbles-requisite-excusable-flatness-displace-caloric-sensuous-moustache-"
"sensuous-capillary-aversion-contents-cadet-giggly-amenity-peddling-spotting-drier-mooned-rudder-peroxide-"
"posting-oppressor-scrabble-scorer-whomever-paprika-slapstick-said-spectacle-capture-debate-attire-emcee-"
"unfocused-sympathy-doily-election-ambulance-polish-subtype-grumbling-neon-stooge-reanalyze-rockfish-"
"disparate-decorated-washroom-threefold-muzzle-buckwheat-kerosene-swell-why-reprocess-correct-shady-"
"impatient-slit-banshee-scrubbed-dreadful-unlocking-urologist-hurried-citable-fragment-septic-lapped-"
"prankish-phantom-unpaved-moisture-unused-crawlers-quartered-crushed-kangaroo-lapel-emporium-renounce"
<< "Password Quality: Excellent";
}
void TestGui::testPasswordEntryEntropy()
{
auto* toolBar = m_mainWindow->findChild<QToolBar*>("toolBar");
// Find the new entry action
auto* entryNewAction = m_mainWindow->findChild<QAction*>("actionEntryNew");
QVERIFY(entryNewAction->isEnabled());
// Find the button associated with the new entry action
QWidget* entryNewWidget = toolBar->widgetForAction(entryNewAction);
QVERIFY(entryNewWidget->isVisible());
QVERIFY(entryNewWidget->isEnabled());
// Click the new entry button and check that we enter edit mode
QTest::mouseClick(entryNewWidget, Qt::LeftButton);
QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::EditMode);
// Add entry "test" and confirm added
auto* editEntryWidget = m_dbWidget->findChild<EditEntryWidget*>("editEntryWidget");
auto* titleEdit = editEntryWidget->findChild<QLineEdit*>("titleEdit");
QTest::keyClicks(titleEdit, "test");
// Open the password generator
auto* passwordEdit =
editEntryWidget->findChild<PasswordWidget*>("passwordEdit")->findChild<QLineEdit*>("passwordEdit");
QVERIFY(passwordEdit);
QTest::mouseClick(passwordEdit, Qt::LeftButton);
#ifdef Q_OS_MAC
QTest::keyClick(passwordEdit, Qt::Key_G, Qt::MetaModifier);
#else
QTest::keyClick(passwordEdit, Qt::Key_G, Qt::ControlModifier);
#endif
TEST_MODAL(
PasswordGeneratorWidget * pwGeneratorWidget;
QTRY_VERIFY(pwGeneratorWidget = m_dbWidget->findChild<PasswordGeneratorWidget*>());
// Type in some password
auto* generatedPassword =
pwGeneratorWidget->findChild<PasswordWidget*>("editNewPassword")->findChild<QLineEdit*>("passwordEdit");
auto* entropyLabel = pwGeneratorWidget->findChild<QLabel*>("entropyLabel");
auto* strengthLabel = pwGeneratorWidget->findChild<QLabel*>("strengthLabel");
QFETCH(QString, password);
QFETCH(QString, expectedStrengthLabel);
// Dynamically calculate entropy due to variances with zxcvbn wordlists
PasswordHealth health(password);
auto expectedEntropy = QString("Entropy: %1 bit").arg(QString::number(health.entropy(), 'f', 2));
generatedPassword->setText(password);
QCOMPARE(entropyLabel->text(), expectedEntropy);
QCOMPARE(strengthLabel->text(), expectedStrengthLabel);
QTest::mouseClick(generatedPassword, Qt::LeftButton);
QTest::keyClick(generatedPassword, Qt::Key_Escape););
}
void TestGui::testDicewareEntryEntropy()
{
auto* toolBar = m_mainWindow->findChild<QToolBar*>("toolBar");
// Find the new entry action
auto* entryNewAction = m_mainWindow->findChild<QAction*>("actionEntryNew");
QVERIFY(entryNewAction->isEnabled());
// Find the button associated with the new entry action
QWidget* entryNewWidget = toolBar->widgetForAction(entryNewAction);
QVERIFY(entryNewWidget->isVisible());
QVERIFY(entryNewWidget->isEnabled());
// Click the new entry button and check that we enter edit mode
QTest::mouseClick(entryNewWidget, Qt::LeftButton);
QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::EditMode);
// Add entry "test" and confirm added
auto* editEntryWidget = m_dbWidget->findChild<EditEntryWidget*>("editEntryWidget");
auto* titleEdit = editEntryWidget->findChild<QLineEdit*>("titleEdit");
QTest::keyClicks(titleEdit, "test");
// Open the password generator
auto* passwordEdit = editEntryWidget->findChild<PasswordWidget*>()->findChild<QLineEdit*>("passwordEdit");
QVERIFY(passwordEdit);
QTest::mouseClick(passwordEdit, Qt::LeftButton);
#ifdef Q_OS_MAC
QTest::keyClick(passwordEdit, Qt::Key_G, Qt::MetaModifier);
#else
QTest::keyClick(passwordEdit, Qt::Key_G, Qt::ControlModifier);
#endif
TEST_MODAL(
PasswordGeneratorWidget * pwGeneratorWidget;
QTRY_VERIFY(pwGeneratorWidget = m_dbWidget->findChild<PasswordGeneratorWidget*>());
// Select Diceware
auto* generatedPassword =
pwGeneratorWidget->findChild<PasswordWidget*>("editNewPassword")->findChild<QLineEdit*>("passwordEdit");
auto* tabWidget = pwGeneratorWidget->findChild<QTabWidget*>("tabWidget");
auto* dicewareWidget = pwGeneratorWidget->findChild<QWidget*>("dicewareWidget");
tabWidget->setCurrentWidget(dicewareWidget);
auto* comboBoxWordList = dicewareWidget->findChild<QComboBox*>("comboBoxWordList");
comboBoxWordList->setCurrentText("eff_large.wordlist");
auto* spinBoxWordCount = dicewareWidget->findChild<QSpinBox*>("spinBoxWordCount");
spinBoxWordCount->setValue(6);
// Confirm a password was generated
QVERIFY(!pwGeneratorWidget->getGeneratedPassword().isEmpty());
// Verify entropy and strength
auto* entropyLabel = pwGeneratorWidget->findChild<QLabel*>("entropyLabel");
auto* strengthLabel = pwGeneratorWidget->findChild<QLabel*>("strengthLabel");
auto* wordLengthLabel = pwGeneratorWidget->findChild<QLabel*>("charactersInPassphraseLabel");
QTRY_COMPARE_WITH_TIMEOUT(entropyLabel->text(), QString("Entropy: 77.55 bit"), 200);
QCOMPARE(strengthLabel->text(), QString("Password Quality: Good"));
QCOMPARE(wordLengthLabel->text().toInt(), pwGeneratorWidget->getGeneratedPassword().size());
QTest::mouseClick(generatedPassword, Qt::LeftButton);
QTest::keyClick(generatedPassword, Qt::Key_Escape););
}
void TestGui::testTotp()
{
auto* toolBar = m_mainWindow->findChild<QToolBar*>("toolBar");
auto* entryView = m_dbWidget->findChild<EntryView*>("entryView");
QCOMPARE(entryView->model()->rowCount(), 1);
QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::ViewMode);
QModelIndex item = entryView->model()->index(0, 1);
Entry* entry = entryView->entryFromIndex(item);
clickIndex(item, entryView, Qt::LeftButton);
triggerAction("actionEntrySetupTotp");
auto* setupTotpDialog = m_dbWidget->findChild<TotpSetupDialog*>("TotpSetupDialog");
QApplication::processEvents();
QString exampleSeed = "gezd gnbvgY 3tqojqGEZdgnb vgy3tqoJq===";
QString expectedFinalSeed = exampleSeed.toUpper().remove(" ").remove("=");
auto* seedEdit = setupTotpDialog->findChild<QLineEdit*>("seedEdit");
seedEdit->setText("");
QTest::keyClicks(seedEdit, exampleSeed);
auto* setupTotpButtonBox = setupTotpDialog->findChild<QDialogButtonBox*>("buttonBox");
QTest::mouseClick(setupTotpButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton);
QTRY_VERIFY(!setupTotpDialog->isVisible());
// Make sure the entryView is selected and active
entryView->activateWindow();
QApplication::processEvents();
QTRY_VERIFY(entryView->hasFocus());
auto* entryEditAction = m_mainWindow->findChild<QAction*>("actionEntryEdit");
QWidget* entryEditWidget = toolBar->widgetForAction(entryEditAction);
QVERIFY(entryEditWidget->isVisible());
QVERIFY(entryEditWidget->isEnabled());
QTest::mouseClick(entryEditWidget, Qt::LeftButton);
QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::EditMode);
auto* editEntryWidget = m_dbWidget->findChild<EditEntryWidget*>("editEntryWidget");
editEntryWidget->setCurrentPage(1);
auto* attrTextEdit = editEntryWidget->findChild<QPlainTextEdit*>("attributesEdit");
QTest::mouseClick(editEntryWidget->findChild<QAbstractButton*>("revealAttributeButton"), Qt::LeftButton);
QCOMPARE(attrTextEdit->toPlainText(), expectedFinalSeed);
auto* editEntryWidgetButtonBox = editEntryWidget->findChild<QDialogButtonBox*>("buttonBox");
QTest::mouseClick(editEntryWidgetButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton);
// Test the TOTP value
triggerAction("actionEntryTotp");
auto* totpDialog = m_dbWidget->findChild<TotpDialog*>("TotpDialog");
auto* totpLabel = totpDialog->findChild<QLabel*>("totpLabel");
QTRY_COMPARE(totpLabel->text().replace(" ", ""), entry->totp());
QTest::keyClick(totpDialog, Qt::Key_Escape);
// Test the QR code
triggerAction("actionEntryTotpQRCode");
auto* qrCodeDialog = m_mainWindow->findChild<QDialog*>("entryQrCodeWidget");
QVERIFY(qrCodeDialog);
QVERIFY(qrCodeDialog->isVisible());
auto* qrCodeWidget = qrCodeDialog->findChild<QWidget*>("squareSvgWidget");
QVERIFY2(qrCodeWidget->geometry().width() == qrCodeWidget->geometry().height(), "Initial QR code is not square");
// Test the QR code window resizing, make the dialog bigger.
qrCodeDialog->setFixedSize(800, 600);
QVERIFY2(qrCodeWidget->geometry().width() == qrCodeWidget->geometry().height(), "Resized QR code is not square");
QTest::keyClick(qrCodeDialog, Qt::Key_Escape);
}
void TestGui::testSearch()
{
// Add canned entries for consistent testing
addCannedEntries();
auto* toolBar = m_mainWindow->findChild<QToolBar*>("toolBar");
auto* searchWidget = toolBar->findChild<SearchWidget*>("SearchWidget");
QVERIFY(searchWidget->isEnabled());
auto* searchTextEdit = searchWidget->findChild<QLineEdit*>("searchEdit");
auto* entryView = m_dbWidget->findChild<EntryView*>("entryView");
QVERIFY(entryView->isVisible());
QVERIFY(searchTextEdit->isClearButtonEnabled());
auto* helpButton = searchWidget->findChild<QAction*>("helpIcon");
auto* helpPanel = searchWidget->findChild<QWidget*>("SearchHelpWidget");
QVERIFY(helpButton->isVisible());
QVERIFY(!helpPanel->isVisible());
// Enter search
QTest::mouseClick(searchTextEdit, Qt::LeftButton);
QTRY_VERIFY(searchTextEdit->hasFocus());
// Show/Hide search help
helpButton->trigger();
QTRY_VERIFY(helpPanel->isVisible());
QTest::mouseClick(searchTextEdit, Qt::LeftButton);
QTRY_VERIFY(helpPanel->isVisible());
QApplication::processEvents();
helpButton->trigger();
QTRY_VERIFY(!helpPanel->isVisible());
// Need to re-activate the window after the help test
m_mainWindow->activateWindow();
// Search for "ZZZ"
QTest::keyClicks(searchTextEdit, "ZZZ");
QTRY_COMPARE(searchTextEdit->text(), QString("ZZZ"));
QTRY_VERIFY(m_dbWidget->isSearchActive());
QTRY_COMPARE(entryView->model()->rowCount(), 0);
// Press the search clear button
searchTextEdit->clear();
QTRY_VERIFY(searchTextEdit->text().isEmpty());
QTRY_VERIFY(searchTextEdit->hasFocus());
// Test tag search
searchTextEdit->clear();
QTest::keyClicks(searchTextEdit, "tag: testTag");
QTRY_VERIFY(m_dbWidget->isSearchActive());
QTRY_COMPARE(entryView->model()->rowCount(), 1);
searchTextEdit->clear();
QTRY_VERIFY(searchTextEdit->text().isEmpty());
QTRY_VERIFY(searchTextEdit->hasFocus());
// Escape clears searchedit and retains focus
QTest::keyClicks(searchTextEdit, "ZZZ");
QTest::keyClick(searchTextEdit, Qt::Key_Escape);
QTRY_VERIFY(searchTextEdit->text().isEmpty());
QTRY_VERIFY(searchTextEdit->hasFocus());
QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::ViewMode);
// Search for "some"
QTest::keyClicks(searchTextEdit, "some");
QTRY_VERIFY(m_dbWidget->isSearchActive());
QTRY_COMPARE(entryView->model()->rowCount(), 3);
// Search for "someTHING"
QTest::keyClicks(searchTextEdit, "THING");
QTRY_COMPARE(entryView->model()->rowCount(), 2);
// Press Down to focus on the entry view
QTest::keyClick(searchTextEdit, Qt::Key_Right, Qt::ControlModifier);
QTRY_VERIFY(searchTextEdit->hasFocus());
QTest::keyClick(searchTextEdit, Qt::Key_Down);
QTRY_VERIFY(entryView->hasFocus());
auto* searchedEntry = entryView->currentEntry();
// Restore focus using F3 key and search text selection
QTest::keyClick(m_mainWindow.data(), Qt::Key_F3);
QTRY_VERIFY(searchTextEdit->hasFocus());
QTRY_COMPARE(searchTextEdit->selectedText(), QString("someTHING"));
searchedEntry->setPassword("password");
QClipboard* clipboard = QApplication::clipboard();
// Copy to clipboard: should copy search text (not password)
QTest::keyClick(searchTextEdit, Qt::Key_C, Qt::ControlModifier);
QCOMPARE(clipboard->text(), QString("someTHING"));
// Deselect text and confirm password copies
QTest::mouseClick(searchTextEdit, Qt::LeftButton);
QTRY_VERIFY(searchTextEdit->selectedText().isEmpty());
QTRY_VERIFY(searchTextEdit->hasFocus());
QTest::keyClick(searchTextEdit, Qt::Key_C, Qt::ControlModifier);
QCOMPARE(clipboard->text(), searchedEntry->password());
// Ensure Down focuses on entry view when search text is selected
QTest::keyClick(searchTextEdit, Qt::Key_A, Qt::ControlModifier);
QTest::keyClick(searchTextEdit, Qt::Key_Down);
QTRY_VERIFY(entryView->hasFocus());
QCOMPARE(entryView->currentEntry(), searchedEntry);
// Test that password copies with entry focused
QTest::keyClick(entryView, Qt::Key_C, Qt::ControlModifier);
QCOMPARE(clipboard->text(), searchedEntry->password());
// Refocus back to search edit
QTest::mouseClick(searchTextEdit, Qt::LeftButton);
QTRY_VERIFY(searchTextEdit->hasFocus());
// Select search text and test that password does not copy
searchTextEdit->selectAll();
QTest::keyClick(searchTextEdit, Qt::Key_C, Qt::ControlModifier);
QTRY_COMPARE(clipboard->text(), QString("someTHING"));
// Test case sensitive search
searchWidget->setCaseSensitive(true);
QTRY_COMPARE(entryView->model()->rowCount(), 0);
searchWidget->setCaseSensitive(false);
QTRY_COMPARE(entryView->model()->rowCount(), 2);
// Test group search
searchWidget->setLimitGroup(false);
GroupView* groupView = m_dbWidget->findChild<GroupView*>("groupView");
QCOMPARE(groupView->currentGroup(), m_db->rootGroup());
QModelIndex rootGroupIndex = groupView->model()->index(0, 0);
clickIndex(groupView->model()->index(0, 0, rootGroupIndex), groupView, Qt::LeftButton);
QCOMPARE(groupView->currentGroup()->name(), QString("General"));
// Selecting a group should cancel search
QTRY_COMPARE(entryView->model()->rowCount(), 0);
// Restore search
QTest::keyClick(m_mainWindow.data(), Qt::Key_F, Qt::ControlModifier);
QTest::keyClicks(searchTextEdit, "someTHING");
QTRY_COMPARE(entryView->model()->rowCount(), 2);
// Enable group limiting
searchWidget->setLimitGroup(true);
QTRY_COMPARE(entryView->model()->rowCount(), 0);
// Selecting another group should NOT cancel search
clickIndex(rootGroupIndex, groupView, Qt::LeftButton);
QCOMPARE(groupView->currentGroup(), m_db->rootGroup());
QTRY_COMPARE(entryView->model()->rowCount(), 2);
// reset
searchWidget->setLimitGroup(false);
clickIndex(rootGroupIndex, groupView, Qt::LeftButton);
QCOMPARE(groupView->currentGroup(), m_db->rootGroup());
QVERIFY(!m_dbWidget->isSearchActive());
// check if first entry is selected after search
QTest::keyClicks(searchTextEdit, "some");
QTRY_VERIFY(m_dbWidget->isSearchActive());
QTRY_COMPARE(entryView->selectedEntries().length(), 1);
QModelIndex index_current = entryView->indexFromEntry(entryView->currentEntry());
QTRY_COMPARE(index_current.row(), 0);
// Try to edit the first entry from the search view
// Refocus back to search edit
QTest::mouseClick(searchTextEdit, Qt::LeftButton);
QTRY_VERIFY(searchTextEdit->hasFocus());
QTest::keyClicks(searchTextEdit, "someTHING");
QTRY_VERIFY(m_dbWidget->isSearchActive());
QModelIndex item = entryView->model()->index(0, 1);
Entry* entry = entryView->entryFromIndex(item);
QTest::keyClick(searchTextEdit, Qt::Key_Return);
QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::EditMode);
// Perform the edit and save it
EditEntryWidget* editEntryWidget = m_dbWidget->findChild<EditEntryWidget*>("editEntryWidget");
QLineEdit* titleEdit = editEntryWidget->findChild<QLineEdit*>("titleEdit");
QString origTitle = titleEdit->text();
QTest::keyClicks(titleEdit, "_edited");
QDialogButtonBox* editEntryWidgetButtonBox = editEntryWidget->findChild<QDialogButtonBox*>("buttonBox");
QTest::mouseClick(editEntryWidgetButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton);
// Confirm the edit was made and we are back in search mode
QTRY_VERIFY(m_dbWidget->isSearchActive());
QCOMPARE(entry->title(), origTitle.append("_edited"));
// Cancel search, should return to normal view
QTest::keyClick(m_mainWindow.data(), Qt::Key_Escape);
QTRY_COMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::ViewMode);
}
void TestGui::testDeleteEntry()
{
// Add canned entries for consistent testing
addCannedEntries();
checkStatusBarText("4 Ent");
auto* groupView = m_dbWidget->findChild<GroupView*>("groupView");
auto* entryView = m_dbWidget->findChild<EntryView*>("entryView");
auto* toolBar = m_mainWindow->findChild<QToolBar*>("toolBar");
auto* entryDeleteAction = m_mainWindow->findChild<QAction*>("actionEntryDelete");
QWidget* entryDeleteWidget = toolBar->widgetForAction(entryDeleteAction);
entryView->setFocus();
// Move one entry to the recycling bin
QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::ViewMode);
clickIndex(entryView->model()->index(1, 1), entryView, Qt::LeftButton);
QVERIFY(entryDeleteWidget->isVisible());
QVERIFY(entryDeleteWidget->isEnabled());
QVERIFY(!m_db->metadata()->recycleBin());
// Test with confirmation dialog
if (!config()->get(Config::Security_NoConfirmMoveEntryToRecycleBin).toBool()) {
MessageBox::setNextAnswer(MessageBox::Move);
QTest::mouseClick(entryDeleteWidget, Qt::LeftButton);
QCOMPARE(entryView->model()->rowCount(), 3);
QCOMPARE(m_db->metadata()->recycleBin()->entries().size(), 1);
} else {
// no confirm dialog
QTest::mouseClick(entryDeleteWidget, Qt::LeftButton);
QCOMPARE(entryView->model()->rowCount(), 3);
QCOMPARE(m_db->metadata()->recycleBin()->entries().size(), 1);
}
checkStatusBarText("3 Ent");
// Select multiple entries and move them to the recycling bin
clickIndex(entryView->model()->index(1, 1), entryView, Qt::LeftButton);
clickIndex(entryView->model()->index(2, 1), entryView, Qt::LeftButton, Qt::ControlModifier);
QCOMPARE(entryView->selectionModel()->selectedRows().size(), 2);
if (!config()->get(Config::Security_NoConfirmMoveEntryToRecycleBin).toBool()) {
MessageBox::setNextAnswer(MessageBox::Cancel);
QTest::mouseClick(entryDeleteWidget, Qt::LeftButton);
QCOMPARE(entryView->model()->rowCount(), 3);
QCOMPARE(m_db->metadata()->recycleBin()->entries().size(), 1);
MessageBox::setNextAnswer(MessageBox::Move);
QTest::mouseClick(entryDeleteWidget, Qt::LeftButton);
QCOMPARE(entryView->model()->rowCount(), 1);
QCOMPARE(m_db->metadata()->recycleBin()->entries().size(), 3);
} else {
QTest::mouseClick(entryDeleteWidget, Qt::LeftButton);
QCOMPARE(entryView->model()->rowCount(), 1);
QCOMPARE(m_db->metadata()->recycleBin()->entries().size(), 3);
}
// Go to the recycling bin
QCOMPARE(groupView->currentGroup(), m_db->rootGroup());
QModelIndex rootGroupIndex = groupView->model()->index(0, 0);
clickIndex(groupView->model()->index(groupView->model()->rowCount(rootGroupIndex) - 1, 0, rootGroupIndex),
groupView,
Qt::LeftButton);
QCOMPARE(groupView->currentGroup()->name(), m_db->metadata()->recycleBin()->name());
// Delete one entry from the bin
clickIndex(entryView->model()->index(0, 1), entryView, Qt::LeftButton);
MessageBox::setNextAnswer(MessageBox::Cancel);
QTest::mouseClick(entryDeleteWidget, Qt::LeftButton);
QCOMPARE(entryView->model()->rowCount(), 3);
QCOMPARE(m_db->metadata()->recycleBin()->entries().size(), 3);
MessageBox::setNextAnswer(MessageBox::Delete);
QTest::mouseClick(entryDeleteWidget, Qt::LeftButton);
QCOMPARE(entryView->model()->rowCount(), 2);
QCOMPARE(m_db->metadata()->recycleBin()->entries().size(), 2);
// Select the remaining entries and delete them
clickIndex(entryView->model()->index(0, 1), entryView, Qt::LeftButton);
clickIndex(entryView->model()->index(1, 1), entryView, Qt::LeftButton, Qt::ControlModifier);
MessageBox::setNextAnswer(MessageBox::Delete);
QTest::mouseClick(entryDeleteWidget, Qt::LeftButton);
QCOMPARE(entryView->model()->rowCount(), 0);
QCOMPARE(m_db->metadata()->recycleBin()->entries().size(), 0);
// Ensure the entry preview widget shows the recycling group since all entries are deleted
auto* previewWidget = m_dbWidget->findChild<EntryPreviewWidget*>("previewWidget");
QVERIFY(previewWidget);
auto* groupTitleLabel = previewWidget->findChild<QLabel*>("groupTitleLabel");
QVERIFY(groupTitleLabel);
QTRY_VERIFY(groupTitleLabel->isVisible());
QVERIFY(groupTitleLabel->text().contains(m_db->metadata()->recycleBin()->name()));
// Go back to the root group
clickIndex(groupView->model()->index(0, 0), groupView, Qt::LeftButton);
QCOMPARE(groupView->currentGroup(), m_db->rootGroup());
}
void TestGui::testCloneEntry()
{
auto* entryView = m_dbWidget->findChild<EntryView*>("entryView");
entryView->setFocus();
QCOMPARE(entryView->model()->rowCount(), 1);
QModelIndex item = entryView->model()->index(0, 1);
Entry* entryOrg = entryView->entryFromIndex(item);
clickIndex(item, entryView, Qt::LeftButton);
triggerAction("actionEntryClone");
auto* cloneDialog = m_dbWidget->findChild<CloneDialog*>("CloneDialog");
auto* cloneButtonBox = cloneDialog->findChild<QDialogButtonBox*>("buttonBox");
QTest::mouseClick(cloneButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton);
QCOMPARE(entryView->model()->rowCount(), 2);
Entry* entryClone = entryView->entryFromIndex(entryView->model()->index(1, 1));
QVERIFY(entryOrg->uuid() != entryClone->uuid());
QCOMPARE(entryClone->title(), entryOrg->title() + QString(" - Clone"));
QVERIFY(m_dbWidget->currentSelectedEntry()->uuid() == entryClone->uuid());
}
void TestGui::testEntryPlaceholders()
{
auto* toolBar = m_mainWindow->findChild<QToolBar*>("toolBar");
auto* entryView = m_dbWidget->findChild<EntryView*>("entryView");
// Find the new entry action
auto* entryNewAction = m_mainWindow->findChild<QAction*>("actionEntryNew");
QVERIFY(entryNewAction->isEnabled());
// Find the button associated with the new entry action
QWidget* entryNewWidget = toolBar->widgetForAction(entryNewAction);
QVERIFY(entryNewWidget->isVisible());
QVERIFY(entryNewWidget->isEnabled());
// Click the new entry button and check that we enter edit mode
QTest::mouseClick(entryNewWidget, Qt::LeftButton);
QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::EditMode);
// Add entry "test" and confirm added
auto* editEntryWidget = m_dbWidget->findChild<EditEntryWidget*>("editEntryWidget");
auto* titleEdit = editEntryWidget->findChild<QLineEdit*>("titleEdit");
QTest::keyClicks(titleEdit, "test");
QComboBox* usernameComboBox = editEntryWidget->findChild<QComboBox*>("usernameComboBox");
QTest::keyClicks(usernameComboBox, "john");
QLineEdit* urlEdit = editEntryWidget->findChild<QLineEdit*>("urlEdit");
QTest::keyClicks(urlEdit, "{TITLE}.{USERNAME}");
auto* editEntryWidgetButtonBox = editEntryWidget->findChild<QDialogButtonBox*>("buttonBox");
QTest::mouseClick(editEntryWidgetButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton);
QCOMPARE(entryView->model()->rowCount(), 2);
QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::ViewMode);
QModelIndex item = entryView->model()->index(1, 1);
Entry* entry = entryView->entryFromIndex(item);
QCOMPARE(entry->title(), QString("test"));
QCOMPARE(entry->url(), QString("{TITLE}.{USERNAME}"));
// Test password copy
QClipboard* clipboard = QApplication::clipboard();
m_dbWidget->copyURL();
QTRY_COMPARE(clipboard->text(), QString("test.john"));
}
void TestGui::testDragAndDropEntry()
{
auto entryView = m_dbWidget->findChild<EntryView*>("entryView");
auto groupView = m_dbWidget->findChild<GroupView*>("groupView");
auto groupModel = qobject_cast<GroupModel*>(groupView->model());
QModelIndex sourceIndex = entryView->model()->index(0, 1);
QModelIndex targetIndex = groupModel->index(0, 0, groupModel->index(0, 0));
QVERIFY(sourceIndex.isValid());
QVERIFY(targetIndex.isValid());
auto targetGroup = groupModel->groupFromIndex(targetIndex);
QMimeData mimeData;
QByteArray encoded;
QDataStream stream(&encoded, QIODevice::WriteOnly);
auto entry = entryView->entryFromIndex(sourceIndex);
stream << entry->group()->database()->uuid() << entry->uuid();
mimeData.setData("application/x-keepassx-entry", encoded);
// Test Copy, UUID should change, history remain
QVERIFY(groupModel->dropMimeData(&mimeData, Qt::CopyAction, -1, 0, targetIndex));
// Find the copied entry
auto newEntry = targetGroup->findEntryByPath(entry->title());
QVERIFY(newEntry);
QVERIFY(entry->uuid() != newEntry->uuid());
QCOMPARE(entry->historyItems().count(), newEntry->historyItems().count());
encoded.clear();
entry = entryView->entryFromIndex(sourceIndex);
auto history = entry->historyItems().count();
auto uuid = entry->uuid();
stream << entry->group()->database()->uuid() << entry->uuid();
mimeData.setData("application/x-keepassx-entry", encoded);
// Test Move, entry pointer should remain the same
QCOMPARE(entry->group()->name(), QString("NewDatabase"));
QVERIFY(groupModel->dropMimeData(&mimeData, Qt::MoveAction, -1, 0, targetIndex));
QCOMPARE(entry->group()->name(), QString("General"));
QCOMPARE(entry->uuid(), uuid);
QCOMPARE(entry->historyItems().count(), history);
}
void TestGui::testDragAndDropGroup()
{
QAbstractItemModel* groupModel = m_dbWidget->findChild<GroupView*>("groupView")->model();
QModelIndex rootIndex = groupModel->index(0, 0);
dragAndDropGroup(groupModel->index(0, 0, rootIndex), groupModel->index(1, 0, rootIndex), -1, true, "Windows", 0);
// dropping parent on child is supposed to fail
dragAndDropGroup(groupModel->index(0, 0, rootIndex),
groupModel->index(0, 0, groupModel->index(0, 0, rootIndex)),
-1,
false,
"NewDatabase",
0);
dragAndDropGroup(groupModel->index(1, 0, rootIndex), rootIndex, 0, true, "NewDatabase", 0);
dragAndDropGroup(groupModel->index(0, 0, rootIndex), rootIndex, -1, true, "NewDatabase", 4);
}
void TestGui::testSaveAs()
{
QFileInfo fileInfo(m_dbFilePath);
QDateTime lastModified = fileInfo.lastModified();
m_db->metadata()->setName("testSaveAs");
// open temporary file so it creates a filename
TemporaryFile tmpFile;
QVERIFY(tmpFile.open());
QString tmpFileName = tmpFile.fileName();
tmpFile.remove();
fileDialog()->setNextFileName(tmpFileName);
triggerAction("actionDatabaseSaveAs");
QCOMPARE(m_tabWidget->tabText(m_tabWidget->currentIndex()), QString("testSaveAs"));
checkDatabase(tmpFileName);
fileInfo.refresh();
QCOMPARE(fileInfo.lastModified(), lastModified);
tmpFile.remove();
}
void TestGui::testSaveBackup()
{
m_db->metadata()->setName("testSaveBackup");
QFileInfo fileInfo(m_dbFilePath);
QDateTime lastModified = fileInfo.lastModified();
// open temporary file so it creates a filename
TemporaryFile tmpFile;
QVERIFY(tmpFile.open());
QString tmpFileName = tmpFile.fileName();
tmpFile.remove();
// wait for modified timer
QTRY_COMPARE(m_tabWidget->tabText(m_tabWidget->currentIndex()), QString("testSaveBackup*"));
fileDialog()->setNextFileName(tmpFileName);
triggerAction("actionDatabaseSaveBackup");
QTRY_COMPARE(m_tabWidget->tabText(m_tabWidget->currentIndex()), QString("testSaveBackup*"));
checkDatabase(tmpFileName);
fileInfo.refresh();
QCOMPARE(fileInfo.lastModified(), lastModified);
tmpFile.remove();
}
void TestGui::testSave()
{
// Make a modification to the database then save
m_db->metadata()->setName("testSave");
checkSaveDatabase();
}
void TestGui::testSaveBackupPath_data()
{
QTest::addColumn<QString>("backupFilePathPattern");
QTest::addColumn<QString>("expectedBackupFile");
// Absolute paths should remain absolute
TemporaryFile tmpFile;
QVERIFY(tmpFile.open());
tmpFile.remove();
QTest::newRow("Absolute backup path") << tmpFile.fileName() << tmpFile.fileName();
// relative paths should be resolved to database parent directory
QTest::newRow("Relative backup path (implicit)") << "other_dir/test.old.kdbx"
<< "other_dir/test.old.kdbx";
QTest::newRow("Relative backup path (explicit)") << "./other_dir2/test2.old.kdbx"
<< "other_dir2/test2.old.kdbx";
QTest::newRow("Path with placeholders") << "{DB_FILENAME}.old.kdbx"
<< "KeePassXC.old.kdbx";
// empty path should be replaced with default pattern
QTest::newRow("Empty path") << QString("") << config()->getDefault(Config::BackupFilePathPattern).toString();
// {DB_FILENAME} should be replaced with database filename
QTest::newRow("") << "{DB_FILENAME}_.old.kdbx"
<< "{DB_FILENAME}_.old.kdbx";
}
void TestGui::testSaveBackupPath()
{
/**
* Tests that the backupFilePathPattern config entry is respected. We do not test patterns like {TIME} etc here
* as this is done in a separate test case. We do however check {DB_FILENAME} as this is a feature of the
* performBackup() function.
*/
// Get test data
QFETCH(QString, backupFilePathPattern);
QFETCH(QString, expectedBackupFile);
// Enable automatic backups
config()->set(Config::BackupBeforeSave, true);
config()->set(Config::BackupFilePathPattern, backupFilePathPattern);
// Replace placeholders and resolve relative paths. This cannot be done in the _data() function as the
// db path/filename is not known yet
auto dbFileInfo = QFileInfo(m_dbFilePath);
if (!QDir::isAbsolutePath(expectedBackupFile)) {
expectedBackupFile = QDir(dbFileInfo.absolutePath()).absoluteFilePath(expectedBackupFile);
}
expectedBackupFile.replace("{DB_FILENAME}", dbFileInfo.completeBaseName());
// Save a modified database
auto prevName = m_db->metadata()->name();
m_db->metadata()->setName("testBackupPathPattern");
checkSaveDatabase();
// Test that the backup file has the previous database name
checkDatabase(expectedBackupFile, prevName);
// Clean up
QFile(expectedBackupFile).remove();
}
void TestGui::testDatabaseSettings()
{
m_db->metadata()->setName("testDatabaseSettings");
triggerAction("actionDatabaseSettings");
auto* dbSettingsDialog = m_dbWidget->findChild<QWidget*>("databaseSettingsDialog");
auto* dbSettingsCategoryList = dbSettingsDialog->findChild<CategoryListWidget*>("categoryList");
auto* dbSettingsStackedWidget = dbSettingsDialog->findChild<QStackedWidget*>("stackedWidget");
auto* autosaveDelayCheckBox = dbSettingsDialog->findChild<QCheckBox*>("autosaveDelayCheckBox");
auto* autosaveDelaySpinBox = dbSettingsDialog->findChild<QSpinBox*>("autosaveDelaySpinBox");
auto* dbSettingsButtonBox = dbSettingsDialog->findChild<QDialogButtonBox*>("buttonBox");
int autosaveDelayTestValue = 2;
dbSettingsCategoryList->setCurrentCategory(1); // go into security category
auto securityTabWidget = dbSettingsStackedWidget->findChild<QTabWidget*>();
QCOMPARE(securityTabWidget->currentIndex(), 0);
// Interact with the password edit option
auto passwordEditWidget = securityTabWidget->findChild<PasswordEditWidget*>();
QVERIFY(passwordEditWidget);
auto editPasswordButton = passwordEditWidget->findChild<QPushButton*>("changeButton");
QVERIFY(editPasswordButton);
QVERIFY(editPasswordButton->isVisible());
QTest::mouseClick(editPasswordButton, Qt::LeftButton);
QApplication::processEvents();
auto passwordWidgets = dbSettingsDialog->findChildren<PasswordWidget*>();
QVERIFY(passwordWidgets.count() == 2);
QVERIFY(passwordWidgets[0]->isVisible());
passwordWidgets[0]->setText("b");
passwordWidgets[1]->setText("b");
// Toggle between tabs to ensure the password remains
securityTabWidget->setCurrentIndex(1);
QApplication::processEvents();
securityTabWidget->setCurrentIndex(0);
QApplication::processEvents();
QCOMPARE(passwordWidgets[0]->text(), QString("b"));
// Cancel password change and confirm password is cleared
auto cancelPasswordButton = passwordEditWidget->findChild<QPushButton*>("cancelButton");
QVERIFY(cancelPasswordButton);
QTest::mouseClick(cancelPasswordButton, Qt::LeftButton);
QApplication::processEvents();
QVERIFY(!passwordWidgets[0]->isVisible());
QCOMPARE(passwordWidgets[0]->text(), QString(""));
QVERIFY(editPasswordButton->isVisible());
// Switch to encryption tab and interact with various settings
securityTabWidget->setCurrentIndex(1);
QApplication::processEvents();
// Verify database is KDBX3
auto compatibilitySelection = securityTabWidget->findChild<QComboBox*>("compatibilitySelection");
QVERIFY(compatibilitySelection);
QVERIFY(compatibilitySelection->isEnabled());
QCOMPARE(compatibilitySelection->currentText(), QString("KDBX 3"));
// Verify advanced settings
auto encryptionSettings = securityTabWidget->findChild<QTabWidget*>("encryptionSettingsTabWidget");
auto advancedTab = encryptionSettings->findChild<QWidget*>("advancedTab");
encryptionSettings->setCurrentWidget(advancedTab);
QApplication::processEvents();
// Verify KDF is AES KDBX3
auto kdfSelection = advancedTab->findChild<QComboBox*>("kdfComboBox");
QVERIFY(kdfSelection->isVisible());
QCOMPARE(kdfSelection->currentText(), QString("AES-KDF (KDBX 3)"));
auto transformRoundsSpinBox = advancedTab->findChild<QSpinBox*>("transformRoundsSpinBox");
QVERIFY(transformRoundsSpinBox);
// Adjust compatibility to KDBX4 and wait for KDF to update
compatibilitySelection->setCurrentIndex(0);
QTRY_VERIFY(transformRoundsSpinBox->isEnabled());
QCOMPARE(compatibilitySelection->currentText().left(6), QString("KDBX 4"));
QCOMPARE(kdfSelection->currentText().left(7), QString("Argon2d"));
// Switch to AES KDBX4, change rounds, then accept
kdfSelection->setCurrentIndex(2);
QCOMPARE(kdfSelection->currentText(), QString("AES-KDF (KDBX 4)"));
transformRoundsSpinBox->setValue(123456);
QTest::keyClick(transformRoundsSpinBox, Qt::Key_Enter);
QTRY_COMPARE(m_db->kdf()->rounds(), 123456);
QVERIFY(m_db->formatVersion() >= KeePass2::FILE_VERSION_4);
QCOMPARE(m_db->kdf()->uuid(), KeePass2::KDF_AES_KDBX4);
// Go back into database settings
triggerAction("actionDatabaseSettings");
// test disable and default values for maximum history items and size
auto* historyMaxItemsCheckBox = dbSettingsDialog->findChild<QCheckBox*>("historyMaxItemsCheckBox");
auto* historyMaxItemsSpinBox = dbSettingsDialog->findChild<QSpinBox*>("historyMaxItemsSpinBox");
auto* historyMaxSizeCheckBox = dbSettingsDialog->findChild<QCheckBox*>("historyMaxSizeCheckBox");
auto* historyMaxSizeSpinBox = dbSettingsDialog->findChild<QSpinBox*>("historyMaxSizeSpinBox");
// test defaults
QCOMPARE(historyMaxItemsSpinBox->value(), Metadata::DefaultHistoryMaxItems);
QCOMPARE(historyMaxSizeSpinBox->value(), qRound(Metadata::DefaultHistoryMaxSize / qreal(1024 * 1024)));
// disable and test setting as well
historyMaxItemsCheckBox->setChecked(false);
historyMaxSizeCheckBox->setChecked(false);
QTest::mouseClick(dbSettingsButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton);
QTRY_COMPARE(m_db->metadata()->historyMaxItems(), -1);
QTRY_COMPARE(m_db->metadata()->historyMaxSize(), -1);
// then open to check the saved disabled state in gui
triggerAction("actionDatabaseSettings");
QCOMPARE(historyMaxItemsCheckBox->isChecked(), false);
QCOMPARE(historyMaxSizeCheckBox->isChecked(), false);
QTest::mouseClick(dbSettingsButtonBox->button(QDialogButtonBox::Cancel), Qt::LeftButton);
// Test loading default values and setting autosaveDelay
triggerAction("actionDatabaseSettings");
QVERIFY(autosaveDelayCheckBox->isChecked() == false);
autosaveDelayCheckBox->toggle();
autosaveDelaySpinBox->setValue(autosaveDelayTestValue);
QTest::mouseClick(dbSettingsButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton);
QTRY_COMPARE(m_db->metadata()->autosaveDelayMin(), autosaveDelayTestValue);
checkSaveDatabase();
// Test loading autosaveDelay non-default values
triggerAction("actionDatabaseSettings");
QTRY_COMPARE(autosaveDelayCheckBox->isChecked(), true);
QTRY_COMPARE(autosaveDelaySpinBox->value(), autosaveDelayTestValue);
QTest::mouseClick(dbSettingsButtonBox->button(QDialogButtonBox::Cancel), Qt::LeftButton);
// test autosave delay
// 1 init
config()->set(Config::AutoSaveAfterEveryChange, true);
QSignalSpy writeDbSignalSpy(m_db.data(), &Database::databaseSaved);
// 2 create new entries
// 2.a) Click the new entry button and set the title
auto* entryNewAction = m_mainWindow->findChild<QAction*>("actionEntryNew");
QVERIFY(entryNewAction->isEnabled());
auto* toolBar = m_mainWindow->findChild<QToolBar*>("toolBar");
QVERIFY(toolBar);
QWidget* entryNewWidget = toolBar->widgetForAction(entryNewAction);
QTest::mouseClick(entryNewWidget, Qt::LeftButton);
QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::EditMode);
auto* editEntryWidget = m_dbWidget->findChild<EditEntryWidget*>("editEntryWidget");
QVERIFY(editEntryWidget);
auto* titleEdit = editEntryWidget->findChild<QLineEdit*>("titleEdit");
QVERIFY(titleEdit);
QTest::keyClicks(titleEdit, "Test autosaveDelay 1");
// 2.b) Save changes
editEntryWidget->setCurrentPage(0);
auto* editEntryWidgetButtonBox = editEntryWidget->findChild<QDialogButtonBox*>("buttonBox");
QTest::mouseClick(editEntryWidgetButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton);
// 2.c) Make sure file was not modified yet
Tools::wait(150); // due to modify timer
QTRY_COMPARE(writeDbSignalSpy.count(), 0);
// 2.d) Create second entry to test delay timer reset
QTest::mouseClick(entryNewWidget, Qt::LeftButton);
QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::EditMode);
QTest::keyClicks(titleEdit, "Test autosaveDelay 2");
// 2.e) Save changes
editEntryWidget->setCurrentPage(0);
editEntryWidgetButtonBox = editEntryWidget->findChild<QDialogButtonBox*>("buttonBox");
QTest::mouseClick(editEntryWidgetButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton);
// 3 Double check both true negative and true positive
// 3.a) Test unmodified prior to delay timeout
Tools::wait(150); // due to modify timer
QTRY_COMPARE(writeDbSignalSpy.count(), 0);
// 3.b) Test modification time after expected
m_dbWidget->triggerAutosaveTimer();
QTRY_COMPARE(writeDbSignalSpy.count(), 1);
// 4 Test no delay when disabled autosave or autosaveDelay
// 4.a) create new entry
QTest::mouseClick(entryNewWidget, Qt::LeftButton);
QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::EditMode);
QTest::keyClicks(titleEdit, "Test autosaveDelay 3");
// 4.b) Save changes
editEntryWidget->setCurrentPage(0);
editEntryWidgetButtonBox = editEntryWidget->findChild<QDialogButtonBox*>("buttonBox");
QTest::mouseClick(editEntryWidgetButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton);
// 4.c) Start timer
Tools::wait(150); // due to modify timer
// 4.d) Disable autosave
config()->set(Config::AutoSaveAfterEveryChange, false);
// 4.e) Make sure changes are not saved
m_dbWidget->triggerAutosaveTimer();
QTRY_COMPARE(writeDbSignalSpy.count(), 1);
// 4.f) Repeat for autosaveDelay
config()->set(Config::AutoSaveAfterEveryChange, true);
QTest::mouseClick(entryNewWidget, Qt::LeftButton);
QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::EditMode);
QTest::keyClicks(titleEdit, "Test autosaveDelay 4");
editEntryWidget->setCurrentPage(0);
editEntryWidgetButtonBox = editEntryWidget->findChild<QDialogButtonBox*>("buttonBox");
QTest::mouseClick(editEntryWidgetButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton);
Tools::wait(150); // due to modify timer
m_db->metadata()->setAutosaveDelayMin(0);
// 4.g) Make sure changes are not saved
m_dbWidget->triggerAutosaveTimer();
QTRY_COMPARE(writeDbSignalSpy.count(), 1);
// 5 Cleanup
config()->set(Config::AutoSaveAfterEveryChange, false);
}
void TestGui::testDatabaseLocking()
{
QString origDbName = m_tabWidget->tabText(0);
MessageBox::setNextAnswer(MessageBox::Cancel);
triggerAction("actionLockAllDatabases");
QCOMPARE(m_tabWidget->tabText(0), origDbName + " [Locked]");
auto* actionDatabaseMerge = m_mainWindow->findChild<QAction*>("actionDatabaseMerge", Qt::FindChildrenRecursively);
QCOMPARE(actionDatabaseMerge->isEnabled(), false);
auto* actionDatabaseSave = m_mainWindow->findChild<QAction*>("actionDatabaseSave", Qt::FindChildrenRecursively);
QCOMPARE(actionDatabaseSave->isEnabled(), false);
DatabaseWidget* dbWidget = m_tabWidget->currentDatabaseWidget();
QVERIFY(dbWidget->isLocked());
auto* unlockDatabaseWidget = dbWidget->findChild<QWidget*>("databaseOpenWidget");
QWidget* editPassword =
unlockDatabaseWidget->findChild<PasswordWidget*>("editPassword")->findChild<QLineEdit*>("passwordEdit");
QVERIFY(editPassword);
QTest::keyClicks(editPassword, "a");
QTest::keyClick(editPassword, Qt::Key_Enter);
QVERIFY(!dbWidget->isLocked());
QCOMPARE(m_tabWidget->tabText(0), origDbName);
actionDatabaseMerge = m_mainWindow->findChild<QAction*>("actionDatabaseMerge", Qt::FindChildrenRecursively);
QCOMPARE(actionDatabaseMerge->isEnabled(), true);
}
void TestGui::testDragAndDropKdbxFiles()
{
const int openedDatabasesCount = m_tabWidget->count();
const QString badDatabaseFilePath(QString(KEEPASSX_TEST_DATA_DIR).append("/NotDatabase.notkdbx"));
const QString goodDatabaseFilePath(QString(KEEPASSX_TEST_DATA_DIR).append("/NewDatabase.kdbx"));
QMimeData badMimeData;
badMimeData.setUrls({QUrl::fromLocalFile(badDatabaseFilePath)});
QDragEnterEvent badDragEvent(QPoint(1, 1), Qt::LinkAction, &badMimeData, Qt::LeftButton, Qt::NoModifier);
qApp->notify(m_mainWindow.data(), &badDragEvent);
QCOMPARE(badDragEvent.isAccepted(), false);
QDropEvent badDropEvent(QPoint(1, 1), Qt::LinkAction, &badMimeData, Qt::LeftButton, Qt::NoModifier);
qApp->notify(m_mainWindow.data(), &badDropEvent);
QCOMPARE(badDropEvent.isAccepted(), false);
QCOMPARE(m_tabWidget->count(), openedDatabasesCount);
QMimeData goodMimeData;
goodMimeData.setUrls({QUrl::fromLocalFile(goodDatabaseFilePath)});
QDragEnterEvent goodDragEvent(QPoint(1, 1), Qt::LinkAction, &goodMimeData, Qt::LeftButton, Qt::NoModifier);
qApp->notify(m_mainWindow.data(), &goodDragEvent);
QCOMPARE(goodDragEvent.isAccepted(), true);
QDropEvent goodDropEvent(QPoint(1, 1), Qt::LinkAction, &goodMimeData, Qt::LeftButton, Qt::NoModifier);
qApp->notify(m_mainWindow.data(), &goodDropEvent);
QCOMPARE(goodDropEvent.isAccepted(), true);
QCOMPARE(m_tabWidget->count(), openedDatabasesCount + 1);
MessageBox::setNextAnswer(MessageBox::No);
triggerAction("actionDatabaseClose");
QTRY_COMPARE(m_tabWidget->count(), openedDatabasesCount);
}
void TestGui::testSortGroups()
{
auto* editGroupWidget = m_dbWidget->findChild<EditGroupWidget*>("editGroupWidget");
auto* nameEdit = editGroupWidget->findChild<QLineEdit*>("editName");
auto* editGroupWidgetButtonBox = editGroupWidget->findChild<QDialogButtonBox*>("buttonBox");
// Create some sub-groups
Group* rootGroup = m_db->rootGroup();
Group* internetGroup = rootGroup->findGroupByPath("Internet");
m_dbWidget->groupView()->setCurrentGroup(internetGroup);
m_dbWidget->createGroup();
QTest::keyClicks(nameEdit, "Google");
QTest::mouseClick(editGroupWidgetButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton);
m_dbWidget->groupView()->setCurrentGroup(internetGroup);
m_dbWidget->createGroup();
QTest::keyClicks(nameEdit, "eBay");
QTest::mouseClick(editGroupWidgetButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton);
m_dbWidget->groupView()->setCurrentGroup(internetGroup);
m_dbWidget->createGroup();
QTest::keyClicks(nameEdit, "Amazon");
QTest::mouseClick(editGroupWidgetButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton);
m_dbWidget->groupView()->setCurrentGroup(internetGroup);
m_dbWidget->createGroup();
QTest::keyClicks(nameEdit, "Facebook");
QTest::mouseClick(editGroupWidgetButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton);
m_dbWidget->groupView()->setCurrentGroup(rootGroup);
triggerAction("actionGroupSortAsc");
QList<Group*> children = rootGroup->children();
QCOMPARE(children[0]->name(), QString("eMail"));
QCOMPARE(children[1]->name(), QString("General"));
QCOMPARE(children[2]->name(), QString("Homebanking"));
QCOMPARE(children[3]->name(), QString("Internet"));
QCOMPARE(children[4]->name(), QString("Network"));
QCOMPARE(children[5]->name(), QString("Windows"));
QList<Group*> subChildren = internetGroup->children();
QCOMPARE(subChildren[0]->name(), QString("Amazon"));
QCOMPARE(subChildren[1]->name(), QString("eBay"));
QCOMPARE(subChildren[2]->name(), QString("Facebook"));
QCOMPARE(subChildren[3]->name(), QString("Google"));
triggerAction("actionGroupSortDesc");
children = rootGroup->children();
QCOMPARE(children[0]->name(), QString("Windows"));
QCOMPARE(children[1]->name(), QString("Network"));
QCOMPARE(children[2]->name(), QString("Internet"));
QCOMPARE(children[3]->name(), QString("Homebanking"));
QCOMPARE(children[4]->name(), QString("General"));
QCOMPARE(children[5]->name(), QString("eMail"));
subChildren = internetGroup->children();
QCOMPARE(subChildren[0]->name(), QString("Google"));
QCOMPARE(subChildren[1]->name(), QString("Facebook"));
QCOMPARE(subChildren[2]->name(), QString("eBay"));
QCOMPARE(subChildren[3]->name(), QString("Amazon"));
m_dbWidget->groupView()->setCurrentGroup(internetGroup);
triggerAction("actionGroupSortAsc");
children = rootGroup->children();
QCOMPARE(children[0]->name(), QString("Windows"));
QCOMPARE(children[1]->name(), QString("Network"));
QCOMPARE(children[2]->name(), QString("Internet"));
QCOMPARE(children[3]->name(), QString("Homebanking"));
QCOMPARE(children[4]->name(), QString("General"));
QCOMPARE(children[5]->name(), QString("eMail"));
subChildren = internetGroup->children();
QCOMPARE(subChildren[0]->name(), QString("Amazon"));
QCOMPARE(subChildren[1]->name(), QString("eBay"));
QCOMPARE(subChildren[2]->name(), QString("Facebook"));
QCOMPARE(subChildren[3]->name(), QString("Google"));
m_dbWidget->groupView()->setCurrentGroup(rootGroup);
triggerAction("actionGroupSortAsc");
m_dbWidget->groupView()->setCurrentGroup(internetGroup);
triggerAction("actionGroupSortDesc");
children = rootGroup->children();
QCOMPARE(children[0]->name(), QString("eMail"));
QCOMPARE(children[1]->name(), QString("General"));
QCOMPARE(children[2]->name(), QString("Homebanking"));
QCOMPARE(children[3]->name(), QString("Internet"));
QCOMPARE(children[4]->name(), QString("Network"));
QCOMPARE(children[5]->name(), QString("Windows"));
subChildren = internetGroup->children();
QCOMPARE(subChildren[0]->name(), QString("Google"));
QCOMPARE(subChildren[1]->name(), QString("Facebook"));
QCOMPARE(subChildren[2]->name(), QString("eBay"));
QCOMPARE(subChildren[3]->name(), QString("Amazon"));
}
void TestGui::testTrayRestoreHide()
{
if (!QSystemTrayIcon::isSystemTrayAvailable()) {
QSKIP("QSystemTrayIcon::isSystemTrayAvailable() = false, skipping tray restore/hide test…");
}
#ifndef Q_OS_MACOS
m_mainWindow->hideWindow();
QVERIFY(!m_mainWindow->isVisible());
auto* trayIcon = m_mainWindow->findChild<QSystemTrayIcon*>();
QVERIFY(trayIcon);
trayIcon->activated(QSystemTrayIcon::Trigger);
QTRY_VERIFY(m_mainWindow->isVisible());
trayIcon->activated(QSystemTrayIcon::Trigger);
QTRY_VERIFY(!m_mainWindow->isVisible());
trayIcon->activated(QSystemTrayIcon::MiddleClick);
QTRY_VERIFY(m_mainWindow->isVisible());
trayIcon->activated(QSystemTrayIcon::MiddleClick);
QTRY_VERIFY(!m_mainWindow->isVisible());
trayIcon->activated(QSystemTrayIcon::DoubleClick);
QTRY_VERIFY(m_mainWindow->isVisible());
trayIcon->activated(QSystemTrayIcon::DoubleClick);
QTRY_VERIFY(!m_mainWindow->isVisible());
// Ensure window is visible at the end
trayIcon->activated(QSystemTrayIcon::DoubleClick);
QTRY_VERIFY(m_mainWindow->isVisible());
#endif
}
void TestGui::testShortcutConfig()
{
// Action collection should not be empty
QVERIFY(!ActionCollection::instance()->actions().isEmpty());
// Add an action, make sure it gets added
QAction* a = new QAction(ActionCollection::instance());
a->setObjectName("MyAction1");
ActionCollection::instance()->addAction(a);
QVERIFY(ActionCollection::instance()->actions().contains(a));
const QKeySequence seq(Qt::CTRL + Qt::SHIFT + Qt::ALT + Qt::Key_N);
ActionCollection::instance()->setDefaultShortcut(a, seq);
QCOMPARE(ActionCollection::instance()->defaultShortcut(a), seq);
bool v = false;
m_mainWindow->addAction(a);
connect(a, &QAction::triggered, ActionCollection::instance(), [&v] { v = !v; });
QTest::keyClick(m_mainWindow.data(), Qt::Key_N, Qt::ControlModifier | Qt::ShiftModifier | Qt::AltModifier);
QVERIFY(v);
// Change shortcut and save
const QKeySequence newSeq(Qt::CTRL + Qt::SHIFT + Qt::ALT + Qt::Key_M);
a->setShortcut(newSeq);
QVERIFY(a->shortcut() != ActionCollection::instance()->defaultShortcut(a));
ActionCollection::instance()->saveShortcuts();
QCOMPARE(a->shortcut(), newSeq);
const auto shortcuts = Config::instance()->getShortcuts();
Config::ShortcutEntry entryForA;
for (const auto& s : shortcuts) {
if (s.name == a->objectName()) {
entryForA = s;
break;
}
}
QCOMPARE(entryForA.name, a->objectName());
QCOMPARE(QKeySequence::fromString(entryForA.shortcut), a->shortcut());
// trigger the old shortcut
QTest::keyClick(m_mainWindow.data(), Qt::Key_N, Qt::ControlModifier | Qt::ShiftModifier | Qt::AltModifier);
QVERIFY(v); // value of v should not change
QTest::keyClick(m_mainWindow.data(), Qt::Key_M, Qt::ControlModifier | Qt::ShiftModifier | Qt::AltModifier);
QVERIFY(!v);
disconnect(a, nullptr, nullptr, nullptr);
}
void TestGui::testAutoType()
{
// Clear entries from root group to guarantee order
for (Entry* entry : m_db->rootGroup()->entries()) {
m_db->rootGroup()->removeEntry(entry);
}
Tools::wait(150);
// 1. Create an entry with Auto-Type disabled
// 1.a) Click the new entry button and set the title
auto* entryNewAction = m_mainWindow->findChild<QAction*>("actionEntryNew");
QVERIFY(entryNewAction->isEnabled());
auto* toolBar = m_mainWindow->findChild<QToolBar*>("toolBar");
QVERIFY(toolBar);
QWidget* entryNewWidget = toolBar->widgetForAction(entryNewAction);
QVERIFY(entryNewWidget->isVisible());
QVERIFY(entryNewWidget->isEnabled());
QTest::mouseClick(entryNewWidget, Qt::LeftButton);
QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::EditMode);
auto* editEntryWidget = m_dbWidget->findChild<EditEntryWidget*>("editEntryWidget");
QVERIFY(editEntryWidget);
auto* titleEdit = editEntryWidget->findChild<QLineEdit*>("titleEdit");
QVERIFY(titleEdit);
QTest::keyClicks(titleEdit, "1. Entry With Disabled Auto-Type");
auto* usernameComboBox = editEntryWidget->findChild<QComboBox*>("usernameComboBox");
QVERIFY(usernameComboBox);
QTest::mouseClick(usernameComboBox, Qt::LeftButton);
QTest::keyClicks(usernameComboBox, "AutocompletionUsername");
// 1.b) Uncheck Auto-Type checkbox
editEntryWidget->setCurrentPage(3);
auto* enableAutoTypeButton = editEntryWidget->findChild<QCheckBox*>("enableButton");
QVERIFY(enableAutoTypeButton);
QVERIFY(enableAutoTypeButton->isVisible());
QVERIFY(enableAutoTypeButton->isEnabled());
enableAutoTypeButton->click();
QVERIFY(!enableAutoTypeButton->isChecked());
// 1.c) Save changes
editEntryWidget->setCurrentPage(0);
auto* editEntryWidgetButtonBox = editEntryWidget->findChild<QDialogButtonBox*>("buttonBox");
QTest::mouseClick(editEntryWidgetButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton);
// 2. Create an entry with default/inherited Auto-Type sequence
// 2.a) Click the new entry button and set the title
QTest::mouseClick(entryNewWidget, Qt::LeftButton);
QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::EditMode);
QTest::keyClicks(titleEdit, "2. Entry With Default Auto-Type Sequence");
QTest::mouseClick(usernameComboBox, Qt::LeftButton);
QTest::keyClicks(usernameComboBox, "AutocompletionUsername");
// 2.b) Confirm AutoType is enabled and default
editEntryWidget->setCurrentPage(3);
QVERIFY(enableAutoTypeButton->isChecked());
auto* inheritSequenceButton = editEntryWidget->findChild<QRadioButton*>("inheritSequenceButton");
QVERIFY(inheritSequenceButton->isChecked());
// 2.c) Save changes
editEntryWidget->setCurrentPage(0);
QTest::mouseClick(editEntryWidgetButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton);
// 3. Create an entry with custom Auto-Type sequence
// 3.a) Click the new entry button and set the title
QTest::mouseClick(entryNewWidget, Qt::LeftButton);
QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::EditMode);
QTest::keyClicks(titleEdit, "3. Entry With Custom Auto-Type Sequence");
QTest::mouseClick(usernameComboBox, Qt::LeftButton);
QTest::keyClicks(usernameComboBox, "AutocompletionUsername");
// 3.b) Confirm AutoType is enabled and set custom sequence
editEntryWidget->setCurrentPage(3);
QVERIFY(enableAutoTypeButton->isChecked());
auto* customSequenceButton = editEntryWidget->findChild<QRadioButton*>("customSequenceButton");
QTest::mouseClick(customSequenceButton, Qt::LeftButton);
QVERIFY(customSequenceButton->isChecked());
QVERIFY(!inheritSequenceButton->isChecked());
auto* sequenceEdit = editEntryWidget->findChild<QLineEdit*>("sequenceEdit");
QVERIFY(sequenceEdit);
sequenceEdit->setFocus();
QTRY_VERIFY(sequenceEdit->hasFocus());
QTest::keyClicks(sequenceEdit, "{USERNAME}{TAB}{TAB}{PASSWORD}{ENTER}");
// 3.c) Save changes
editEntryWidget->setCurrentPage(0);
QTest::mouseClick(editEntryWidgetButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton);
QApplication::processEvents();
// Check total number of entries matches expected
auto* entryView = m_dbWidget->findChild<EntryView*>("entryView");
QVERIFY(entryView);
QTRY_COMPARE(entryView->model()->rowCount(), 3);
// Sort entries by title
entryView->sortByColumn(1, Qt::AscendingOrder);
// Select first entry
entryView->selectionModel()->clearSelection();
QModelIndex entryIndex = entryView->model()->index(0, 0);
entryView->selectionModel()->select(entryIndex, QItemSelectionModel::Rows | QItemSelectionModel::Select);
auto* entryPreviewWidget = m_dbWidget->findChild<EntryPreviewWidget*>("previewWidget");
QVERIFY(entryPreviewWidget->isVisible());
// Check that the Autotype tab in entry preview pane is disabled for entry with disabled Auto-Type
auto* entryAutotypeTab = entryPreviewWidget->findChild<QWidget*>("entryAutotypeTab");
QVERIFY(!entryAutotypeTab->isEnabled());
// Check that Auto-Type is disabled in the actual entry model as well
Entry* entry = entryView->entryFromIndex(entryIndex);
QVERIFY(!entry->autoTypeEnabled());
// Select second entry
entryView->selectionModel()->clearSelection();
entryIndex = entryView->model()->index(1, 0);
entryView->selectionModel()->select(entryIndex, QItemSelectionModel::Rows | QItemSelectionModel::Select);
QVERIFY(entryPreviewWidget->isVisible());
// Check that the Autotype tab in entry preview pane is enabled for entry with default Auto-Type sequence;
QVERIFY(entryAutotypeTab->isEnabled());
// Check that Auto-Type is enabled in the actual entry model as well
entry = entryView->entryFromIndex(entryIndex);
QVERIFY(entry->autoTypeEnabled());
// Select third entry
entryView->selectionModel()->clearSelection();
entryIndex = entryView->model()->index(2, 0);
entryView->selectionModel()->select(entryIndex, QItemSelectionModel::Rows | QItemSelectionModel::Select);
QVERIFY(entryPreviewWidget->isVisible());
// Check that the Autotype tab in entry preview pane is enabled for entry with custom Auto-Type sequence
QVERIFY(entryAutotypeTab->isEnabled());
// Check that Auto-Type is enabled in the actual entry model as well
entry = entryView->entryFromIndex(entryIndex);
QVERIFY(entry->autoTypeEnabled());
// De-select third entry
entryView->selectionModel()->clearSelection();
}
void TestGui::addCannedEntries()
{
// Find buttons
auto* toolBar = m_mainWindow->findChild<QToolBar*>("toolBar");
QWidget* entryNewWidget = toolBar->widgetForAction(m_mainWindow->findChild<QAction*>("actionEntryNew"));
auto* editEntryWidget = m_dbWidget->findChild<EditEntryWidget*>("editEntryWidget");
auto* titleEdit = editEntryWidget->findChild<QLineEdit*>("titleEdit");
auto* passwordEdit =
editEntryWidget->findChild<PasswordWidget*>("passwordEdit")->findChild<QLineEdit*>("passwordEdit");
// Add entry "test" and confirm added
QTest::mouseClick(entryNewWidget, Qt::LeftButton);
QTest::keyClicks(titleEdit, "test");
auto* editEntryWidgetTagsEdit = editEntryWidget->findChild<TagsEdit*>("tagsList");
editEntryWidgetTagsEdit->tags(QStringList() << "testTag");
auto* editEntryWidgetButtonBox = editEntryWidget->findChild<QDialogButtonBox*>("buttonBox");
QTest::mouseClick(editEntryWidgetButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton);
// Add entry "something 2"
QTest::mouseClick(entryNewWidget, Qt::LeftButton);
QTest::keyClicks(titleEdit, "something 2");
QTest::keyClicks(passwordEdit, "something 2");
QTest::mouseClick(editEntryWidgetButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton);
// Add entry "something 3"
QTest::mouseClick(entryNewWidget, Qt::LeftButton);
QTest::keyClicks(titleEdit, "something 3");
QTest::mouseClick(editEntryWidgetButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton);
}
void TestGui::checkDatabase(const QString& filePath, const QString& expectedDbName)
{
auto key = QSharedPointer<CompositeKey>::create();
key->addKey(QSharedPointer<PasswordKey>::create("a"));
auto dbSaved = QSharedPointer<Database>::create();
QVERIFY(dbSaved->open(filePath, key, nullptr));
QCOMPARE(dbSaved->metadata()->name(), expectedDbName);
}
void TestGui::checkDatabase(const QString& filePath)
{
checkDatabase(filePath.isEmpty() ? m_dbFilePath : filePath, m_db->metadata()->name());
}
void TestGui::checkSaveDatabase()
{
// Attempt to save the database up to two times to overcome transient file errors
QTRY_VERIFY(m_db->isModified());
QTRY_VERIFY(m_tabWidget->tabText(m_tabWidget->currentIndex()).endsWith("*"));
int i = 0;
do {
triggerAction("actionDatabaseSave");
if (!m_db->isModified()) {
checkDatabase();
return;
}
QWARN("Failed to save database, trying again...");
Tools::wait(250);
} while (++i < 2);
QFAIL("Could not save database.");
}
void TestGui::checkStatusBarText(const QString& textFragment)
{
QApplication::processEvents();
QVERIFY(m_statusBarLabel->isVisible());
QTRY_VERIFY2(m_statusBarLabel->text().startsWith(textFragment),
qPrintable(QString("'%1' doesn't start with '%2'").arg(m_statusBarLabel->text(), textFragment)));
}
void TestGui::triggerAction(const QString& name)
{
auto* action = m_mainWindow->findChild<QAction*>(name);
QVERIFY(action);
QVERIFY(action->isEnabled());
action->trigger();
QApplication::processEvents();
}
void TestGui::dragAndDropGroup(const QModelIndex& sourceIndex,
const QModelIndex& targetIndex,
int row,
bool expectedResult,
const QString& expectedParentName,
int expectedPos)
{
QVERIFY(sourceIndex.isValid());
QVERIFY(targetIndex.isValid());
auto groupModel = qobject_cast<GroupModel*>(m_dbWidget->findChild<GroupView*>("groupView")->model());
QMimeData mimeData;
QByteArray encoded;
QDataStream stream(&encoded, QIODevice::WriteOnly);
Group* group = groupModel->groupFromIndex(sourceIndex);
stream << group->database()->uuid() << group->uuid();
mimeData.setData("application/x-keepassx-group", encoded);
QCOMPARE(groupModel->dropMimeData(&mimeData, Qt::MoveAction, row, 0, targetIndex), expectedResult);
QCOMPARE(group->parentGroup()->name(), expectedParentName);
QCOMPARE(group->parentGroup()->children().indexOf(group), expectedPos);
}
void TestGui::clickIndex(const QModelIndex& index,
QAbstractItemView* view,
Qt::MouseButton button,
Qt::KeyboardModifiers stateKey)
{
view->scrollTo(index);
QTest::mouseClick(view->viewport(), button, stateKey, view->visualRect(index).center());
}