/*
 *  Copyright (C) 2019 Aetf <aetf@unlimitedcodeworks.xyz>
 *
 *  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 "TestGuiFdoSecrets.h"

#include "fdosecrets/FdoSecretsPlugin.h"
#include "fdosecrets/FdoSecretsSettings.h"
#include "fdosecrets/objects/Collection.h"
#include "fdosecrets/objects/Item.h"
#include "fdosecrets/objects/SessionCipher.h"
#include "fdosecrets/widgets/AccessControlDialog.h"

#include "config-keepassx-tests.h"

#include "core/Global.h"
#include "core/Tools.h"
#include "crypto/Crypto.h"
#include "gui/Application.h"
#include "gui/DatabaseTabWidget.h"
#include "gui/FileDialog.h"
#include "gui/MainWindow.h"
#include "gui/MessageBox.h"
#include "gui/PasswordWidget.h"
#include "gui/wizard/NewDatabaseWizard.h"
#include "util/FdoSecretsProxy.h"
#include "util/TemporaryFile.h"

#include <QCheckBox>
#include <QLineEdit>
#include <QSignalSpy>
#include <QTest>
#include <utility>

int main(int argc, char* argv[])
{
    QApplication::setAttribute(Qt::AA_EnableHighDpiScaling);
    QGuiApplication::setAttribute(Qt::AA_UseHighDpiPixmaps);
    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
    TestGuiFdoSecrets tc;
    QTEST_SET_MAIN_SOURCE_PATH
    return QTest::qExec(&tc, argc, argv);
}

#define DBUS_PATH_DEFAULT_ALIAS "/org/freedesktop/secrets/aliases/default"

// assert macros compatible with function having return values
#define VERIFY2_RET(statement, msg)                                                                                    \
    do {                                                                                                               \
        if (!QTest::qVerify(static_cast<bool>(statement), #statement, (msg), __FILE__, __LINE__))                      \
            return {};                                                                                                 \
    } while (false)

#define COMPARE_RET(actual, expected)                                                                                  \
    do {                                                                                                               \
        if (!QTest::qCompare(actual, expected, #actual, #expected, __FILE__, __LINE__))                                \
            return {};                                                                                                 \
    } while (false)

// by default use these with Qt macros
#define VERIFY QVERIFY
#define COMPARE QCOMPARE
#define VERIFY2 QVERIFY2

#define DBUS_COMPARE(actual, expected)                                                                                 \
    do {                                                                                                               \
        auto reply = (actual);                                                                                         \
        VERIFY2(reply.isValid(), reply.error().name().toLocal8Bit());                                                  \
        COMPARE(reply.value(), (expected));                                                                            \
    } while (false)

#define DBUS_VERIFY(stmt)                                                                                              \
    do {                                                                                                               \
        auto reply = (stmt);                                                                                           \
        VERIFY2(reply.isValid(), reply.error().name().toLocal8Bit());                                                  \
    } while (false)

#define DBUS_GET(var, stmt)                                                                                            \
    std::remove_cv<decltype((stmt).argumentAt<0>())>::type var;                                                        \
    do {                                                                                                               \
        const auto rep = (stmt);                                                                                       \
        VERIFY2(rep.isValid(), rep.error().name().toLocal8Bit());                                                      \
        var = rep.argumentAt<0>();                                                                                     \
    } while (false)

#define DBUS_GET2(name1, name2, stmt)                                                                                  \
    std::remove_cv<decltype((stmt).argumentAt<0>())>::type name1;                                                      \
    std::remove_cv<decltype((stmt).argumentAt<1>())>::type name2;                                                      \
    do {                                                                                                               \
        const auto rep = (stmt);                                                                                       \
        VERIFY2(rep.isValid(), rep.error().name().toLocal8Bit());                                                      \
        name1 = rep.argumentAt<0>();                                                                                   \
        name2 = rep.argumentAt<1>();                                                                                   \
    } while (false)

using namespace FdoSecrets;

class FakeClient : public DBusClient
{
public:
    explicit FakeClient(DBusMgr* dbus)
        : DBusClient(
              dbus,
              {QStringLiteral("local"), 0, true, {ProcInfo{0, 0, QStringLiteral("fake-client"), QString{}, QString{}}}})
    {
    }
};

// pretty print QDBusObjectPath in QCOMPARE
char* toString(const QDBusObjectPath& path)
{
    return QTest::toString("ObjectPath(" + path.path() + ")");
}

TestGuiFdoSecrets::~TestGuiFdoSecrets() = default;

void TestGuiFdoSecrets::initTestCase()
{
    VERIFY(Crypto::init());
    Config::createTempFileInstance();
    config()->set(Config::AutoSaveAfterEveryChange, false);
    config()->set(Config::AutoSaveOnExit, false);
    config()->set(Config::GUI_ShowTrayIcon, true);
    config()->set(Config::UpdateCheckMessageShown, true);
    // Disable quick unlock
    config()->set(Config::Security_QuickUnlock, false);
    // Disable secret service integration (activate within individual tests to test the plugin)
    FdoSecrets::settings()->setEnabled(false);
    // activate within individual tests
    FdoSecrets::settings()->setShowNotification(false);

    Application::bootstrap();

    m_mainWindow.reset(new MainWindow());
    m_tabWidget = m_mainWindow->findChild<DatabaseTabWidget*>("tabWidget");
    VERIFY(m_tabWidget);
    m_plugin = FdoSecretsPlugin::getPlugin();
    VERIFY(m_plugin);
    m_mainWindow->show();

    auto key = QByteArray::fromHex("e407997e8b918419cf851cf3345358fdf"
                                   "ffb9564a220ac9c3934efd277cea20d17"
                                   "467ecdc56e817f75ac39501f38a4a04ff"
                                   "64d627e16c09981c7ad876da255b61c8e"
                                   "6a8408236c2a4523cfe6961c26dbdfc77"
                                   "c1a27a5b425ca71a019e829fae32c0b42"
                                   "0e1b3096b48bc2ce9ccab1d1ff13a5eb4"
                                   "b263cee30bdb1a57af9bfa93f");
    m_clientCipher.reset(new DhIetf1024Sha256Aes128CbcPkcs7(key));

    // Load the NewDatabase.kdbx file into temporary storage
    QFile sourceDbFile(QStringLiteral(KEEPASSX_TEST_DATA_DIR "/NewDatabase.kdbx"));
    VERIFY(sourceDbFile.open(QIODevice::ReadOnly));
    VERIFY(Tools::readAllFromDevice(&sourceDbFile, m_dbData));
    sourceDbFile.close();

    // set a fake dbus client all the time so we can freely access DBusMgr anywhere
    m_client.reset(new FakeClient(m_plugin->dbus().data()));
    m_plugin->dbus()->overrideClient(m_client);
}

// Every test starts with opening the temp database
void TestGuiFdoSecrets::init()
{
    m_dbFile.reset(new TemporaryFile());
    // Write the temp storage to a temp database file for use in our tests
    VERIFY(m_dbFile->open());
    COMPARE(m_dbFile->write(m_dbData), static_cast<qint64>(m_dbData.size()));
    m_dbFile->close();

    // make sure window is activated or focus tests may fail
    m_mainWindow->activateWindow();
    processEvents();

    // open and unlock the database
    m_tabWidget->addDatabaseTab(m_dbFile->fileName(), false, "a");
    m_dbWidget = m_tabWidget->currentDatabaseWidget();
    m_db = m_dbWidget->database();

    // by default expose the root group
    FdoSecrets::settings()->setExposedGroup(m_db, m_db->rootGroup()->uuid());
    VERIFY(m_dbWidget->save());

    // enforce consistent default settings at the beginning
    FdoSecrets::settings()->setUnlockBeforeSearch(false);
    FdoSecrets::settings()->setShowNotification(false);
    FdoSecrets::settings()->setConfirmAccessItem(false);
    FdoSecrets::settings()->setEnabled(false);
}

// Every test ends with closing the temp database without saving
void TestGuiFdoSecrets::cleanup()
{
    // restore to default settings
    FdoSecrets::settings()->setUnlockBeforeSearch(false);
    FdoSecrets::settings()->setShowNotification(false);
    FdoSecrets::settings()->setConfirmAccessItem(false);
    FdoSecrets::settings()->setEnabled(false);
    if (m_plugin) {
        m_plugin->updateServiceState();
    }

    // DO NOT save the database
    for (int i = 0; i != m_tabWidget->count(); ++i) {
        m_tabWidget->databaseWidgetFromIndex(i)->database()->markAsClean();
    }

    // Close any dialogs
    while (auto w = QApplication::activeModalWidget()) {
        w->close();
    }

    VERIFY(m_tabWidget->closeAllDatabaseTabs());
    processEvents();

    if (m_dbFile) {
        m_dbFile->remove();
    }

    m_client->clearAuthorization();
}

void TestGuiFdoSecrets::cleanupTestCase()
{
    m_plugin->dbus()->overrideClient({});
    if (m_dbFile) {
        m_dbFile->remove();
    }
}

void TestGuiFdoSecrets::testServiceEnable()
{
    QSignalSpy sigError(m_plugin, SIGNAL(error(QString)));
    VERIFY(sigError.isValid());

    QSignalSpy sigStarted(m_plugin, SIGNAL(secretServiceStarted()));
    VERIFY(sigStarted.isValid());

    // make sure no one else is holding the service
    VERIFY(!QDBusConnection::sessionBus().interface()->isServiceRegistered(DBUS_SERVICE_SECRET));

    // enable the service
    auto service = enableService();
    VERIFY(service);

    // service started without error
    VERIFY(sigError.isEmpty());
    COMPARE(sigStarted.size(), 1);

    processEvents();

    VERIFY(QDBusConnection::sessionBus().interface()->isServiceRegistered(DBUS_SERVICE_SECRET));

    // there will be one default collection
    auto coll = getDefaultCollection(service);
    VERIFY(coll);

    DBUS_COMPARE(coll->locked(), false);
    DBUS_COMPARE(coll->label(), m_db->metadata()->name());

    DBUS_COMPARE(coll->created(),
                 static_cast<qulonglong>(m_db->rootGroup()->timeInfo().creationTime().toMSecsSinceEpoch() / 1000));
    DBUS_COMPARE(
        coll->modified(),
        static_cast<qulonglong>(m_db->rootGroup()->timeInfo().lastModificationTime().toMSecsSinceEpoch() / 1000));
}

void TestGuiFdoSecrets::testServiceEnableNoExposedDatabase()
{
    // reset the exposed group and then enable the service
    FdoSecrets::settings()->setExposedGroup(m_db, {});
    auto service = enableService();
    VERIFY(service);

    // no collections
    DBUS_COMPARE(service->collections(), QList<QDBusObjectPath>{});
}

void TestGuiFdoSecrets::testServiceSearch()
{
    auto service = enableService();
    VERIFY(service);
    auto coll = getDefaultCollection(service);
    VERIFY(coll);
    auto item = getFirstItem(coll);
    VERIFY(item);

    auto itemObj = m_plugin->dbus()->pathToObject<Item>(QDBusObjectPath(item->path()));
    VERIFY(itemObj);
    auto entry = itemObj->backend();
    VERIFY(entry);

    entry->attributes()->set("fdosecrets-test", "1");
    entry->attributes()->set("fdosecrets-test-protected", "2", true);
    const QString crazyKey = "_a:bc&-+'-e%12df_d";
    const QString crazyValue = "[v]al@-ue";
    entry->attributes()->set(crazyKey, crazyValue);

    // search by title
    {
        DBUS_GET2(unlocked, locked, service->SearchItems({{"Title", entry->title()}}));
        COMPARE(locked, {});
        COMPARE(unlocked, {QDBusObjectPath(item->path())});
    }

    // search by attribute
    {
        DBUS_GET2(unlocked, locked, service->SearchItems({{"fdosecrets-test", "1"}}));
        COMPARE(locked, {});
        COMPARE(unlocked, {QDBusObjectPath(item->path())});
    }
    {
        DBUS_GET2(unlocked, locked, service->SearchItems({{crazyKey, crazyValue}}));
        COMPARE(locked, {});
        COMPARE(unlocked, {QDBusObjectPath(item->path())});
    }

    // searching using empty terms returns nothing
    {
        DBUS_GET2(unlocked, locked, service->SearchItems({}));
        COMPARE(locked, {});
        COMPARE(unlocked, {});
    }

    // searching using protected attributes or password returns nothing
    {
        DBUS_GET2(unlocked, locked, service->SearchItems({{"Password", entry->password()}}));
        COMPARE(locked, {});
        COMPARE(unlocked, {});
    }
    {
        DBUS_GET2(unlocked, locked, service->SearchItems({{"fdosecrets-test-protected", "2"}}));
        COMPARE(locked, {});
        COMPARE(unlocked, {});
    }
}

void TestGuiFdoSecrets::testServiceSearchBlockingUnlock()
{
    auto service = enableService();
    VERIFY(service);
    auto coll = getDefaultCollection(service);
    VERIFY(coll);

    auto entries = m_db->rootGroup()->entriesRecursive();
    VERIFY(!entries.isEmpty());
    // assumes the db is not empty
    auto title = entries.first()->title();

    // NOTE: entries are no longer valid after locking
    lockDatabaseInBackend();

    // when database is locked, nothing is returned
    FdoSecrets::settings()->setUnlockBeforeSearch(false);
    {
        DBUS_GET2(unlocked, locked, service->SearchItems({{"Title", title}}));
        COMPARE(locked, {});
        COMPARE(unlocked, {});
    }

    // when database is locked, nothing is returned
    FdoSecrets::settings()->setUnlockBeforeSearch(true);
    {
        // SearchItems will block because the blocking wait is implemented
        // using a local QEventLoop.
        // so we do a little trick here to get the return value back
        bool unlockDialogWorks = false;
        QTimer::singleShot(50, [&]() { unlockDialogWorks = driveUnlockDialog(); });

        DBUS_GET2(unlocked, locked, service->SearchItems({{"Title", title}}));
        VERIFY(unlockDialogWorks);
        COMPARE(locked, {});
        COMPARE(unlocked.size(), 1);
        auto item = getProxy<ItemProxy>(unlocked.first());
        DBUS_COMPARE(item->label(), title);
    }
}

void TestGuiFdoSecrets::testServiceSearchBlockingUnlockMultiple()
{
    // setup: two databases, both locked, one with exposed db, the other not.

    // add another database tab with a database with no exposed group
    // to avoid modify the original, copy to a temp file first
    QFile sourceDbFile(QStringLiteral(KEEPASSX_TEST_DATA_DIR "/NewDatabase2.kdbx"));
    QByteArray dbData;
    VERIFY(sourceDbFile.open(QIODevice::ReadOnly));
    VERIFY(Tools::readAllFromDevice(&sourceDbFile, dbData));
    sourceDbFile.close();

    QTemporaryFile anotherFile;
    VERIFY(anotherFile.open());
    COMPARE(anotherFile.write(dbData), static_cast<qint64>(dbData.size()));
    anotherFile.close();

    m_tabWidget->addDatabaseTab(anotherFile.fileName(), false);
    auto anotherWidget = m_tabWidget->currentDatabaseWidget();

    auto service = enableService();
    VERIFY(service);

    // when there are multiple locked databases,
    // repeatly show the dialog until there is at least one unlocked collection
    FdoSecrets::settings()->setUnlockBeforeSearch(true);

    // when only unlocking the one with no exposed group, a second dialog is shown
    lockDatabaseInBackend();
    {
        bool unlockDialogWorks = false;
        QTimer::singleShot(50, [&]() {
            unlockDialogWorks = driveUnlockDialog(anotherWidget);
            QTimer::singleShot(50, [&]() { unlockDialogWorks &= driveUnlockDialog(); });
        });

        DBUS_GET2(unlocked, locked, service->SearchItems({{"Title", "Sample Entry"}}));
        VERIFY(unlockDialogWorks);
        COMPARE(locked, {});
        COMPARE(unlocked.size(), 1);
    }

    // when unlocking the one with exposed group, the other one remains locked
    lockDatabaseInBackend();
    {
        bool unlockDialogWorks = false;
        QTimer::singleShot(50, [&]() { unlockDialogWorks = driveUnlockDialog(m_dbWidget); });

        DBUS_GET2(unlocked, locked, service->SearchItems({{"Title", "Sample Entry"}}));
        VERIFY(unlockDialogWorks);
        COMPARE(locked, {});
        COMPARE(unlocked.size(), 1);
        VERIFY(anotherWidget->isLocked());
    }
}

void TestGuiFdoSecrets::testServiceSearchForce()
{
    auto service = enableService();
    VERIFY(service);
    auto coll = getDefaultCollection(service);
    VERIFY(coll);
    auto item = getFirstItem(coll);
    VERIFY(item);

    auto itemObj = m_plugin->dbus()->pathToObject<Item>(QDBusObjectPath(item->path()));
    VERIFY(itemObj);
    auto entry = itemObj->backend();
    VERIFY(entry);

    // fdosecrets should still find the item even if searching is disabled
    entry->group()->setSearchingEnabled(Group::Disable);

    // search by title
    {
        DBUS_GET2(unlocked, locked, service->SearchItems({{"Title", entry->title()}}));
        COMPARE(locked, {});
        COMPARE(unlocked, {QDBusObjectPath(item->path())});
    }
}

void TestGuiFdoSecrets::testServiceUnlock()
{
    lockDatabaseInBackend();

    auto service = enableService();
    VERIFY(service);
    auto coll = getDefaultCollection(service);
    VERIFY(coll);

    QSignalSpy spyCollectionCreated(service.data(), SIGNAL(CollectionCreated(QDBusObjectPath)));
    VERIFY(spyCollectionCreated.isValid());
    QSignalSpy spyCollectionDeleted(service.data(), SIGNAL(CollectionDeleted(QDBusObjectPath)));
    VERIFY(spyCollectionDeleted.isValid());
    QSignalSpy spyCollectionChanged(service.data(), SIGNAL(CollectionChanged(QDBusObjectPath)));
    VERIFY(spyCollectionChanged.isValid());

    DBUS_GET2(unlocked, promptPath, service->Unlock({QDBusObjectPath(coll->path())}));
    // nothing is unlocked immediately without user's action
    COMPARE(unlocked, {});

    auto prompt = getProxy<PromptProxy>(promptPath);
    VERIFY(prompt);
    QSignalSpy spyPromptCompleted(prompt.data(), SIGNAL(Completed(bool, QDBusVariant)));
    VERIFY(spyPromptCompleted.isValid());

    // nothing is unlocked yet
    VERIFY(waitForSignal(spyPromptCompleted, 0));
    DBUS_COMPARE(coll->locked(), true);

    // show the prompt
    DBUS_VERIFY(prompt->Prompt(""));

    // still not unlocked before user action
    VERIFY(waitForSignal(spyPromptCompleted, 0));
    DBUS_COMPARE(coll->locked(), true);

    VERIFY(driveUnlockDialog());

    VERIFY(waitForSignal(spyPromptCompleted, 1));
    {
        auto args = spyPromptCompleted.takeFirst();
        COMPARE(args.size(), 2);
        COMPARE(args.at(0).toBool(), false);
        COMPARE(getSignalVariantArgument<QList<QDBusObjectPath>>(args.at(1)), {QDBusObjectPath(coll->path())});
    }

    // check unlocked *AFTER* the prompt signal
    DBUS_COMPARE(coll->locked(), false);

    VERIFY(waitForSignal(spyCollectionCreated, 0));
    QTRY_VERIFY(!spyCollectionChanged.isEmpty());
    for (const auto& args : spyCollectionChanged) {
        COMPARE(args.size(), 1);
        COMPARE(args.at(0).value<QDBusObjectPath>().path(), coll->path());
    }
    VERIFY(waitForSignal(spyCollectionDeleted, 0));
}

void TestGuiFdoSecrets::testServiceUnlockDatabaseConcurrent()
{
    lockDatabaseInBackend();

    auto service = enableService();
    VERIFY(service);
    auto coll = getDefaultCollection(service);
    VERIFY(coll);

    DBUS_GET2(unlocked, promptPath, service->Unlock({QDBusObjectPath(coll->path())}));
    auto prompt = getProxy<PromptProxy>(promptPath);
    VERIFY(prompt);
    QSignalSpy spyPromptCompleted(prompt.data(), SIGNAL(Completed(bool, QDBusVariant)));
    VERIFY(spyPromptCompleted.isValid());
    DBUS_VERIFY(prompt->Prompt(""));

    // while the first prompt is running, another request come in
    DBUS_GET2(unlocked2, promptPath2, service->Unlock({QDBusObjectPath(coll->path())}));
    auto prompt2 = getProxy<PromptProxy>(promptPath2);
    VERIFY(prompt2);
    QSignalSpy spyPromptCompleted2(prompt2.data(), SIGNAL(Completed(bool, QDBusVariant)));
    VERIFY(spyPromptCompleted2.isValid());
    DBUS_VERIFY(prompt2->Prompt(""));

    // there should be only one unlock dialog
    VERIFY(driveUnlockDialog());

    // both prompts should complete
    VERIFY(waitForSignal(spyPromptCompleted, 1));
    {
        auto args = spyPromptCompleted.takeFirst();
        COMPARE(args.size(), 2);
        COMPARE(args.at(0).toBool(), false);
        COMPARE(getSignalVariantArgument<QList<QDBusObjectPath>>(args.at(1)), {QDBusObjectPath(coll->path())});
    }
    VERIFY(waitForSignal(spyPromptCompleted2, 1));
    {
        auto args = spyPromptCompleted2.takeFirst();
        COMPARE(args.size(), 2);
        COMPARE(args.at(0).toBool(), false);
        COMPARE(getSignalVariantArgument<QList<QDBusObjectPath>>(args.at(1)), {QDBusObjectPath(coll->path())});
    }

    // check unlocked *AFTER* prompt signal
    DBUS_COMPARE(coll->locked(), false);
}

void TestGuiFdoSecrets::testServiceUnlockItems()
{
    FdoSecrets::settings()->setConfirmAccessItem(true);

    auto service = enableService();
    VERIFY(service);
    auto coll = getDefaultCollection(service);
    VERIFY(coll);
    auto item = getFirstItem(coll);
    VERIFY(item);
    auto sess = openSession(service, DhIetf1024Sha256Aes128CbcPkcs7::Algorithm);
    VERIFY(sess);

    DBUS_COMPARE(item->locked(), true);

    {
        DBUS_GET2(unlocked, promptPath, service->Unlock({QDBusObjectPath(item->path())}));
        // nothing is unlocked immediately without user's action
        COMPARE(unlocked, {});

        auto prompt = getProxy<PromptProxy>(promptPath);
        VERIFY(prompt);
        QSignalSpy spyPromptCompleted(prompt.data(), SIGNAL(Completed(bool, QDBusVariant)));
        VERIFY(spyPromptCompleted.isValid());

        // nothing is unlocked yet
        COMPARE(spyPromptCompleted.count(), 0);
        DBUS_COMPARE(item->locked(), true);

        // drive the prompt
        DBUS_VERIFY(prompt->Prompt(""));
        // only allow once
        VERIFY(driveAccessControlDialog(false));

        VERIFY(waitForSignal(spyPromptCompleted, 1));
        {
            auto args = spyPromptCompleted.takeFirst();
            COMPARE(args.size(), 2);
            COMPARE(args.at(0).toBool(), false);
            COMPARE(getSignalVariantArgument<QList<QDBusObjectPath>>(args.at(1)), {QDBusObjectPath(item->path())});
        }

        // unlocked
        DBUS_COMPARE(item->locked(), false);
    }

    // access the secret should reset the locking state
    {
        DBUS_GET(ss, item->GetSecret(QDBusObjectPath(sess->path())));
    }
    DBUS_COMPARE(item->locked(), true);

    // unlock again with remember
    {
        DBUS_GET2(unlocked, promptPath, service->Unlock({QDBusObjectPath(item->path())}));
        // nothing is unlocked immediately without user's action
        COMPARE(unlocked, {});

        auto prompt = getProxy<PromptProxy>(promptPath);
        VERIFY(prompt);
        QSignalSpy spyPromptCompleted(prompt.data(), SIGNAL(Completed(bool, QDBusVariant)));
        VERIFY(spyPromptCompleted.isValid());

        // nothing is unlocked yet
        COMPARE(spyPromptCompleted.count(), 0);
        DBUS_COMPARE(item->locked(), true);

        // drive the prompt
        DBUS_VERIFY(prompt->Prompt(""));
        // only allow and remember
        VERIFY(driveAccessControlDialog(true));

        VERIFY(waitForSignal(spyPromptCompleted, 1));
        {
            auto args = spyPromptCompleted.takeFirst();
            COMPARE(args.size(), 2);
            COMPARE(args.at(0).toBool(), false);
            COMPARE(getSignalVariantArgument<QList<QDBusObjectPath>>(args.at(1)), {QDBusObjectPath(item->path())});
        }

        // unlocked
        DBUS_COMPARE(item->locked(), false);
    }

    // access the secret does not reset the locking state
    {
        DBUS_GET(ss, item->GetSecret(QDBusObjectPath(sess->path())));
    }
    DBUS_COMPARE(item->locked(), false);
}

void TestGuiFdoSecrets::testServiceUnlockItemsIncludeFutureEntries()
{
    FdoSecrets::settings()->setConfirmAccessItem(true);

    auto service = enableService();
    VERIFY(service);
    auto coll = getDefaultCollection(service);
    VERIFY(coll);
    auto item = getFirstItem(coll);
    VERIFY(item);
    auto sess = openSession(service, DhIetf1024Sha256Aes128CbcPkcs7::Algorithm);
    VERIFY(sess);

    DBUS_COMPARE(item->locked(), true);

    {
        DBUS_GET2(unlocked, promptPath, service->Unlock({QDBusObjectPath(item->path())}));
        // nothing is unlocked immediately without user's action
        COMPARE(unlocked, {});

        auto prompt = getProxy<PromptProxy>(promptPath);
        VERIFY(prompt);
        QSignalSpy spyPromptCompleted(prompt.data(), SIGNAL(Completed(bool, QDBusVariant)));
        VERIFY(spyPromptCompleted.isValid());

        // nothing is unlocked yet
        COMPARE(spyPromptCompleted.count(), 0);
        DBUS_COMPARE(item->locked(), true);

        // drive the prompt
        DBUS_VERIFY(prompt->Prompt(""));
        // remember and include future entries
        VERIFY(driveAccessControlDialog(true, true));

        VERIFY(waitForSignal(spyPromptCompleted, 1));
        {
            auto args = spyPromptCompleted.takeFirst();
            COMPARE(args.size(), 2);
            COMPARE(args.at(0).toBool(), false);
            COMPARE(getSignalVariantArgument<QList<QDBusObjectPath>>(args.at(1)), {QDBusObjectPath(item->path())});
        }

        // unlocked
        DBUS_COMPARE(item->locked(), false);
    }

    // check other entries are also unlocked
    {
        DBUS_GET(itemPaths, coll->items());
        VERIFY(itemPaths.size() > 1);
        auto anotherItem = getProxy<ItemProxy>(itemPaths.last());
        VERIFY(anotherItem);
        DBUS_COMPARE(anotherItem->locked(), false);
    }
}

void TestGuiFdoSecrets::testServiceLock()
{
    auto service = enableService();
    VERIFY(service);
    auto coll = getDefaultCollection(service);
    VERIFY(coll);

    QSignalSpy spyCollectionCreated(service.data(), SIGNAL(CollectionCreated(QDBusObjectPath)));
    VERIFY(spyCollectionCreated.isValid());
    QSignalSpy spyCollectionDeleted(service.data(), SIGNAL(CollectionDeleted(QDBusObjectPath)));
    VERIFY(spyCollectionDeleted.isValid());
    QSignalSpy spyCollectionChanged(service.data(), SIGNAL(CollectionChanged(QDBusObjectPath)));
    VERIFY(spyCollectionChanged.isValid());

    // if the db is modified, prompt user
    m_db->markAsModified();
    {
        DBUS_GET2(locked, promptPath, service->Lock({QDBusObjectPath(coll->path())}));
        COMPARE(locked, {});
        auto prompt = getProxy<PromptProxy>(promptPath);
        VERIFY(prompt);
        QSignalSpy spyPromptCompleted(prompt.data(), SIGNAL(Completed(bool, QDBusVariant)));
        VERIFY(spyPromptCompleted.isValid());

        // prompt and click cancel
        MessageBox::setNextAnswer(MessageBox::Cancel);
        DBUS_VERIFY(prompt->Prompt(""));
        processEvents();

        VERIFY(waitForSignal(spyPromptCompleted, 1));
        auto args = spyPromptCompleted.takeFirst();
        COMPARE(args.count(), 2);
        COMPARE(args.at(0).toBool(), true);
        COMPARE(getSignalVariantArgument<QList<QDBusObjectPath>>(args.at(1)), {});

        DBUS_COMPARE(coll->locked(), false);
    }
    {
        DBUS_GET2(locked, promptPath, service->Lock({QDBusObjectPath(coll->path())}));
        COMPARE(locked, {});
        auto prompt = getProxy<PromptProxy>(promptPath);
        VERIFY(prompt);
        QSignalSpy spyPromptCompleted(prompt.data(), SIGNAL(Completed(bool, QDBusVariant)));
        VERIFY(spyPromptCompleted.isValid());

        // prompt and click save
        MessageBox::setNextAnswer(MessageBox::Save);
        DBUS_VERIFY(prompt->Prompt(""));
        processEvents();

        VERIFY(waitForSignal(spyPromptCompleted, 1));
        auto args = spyPromptCompleted.takeFirst();
        COMPARE(args.count(), 2);
        COMPARE(args.at(0).toBool(), false);
        COMPARE(getSignalVariantArgument<QList<QDBusObjectPath>>(args.at(1)), {QDBusObjectPath(coll->path())});

        DBUS_COMPARE(coll->locked(), true);
    }

    VERIFY(waitForSignal(spyCollectionCreated, 0));
    QTRY_VERIFY(!spyCollectionChanged.isEmpty());
    for (const auto& args : spyCollectionChanged) {
        COMPARE(args.size(), 1);
        COMPARE(args.at(0).value<QDBusObjectPath>().path(), coll->path());
    }
    VERIFY(waitForSignal(spyCollectionDeleted, 0));

    // locking item locks the whole db
    unlockDatabaseInBackend();
    {
        auto item = getFirstItem(coll);
        DBUS_GET2(locked, promptPath, service->Lock({QDBusObjectPath(item->path())}));
        COMPARE(locked, {});
        auto prompt = getProxy<PromptProxy>(promptPath);
        VERIFY(prompt);

        MessageBox::setNextAnswer(MessageBox::Save);
        DBUS_VERIFY(prompt->Prompt(""));
        processEvents();

        DBUS_COMPARE(coll->locked(), true);
    }
}

void TestGuiFdoSecrets::testServiceLockConcurrent()
{
    auto service = enableService();
    VERIFY(service);
    auto coll = getDefaultCollection(service);
    VERIFY(coll);

    m_db->markAsModified();

    DBUS_GET2(locked, promptPath, service->Lock({QDBusObjectPath(coll->path())}));
    auto prompt = getProxy<PromptProxy>(promptPath);
    VERIFY(prompt);
    QSignalSpy spyPromptCompleted(prompt.data(), SIGNAL(Completed(bool, QDBusVariant)));
    VERIFY(spyPromptCompleted.isValid());

    DBUS_GET2(locked2, promptPath2, service->Lock({QDBusObjectPath(coll->path())}));
    auto prompt2 = getProxy<PromptProxy>(promptPath2);
    VERIFY(prompt2);
    QSignalSpy spyPromptCompleted2(prompt2.data(), SIGNAL(Completed(bool, QDBusVariant)));
    VERIFY(spyPromptCompleted2.isValid());

    // prompt and click save
    MessageBox::setNextAnswer(MessageBox::Save);
    DBUS_VERIFY(prompt->Prompt(""));

    // second prompt should not show dialog
    DBUS_VERIFY(prompt2->Prompt(""));

    VERIFY(waitForSignal(spyPromptCompleted, 1));
    {
        auto args = spyPromptCompleted.takeFirst();
        COMPARE(args.count(), 2);
        COMPARE(args.at(0).toBool(), false);
        COMPARE(getSignalVariantArgument<QList<QDBusObjectPath>>(args.at(1)), {QDBusObjectPath(coll->path())});
    }

    VERIFY(waitForSignal(spyPromptCompleted2, 1));
    {
        auto args = spyPromptCompleted2.takeFirst();
        COMPARE(args.count(), 2);
        COMPARE(args.at(0).toBool(), false);
        COMPARE(getSignalVariantArgument<QList<QDBusObjectPath>>(args.at(1)), {QDBusObjectPath(coll->path())});
    }

    DBUS_COMPARE(coll->locked(), true);
}

void TestGuiFdoSecrets::testSessionOpen()
{
    auto service = enableService();
    VERIFY(service);

    auto sess = openSession(service, PlainCipher::Algorithm);
    VERIFY(sess);

    sess = openSession(service, DhIetf1024Sha256Aes128CbcPkcs7::Algorithm);
    VERIFY(sess);
}

void TestGuiFdoSecrets::testSessionClose()
{
    auto service = enableService();
    VERIFY(service);

    auto sess = openSession(service, PlainCipher::Algorithm);
    VERIFY(sess);

    DBUS_VERIFY(sess->Close());
}

void TestGuiFdoSecrets::testCollectionCreate()
{
    auto service = enableService();
    VERIFY(service);

    QSignalSpy spyCollectionCreated(service.data(), SIGNAL(CollectionCreated(QDBusObjectPath)));
    VERIFY(spyCollectionCreated.isValid());

    // returns existing if alias is nonempty and exists
    {
        auto existing = getDefaultCollection(service);
        DBUS_GET2(collPath,
                  promptPath,
                  service->CreateCollection({{DBUS_INTERFACE_SECRET_COLLECTION + ".Label", "NewDB"}}, "default"));
        COMPARE(promptPath, QDBusObjectPath("/"));
        COMPARE(collPath.path(), existing->path());
    }
    VERIFY(waitForSignal(spyCollectionCreated, 0));

    // create new one and set properties
    {
        DBUS_GET2(collPath,
                  promptPath,
                  service->CreateCollection({{DBUS_INTERFACE_SECRET_COLLECTION + ".Label", "Test NewDB"}}, "mydatadb"));
        COMPARE(collPath, QDBusObjectPath("/"));
        auto prompt = getProxy<PromptProxy>(promptPath);
        VERIFY(prompt);

        QSignalSpy spyPromptCompleted(prompt.data(), SIGNAL(Completed(bool, QDBusVariant)));
        VERIFY(spyPromptCompleted.isValid());

        DBUS_VERIFY(prompt->Prompt(""));
        VERIFY(driveNewDatabaseWizard());

        VERIFY(waitForSignal(spyPromptCompleted, 1));
        auto args = spyPromptCompleted.takeFirst();
        COMPARE(args.size(), 2);
        COMPARE(args.at(0).toBool(), false);
        auto coll = getProxy<CollectionProxy>(getSignalVariantArgument<QDBusObjectPath>(args.at(1)));
        VERIFY(coll);

        DBUS_COMPARE(coll->label(), QStringLiteral("Test NewDB"));

        VERIFY(waitForSignal(spyCollectionCreated, 1));
        {
            args = spyCollectionCreated.takeFirst();
            COMPARE(args.size(), 1);
            COMPARE(args.at(0).value<QDBusObjectPath>().path(), coll->path());
        }
    }
}

void TestGuiFdoSecrets::testCollectionDelete()
{
    auto service = enableService();
    VERIFY(service);
    auto coll = getDefaultCollection(service);
    VERIFY(coll);
    // save the path which will be gone after the deletion.
    auto collPath = coll->path();

    QSignalSpy spyCollectionDeleted(service.data(), SIGNAL(CollectionDeleted(QDBusObjectPath)));
    VERIFY(spyCollectionDeleted.isValid());

    m_db->markAsModified();
    DBUS_GET(promptPath, coll->Delete());
    auto prompt = getProxy<PromptProxy>(promptPath);
    VERIFY(prompt);
    QSignalSpy spyPromptCompleted(prompt.data(), SIGNAL(Completed(bool, QDBusVariant)));
    VERIFY(spyPromptCompleted.isValid());

    // prompt and click save
    MessageBox::setNextAnswer(MessageBox::Save);
    DBUS_VERIFY(prompt->Prompt(""));

    // closing the tab should have deleted the database if not in testing
    // but deleteLater is not processed in QApplication::processEvent
    // see https://doc.qt.io/qt-5/qcoreapplication.html#processEvents

    VERIFY(waitForSignal(spyPromptCompleted, 1));
    auto args = spyPromptCompleted.takeFirst();
    COMPARE(args.count(), 2);
    COMPARE(args.at(0).toBool(), false);
    COMPARE(args.at(1).value<QDBusVariant>().variant().toString(), QStringLiteral(""));

    // however, the object should already be taken down from dbus
    {
        auto reply = coll->locked();
        VERIFY(reply.isFinished() && reply.isError());
        COMPARE(reply.error().type(), QDBusError::UnknownObject);
    }

    VERIFY(waitForSignal(spyCollectionDeleted, 1));
    {
        args = spyCollectionDeleted.takeFirst();
        COMPARE(args.size(), 1);
        COMPARE(args.at(0).value<QDBusObjectPath>().path(), collPath);
    }
}

void TestGuiFdoSecrets::testCollectionDeleteConcurrent()
{
    auto service = enableService();
    VERIFY(service);
    auto coll = getDefaultCollection(service);
    VERIFY(coll);

    m_db->markAsModified();
    DBUS_GET(promptPath, coll->Delete());
    auto prompt = getProxy<PromptProxy>(promptPath);
    VERIFY(prompt);
    QSignalSpy spyPromptCompleted(prompt.data(), SIGNAL(Completed(bool, QDBusVariant)));
    VERIFY(spyPromptCompleted.isValid());

    // before interacting with the prompt, another request come in
    DBUS_GET(promptPath2, coll->Delete());
    auto prompt2 = getProxy<PromptProxy>(promptPath);
    VERIFY(prompt2);
    QSignalSpy spyPromptCompleted2(prompt2.data(), SIGNAL(Completed(bool, QDBusVariant)));
    VERIFY(spyPromptCompleted2.isValid());

    // prompt and click save
    MessageBox::setNextAnswer(MessageBox::Save);
    DBUS_VERIFY(prompt->Prompt(""));

    // there should be no prompt
    DBUS_VERIFY(prompt2->Prompt(""));

    VERIFY(waitForSignal(spyPromptCompleted, 1));
    {
        auto args = spyPromptCompleted.takeFirst();
        COMPARE(args.count(), 2);
        COMPARE(args.at(0).toBool(), false);
        COMPARE(args.at(1).value<QDBusVariant>().variant().toString(), QStringLiteral(""));
    }

    VERIFY(waitForSignal(spyPromptCompleted2, 1));
    {
        auto args = spyPromptCompleted2.takeFirst();
        COMPARE(args.count(), 2);
        COMPARE(args.at(0).toBool(), false);
        COMPARE(args.at(1).value<QDBusVariant>().variant().toString(), QStringLiteral(""));
    }

    {
        auto reply = coll->locked();
        VERIFY(reply.isFinished() && reply.isError());
        COMPARE(reply.error().type(), QDBusError::UnknownObject);
    }
}

void TestGuiFdoSecrets::testCollectionChange()
{
    auto service = enableService();
    VERIFY(service);
    auto coll = getDefaultCollection(service);
    VERIFY(coll);

    QSignalSpy spyCollectionChanged(service.data(), SIGNAL(CollectionChanged(QDBusObjectPath)));
    VERIFY(spyCollectionChanged.isValid());

    DBUS_VERIFY(coll->setLabel("anotherLabel"));
    COMPARE(m_db->metadata()->name(), QStringLiteral("anotherLabel"));
    QTRY_COMPARE(spyCollectionChanged.size(), 1);
    {
        auto args = spyCollectionChanged.takeFirst();
        COMPARE(args.size(), 1);
        COMPARE(args.at(0).value<QDBusObjectPath>().path(), coll->path());
    }
}

void TestGuiFdoSecrets::testHiddenFilename()
{
    // when file name contains leading dot, all parts excepting the last should be used
    // for collection name, and the registration should success
    VERIFY(m_dbFile->rename(QFileInfo(*m_dbFile).path() + "/.Name.kdbx"));

    // reset is necessary to not hold database longer and cause connections
    // not cleaned up when the database tab is closed.
    m_db.reset();
    VERIFY(m_tabWidget->closeAllDatabaseTabs());
    m_tabWidget->addDatabaseTab(m_dbFile->fileName(), false, "a");
    m_dbWidget = m_tabWidget->currentDatabaseWidget();
    m_db = m_dbWidget->database();

    // enable the service
    auto service = enableService();
    VERIFY(service);

    // collection is properly registered
    auto coll = getDefaultCollection(service);
    auto collObj = m_plugin->dbus()->pathToObject<Collection>(QDBusObjectPath(coll->path()));
    VERIFY(collObj);
    COMPARE(collObj->name(), QStringLiteral(".Name"));
}

void TestGuiFdoSecrets::testDuplicateName()
{
    QTemporaryDir dir;
    VERIFY(dir.isValid());
    // create another file under different path but with the same filename
    QString anotherFile = dir.path() + "/" + QFileInfo(*m_dbFile).fileName();
    m_dbFile->copy(anotherFile);
    m_tabWidget->addDatabaseTab(anotherFile, false, "a");

    auto service = enableService();
    VERIFY(service);

    // when two databases have the same name, one of it will have part of its uuid suffixed
    const QString pathNoSuffix = QStringLiteral("/org/freedesktop/secrets/collection/KeePassXC");
    DBUS_GET(colls, service->collections());
    COMPARE(colls.size(), 2);
    COMPARE(colls[0].path(), pathNoSuffix);
    VERIFY(colls[1].path() != pathNoSuffix);
}

void TestGuiFdoSecrets::testItemCreate()
{
    auto service = enableService();
    VERIFY(service);
    auto coll = getDefaultCollection(service);
    VERIFY(coll);
    auto sess = openSession(service, DhIetf1024Sha256Aes128CbcPkcs7::Algorithm);
    VERIFY(sess);

    QSignalSpy spyItemCreated(coll.data(), SIGNAL(ItemCreated(QDBusObjectPath)));
    VERIFY(spyItemCreated.isValid());

    // create item
    StringStringMap attributes{
        {"application", "fdosecrets-test"},
        {"attr-i[bute]", "![some] -value*"},
    };

    auto item = createItem(sess, coll, "abc", "Password", attributes, false);
    VERIFY(item);

    // signals
    {
        VERIFY(waitForSignal(spyItemCreated, 1));
        auto args = spyItemCreated.takeFirst();
        COMPARE(args.size(), 1);
        COMPARE(args.at(0).value<QDBusObjectPath>().path(), item->path());
    }

    // attributes
    {
        DBUS_GET(actual, item->attributes());
        for (const auto& key : attributes.keys()) {
            COMPARE(actual[key], attributes[key]);
        }
    }

    // label
    DBUS_COMPARE(item->label(), QStringLiteral("abc"));

    // secrets
    {
        DBUS_GET(ss, item->GetSecret(QDBusObjectPath(sess->path())));
        auto decrypted = m_clientCipher->decrypt(ss.unmarshal(m_plugin->dbus()));
        COMPARE(decrypted.value, QByteArrayLiteral("Password"));
    }

    // searchable
    {
        DBUS_GET2(unlocked, locked, service->SearchItems(attributes));
        COMPARE(locked, {});
        COMPARE(unlocked, {QDBusObjectPath(item->path())});
    }
    {
        DBUS_GET(unlocked, coll->SearchItems(attributes));
        VERIFY(unlocked.contains(QDBusObjectPath(item->path())));
    }
}

void TestGuiFdoSecrets::testItemCreateUnlock()
{
    auto service = enableService();
    VERIFY(service);
    auto coll = getDefaultCollection(service);
    VERIFY(coll);
    auto sess = openSession(service, DhIetf1024Sha256Aes128CbcPkcs7::Algorithm);
    VERIFY(sess);

    // NOTE: entries are no longer valid after locking
    lockDatabaseInBackend();

    QSignalSpy spyItemCreated(coll.data(), SIGNAL(ItemCreated(QDBusObjectPath)));
    VERIFY(spyItemCreated.isValid());

    // create item
    StringStringMap attributes{
        {"application", "fdosecrets-test"},
        {"attr-i[bute]", "![some] -value*"},
    };

    auto item = createItem(sess, coll, "abc", "Password", attributes, false, false, true);
    VERIFY(item);
}

void TestGuiFdoSecrets::testItemChange()
{
    auto service = enableService();
    VERIFY(service);
    auto coll = getDefaultCollection(service);
    VERIFY(coll);
    auto item = getFirstItem(coll);
    VERIFY(item);
    auto itemObj = m_plugin->dbus()->pathToObject<Item>(QDBusObjectPath(item->path()));
    VERIFY(itemObj);
    auto entry = itemObj->backend();
    VERIFY(entry);

    QSignalSpy spyItemChanged(coll.data(), SIGNAL(ItemChanged(QDBusObjectPath)));
    VERIFY(spyItemChanged.isValid());

    DBUS_VERIFY(item->setLabel("anotherLabel"));
    COMPARE(entry->title(), QStringLiteral("anotherLabel"));
    QTRY_VERIFY(!spyItemChanged.isEmpty());
    for (const auto& args : spyItemChanged) {
        COMPARE(args.size(), 1);
        COMPARE(args.at(0).value<QDBusObjectPath>().path(), item->path());
    }

    spyItemChanged.clear();
    DBUS_VERIFY(item->setAttributes({
        {"abc", "def"},
    }));
    COMPARE(entry->attributes()->value("abc"), QStringLiteral("def"));
    QTRY_VERIFY(!spyItemChanged.isEmpty());
    for (const auto& args : spyItemChanged) {
        COMPARE(args.size(), 1);
        COMPARE(args.at(0).value<QDBusObjectPath>().path(), item->path());
    }
}

void TestGuiFdoSecrets::testItemReplace()
{
    auto service = enableService();
    VERIFY(service);
    auto coll = getDefaultCollection(service);
    VERIFY(coll);
    auto sess = openSession(service, DhIetf1024Sha256Aes128CbcPkcs7::Algorithm);
    VERIFY(sess);

    // create item
    StringStringMap attr1{
        {"application", "fdosecrets-test"},
        {"attr-i[bute]", "![some] -value*"},
        {"fdosecrets-attr", "1"},
    };
    StringStringMap attr2{
        {"application", "fdosecrets-test"},
        {"attr-i[bute]", "![some] -value*"},
        {"fdosecrets-attr", "2"},
    };

    auto item1 = createItem(sess, coll, "abc1", "Password", attr1, false);
    VERIFY(item1);
    auto item2 = createItem(sess, coll, "abc2", "Password", attr2, false);
    VERIFY(item2);

    {
        DBUS_GET2(unlocked, locked, service->SearchItems({{"application", "fdosecrets-test"}}));
        QSet<QDBusObjectPath> expected{QDBusObjectPath(item1->path()), QDBusObjectPath(item2->path())};
        COMPARE(Tools::asSet(unlocked), expected);
    }

    QSignalSpy spyItemCreated(coll.data(), SIGNAL(ItemCreated(QDBusObjectPath)));
    VERIFY(spyItemCreated.isValid());
    QSignalSpy spyItemChanged(coll.data(), SIGNAL(ItemChanged(QDBusObjectPath)));
    VERIFY(spyItemChanged.isValid());

    {
        // when replace, existing item with matching attr is updated
        auto item3 = createItem(sess, coll, "abc3", "Password", attr2, true);
        VERIFY(item3);
        COMPARE(item2->path(), item3->path());
        DBUS_COMPARE(item3->label(), QStringLiteral("abc3"));
        // there are still 2 entries
        DBUS_GET2(unlocked, locked, service->SearchItems({{"application", "fdosecrets-test"}}));
        QSet<QDBusObjectPath> expected{QDBusObjectPath(item1->path()), QDBusObjectPath(item2->path())};
        COMPARE(Tools::asSet(unlocked), expected);

        VERIFY(waitForSignal(spyItemCreated, 0));
        // there may be multiple changed signals, due to each item attribute is set separately
        QTRY_VERIFY(!spyItemChanged.isEmpty());
        for (const auto& args : spyItemChanged) {
            COMPARE(args.size(), 1);
            COMPARE(args.at(0).value<QDBusObjectPath>().path(), item3->path());
        }
    }

    spyItemCreated.clear();
    spyItemChanged.clear();
    {
        // when NOT replace, another entry is created
        auto item4 = createItem(sess, coll, "abc4", "Password", attr2, false);
        VERIFY(item4);
        DBUS_COMPARE(item2->label(), QStringLiteral("abc3"));
        DBUS_COMPARE(item4->label(), QStringLiteral("abc4"));
        // there are 3 entries
        DBUS_GET2(unlocked, locked, service->SearchItems({{"application", "fdosecrets-test"}}));
        QSet<QDBusObjectPath> expected{
            QDBusObjectPath(item1->path()),
            QDBusObjectPath(item2->path()),
            QDBusObjectPath(item4->path()),
        };
        COMPARE(Tools::asSet(unlocked), expected);

        VERIFY(waitForSignal(spyItemCreated, 1));
        {
            auto args = spyItemCreated.takeFirst();
            COMPARE(args.size(), 1);
            COMPARE(args.at(0).value<QDBusObjectPath>().path(), item4->path());
        }
        // there may be multiple changed signals, due to each item attribute is set separately
        VERIFY(!spyItemChanged.isEmpty());
        for (const auto& args : spyItemChanged) {
            COMPARE(args.size(), 1);
            COMPARE(args.at(0).value<QDBusObjectPath>().path(), item4->path());
        }
    }
}

void TestGuiFdoSecrets::testItemReplaceExistingLocked()
{
    auto service = enableService();
    VERIFY(service);
    auto coll = getDefaultCollection(service);
    VERIFY(coll);
    auto sess = openSession(service, DhIetf1024Sha256Aes128CbcPkcs7::Algorithm);
    VERIFY(sess);

    // create item
    StringStringMap attr1{
        {"application", "fdosecrets-test"},
        {"attr-i[bute]", "![some] -value*"},
        {"fdosecrets-attr", "1"},
    };

    auto item = createItem(sess, coll, "abc1", "Password", attr1, false);
    VERIFY(item);

    // make sure the item is locked
    {
        auto itemObj = m_plugin->dbus()->pathToObject<Item>(QDBusObjectPath(item->path()));
        VERIFY(itemObj);
        auto entry = itemObj->backend();
        VERIFY(entry);
        FdoSecrets::settings()->setConfirmAccessItem(true);
        m_client->setItemAuthorized(entry->uuid(), AuthDecision::Undecided);
        DBUS_COMPARE(item->locked(), true);
    }

    // when replace with a locked item, there will be a prompt
    auto item2 = createItem(sess, coll, "abc2", "PasswordUpdated", attr1, true, true);
    VERIFY(item2);
    COMPARE(item2->path(), item->path());
    DBUS_COMPARE(item2->label(), QStringLiteral("abc2"));
}

void TestGuiFdoSecrets::testItemSecret()
{
    const QString TEXT_PLAIN = "text/plain";
    const QString APPLICATION_OCTET_STREAM = "application/octet-stream";

    auto service = enableService();
    VERIFY(service);
    auto coll = getDefaultCollection(service);
    VERIFY(coll);
    auto item = getFirstItem(coll);
    VERIFY(item);
    auto sess = openSession(service, DhIetf1024Sha256Aes128CbcPkcs7::Algorithm);
    VERIFY(sess);

    auto itemObj = m_plugin->dbus()->pathToObject<Item>(QDBusObjectPath(item->path()));
    VERIFY(itemObj);
    auto entry = itemObj->backend();
    VERIFY(entry);

    // plain text secret
    {
        DBUS_GET(encrypted, item->GetSecret(QDBusObjectPath(sess->path())));
        auto ss = m_clientCipher->decrypt(encrypted.unmarshal(m_plugin->dbus()));
        COMPARE(ss.contentType, TEXT_PLAIN);
        COMPARE(ss.value, entry->password().toUtf8());
    }

    // get secret with notification
    FdoSecrets::settings()->setShowNotification(true);
    {
        QSignalSpy spyShowNotification(m_plugin, SIGNAL(requestShowNotification(QString, QString, int)));
        VERIFY(spyShowNotification.isValid());

        DBUS_GET(encrypted, item->GetSecret(QDBusObjectPath(sess->path())));
        auto ss = m_clientCipher->decrypt(encrypted.unmarshal(m_plugin->dbus()));
        COMPARE(ss.contentType, TEXT_PLAIN);
        COMPARE(ss.value, entry->password().toUtf8());

        COMPARE(ss.contentType, TEXT_PLAIN);
        COMPARE(ss.value, entry->password().toUtf8());

        VERIFY(waitForSignal(spyShowNotification, 1));
    }
    FdoSecrets::settings()->setShowNotification(false);

    // set secret with plain text
    {
        // first create Secret in wire format,
        // then convert to internal format and encrypt
        // finally convert encrypted internal format back to wire format to pass to SetSecret
        const QByteArray expected = QByteArrayLiteral("NewPassword");
        auto encrypted = encryptPassword(expected, TEXT_PLAIN, sess);
        DBUS_VERIFY(item->SetSecret(encrypted));
        COMPARE(entry->password().toUtf8(), expected);
    }

    // set secret with something else is saved as attachment
    const QByteArray expected = QByteArrayLiteral("NewPasswordBinary");
    {
        auto encrypted = encryptPassword(expected, APPLICATION_OCTET_STREAM, sess);
        DBUS_VERIFY(item->SetSecret(encrypted));
        COMPARE(entry->password(), QStringLiteral(""));
    }
    {
        DBUS_GET(encrypted, item->GetSecret(QDBusObjectPath(sess->path())));
        auto ss = m_clientCipher->decrypt(encrypted.unmarshal(m_plugin->dbus()));
        COMPARE(ss.contentType, APPLICATION_OCTET_STREAM);
        COMPARE(ss.value, expected);
    }
}

void TestGuiFdoSecrets::testItemDelete()
{
    FdoSecrets::settings()->setConfirmDeleteItem(true);

    auto service = enableService();
    VERIFY(service);
    auto coll = getDefaultCollection(service);
    VERIFY(coll);
    auto item = getFirstItem(coll);
    VERIFY(item);
    // save the path which will be gone after the deletion.
    auto itemPath = item->path();

    QSignalSpy spyItemDeleted(coll.data(), SIGNAL(ItemDeleted(QDBusObjectPath)));
    VERIFY(spyItemDeleted.isValid());

    DBUS_GET(promptPath, item->Delete());
    auto prompt = getProxy<PromptProxy>(promptPath);
    VERIFY(prompt);

    QSignalSpy spyPromptCompleted(prompt.data(), SIGNAL(Completed(bool, QDBusVariant)));
    VERIFY(spyPromptCompleted.isValid());

    // prompt and click save
    auto itemObj = m_plugin->dbus()->pathToObject<Item>(QDBusObjectPath(item->path()));
    VERIFY(itemObj);
    MessageBox::setNextAnswer(MessageBox::Delete);
    DBUS_VERIFY(prompt->Prompt(""));

    VERIFY(waitForSignal(spyPromptCompleted, 1));
    auto args = spyPromptCompleted.takeFirst();
    COMPARE(args.count(), 2);
    COMPARE(args.at(0).toBool(), false);
    COMPARE(args.at(1).toString(), QStringLiteral(""));

    VERIFY(waitForSignal(spyItemDeleted, 1));
    args = spyItemDeleted.takeFirst();
    COMPARE(args.size(), 1);
    COMPARE(args.at(0).value<QDBusObjectPath>().path(), itemPath);
}

void TestGuiFdoSecrets::testItemLockState()
{
    auto service = enableService();
    VERIFY(service);
    auto coll = getDefaultCollection(service);
    VERIFY(coll);
    auto item = getFirstItem(coll);
    VERIFY(item);
    auto sess = openSession(service, DhIetf1024Sha256Aes128CbcPkcs7::Algorithm);
    VERIFY(sess);
    auto itemObj = m_plugin->dbus()->pathToObject<Item>(QDBusObjectPath(item->path()));
    VERIFY(itemObj);
    auto entry = itemObj->backend();
    VERIFY(entry);

    auto secret =
        wire::Secret{
            QDBusObjectPath(sess->path()),
            {},
            "NewPassword",
            "text/plain",
        }
            .unmarshal(m_plugin->dbus());
    auto encrypted = m_clientCipher->encrypt(secret).marshal();

    // when access confirmation is disabled, item is unlocked when the collection is unlocked
    FdoSecrets::settings()->setConfirmAccessItem(false);
    DBUS_COMPARE(item->locked(), false);

    // when access confirmation is enabled, item is locked if the client has no authorization
    FdoSecrets::settings()->setConfirmAccessItem(true);
    DBUS_COMPARE(item->locked(), true);
    // however, item properties are still accessible as long as the collection is unlocked
    DBUS_VERIFY(item->attributes());
    DBUS_VERIFY(item->setAttributes({}));
    DBUS_VERIFY(item->label());
    DBUS_VERIFY(item->setLabel("abc"));
    DBUS_VERIFY(item->created());
    DBUS_VERIFY(item->modified());
    // except secret, which is locked
    {
        auto reply = item->GetSecret(QDBusObjectPath(sess->path()));
        VERIFY(reply.isError());
        COMPARE(reply.error().name(), DBUS_ERROR_SECRET_IS_LOCKED);
    }
    {
        auto reply = item->SetSecret(encrypted);
        VERIFY(reply.isError());
        COMPARE(reply.error().name(), DBUS_ERROR_SECRET_IS_LOCKED);
    }

    // item is unlocked if the client is authorized
    m_client->setItemAuthorized(entry->uuid(), AuthDecision::Allowed);
    DBUS_COMPARE(item->locked(), false);
    DBUS_VERIFY(item->GetSecret(QDBusObjectPath(sess->path())));
    DBUS_VERIFY(item->SetSecret(encrypted));
}

void TestGuiFdoSecrets::testItemRejectSetReferenceFields()
{
    // expose a subgroup, entries in it should not be able to retrieve data from entries outside it
    auto rootEntry = m_db->rootGroup()->entries().first();
    VERIFY(rootEntry);
    auto subgroup = m_db->rootGroup()->findGroupByPath("/Homebanking/Subgroup");
    VERIFY(subgroup);
    FdoSecrets::settings()->setExposedGroup(m_db, subgroup->uuid());
    auto service = enableService();
    VERIFY(service);
    auto coll = getDefaultCollection(service);
    VERIFY(coll);
    auto item = getFirstItem(coll);
    VERIFY(item);
    auto sess = openSession(service, DhIetf1024Sha256Aes128CbcPkcs7::Algorithm);
    VERIFY(sess);

    const auto refText = QStringLiteral("{REF:P@T:%1}").arg(rootEntry->title());

    // reject ref in label
    {
        auto reply = item->setLabel(refText);
        VERIFY(reply.isFinished() && reply.isError());
        COMPARE(reply.error().type(), QDBusError::InvalidArgs);
    }
    // reject ref in custom attributes
    {
        auto reply = item->setAttributes({{"steal", refText}});
        VERIFY(reply.isFinished() && reply.isError());
        COMPARE(reply.error().type(), QDBusError::InvalidArgs);
    }
    // reject ref in password
    {
        auto reply = item->SetSecret(encryptPassword(refText.toUtf8(), "text/plain", sess));
        VERIFY(reply.isFinished() && reply.isError());
        COMPARE(reply.error().type(), QDBusError::InvalidArgs);
    }
    // reject ref in content type
    {
        auto reply = item->SetSecret(encryptPassword("dummy", refText, sess));
        VERIFY(reply.isFinished() && reply.isError());
        COMPARE(reply.error().type(), QDBusError::InvalidArgs);
    }
}

void TestGuiFdoSecrets::testAlias()
{
    auto service = enableService();
    VERIFY(service);

    // read default alias
    DBUS_GET(collPath, service->ReadAlias("default"));
    auto coll = getProxy<CollectionProxy>(collPath);
    VERIFY(coll);

    // set extra alias
    DBUS_VERIFY(service->SetAlias("another", QDBusObjectPath(collPath)));

    // get using extra alias
    DBUS_GET(collPath2, service->ReadAlias("another"));
    COMPARE(collPath2, collPath);
}

void TestGuiFdoSecrets::testDefaultAliasAlwaysPresent()
{
    auto service = enableService();
    VERIFY(service);

    // one collection, which is default alias
    auto coll = getDefaultCollection(service);
    VERIFY(coll);

    // after locking, the collection is still there, but locked
    lockDatabaseInBackend();

    coll = getDefaultCollection(service);
    VERIFY(coll);
    DBUS_COMPARE(coll->locked(), true);

    // unlock the database, the alias and collection is present
    unlockDatabaseInBackend();

    coll = getDefaultCollection(service);
    VERIFY(coll);
    DBUS_COMPARE(coll->locked(), false);
}

void TestGuiFdoSecrets::testExposeSubgroup()
{
    auto subgroup = m_db->rootGroup()->findGroupByPath("/Homebanking/Subgroup");
    VERIFY(subgroup);
    FdoSecrets::settings()->setExposedGroup(m_db, subgroup->uuid());
    auto service = enableService();
    VERIFY(service);

    auto coll = getDefaultCollection(service);
    VERIFY(coll);

    // exposing subgroup does not expose entries in other groups
    DBUS_GET(itemPaths, coll->items());
    QSet<Entry*> exposedEntries;
    for (const auto& itemPath : itemPaths) {
        exposedEntries << m_plugin->dbus()->pathToObject<Item>(itemPath)->backend();
    }
    COMPARE(exposedEntries, Tools::asSet(subgroup->entries()));
}

void TestGuiFdoSecrets::testModifyingExposedGroup()
{
    // test when exposed group is removed the collection is not exposed anymore
    auto subgroup = m_db->rootGroup()->findGroupByPath("/Homebanking");
    VERIFY(subgroup);
    FdoSecrets::settings()->setExposedGroup(m_db, subgroup->uuid());
    auto service = enableService();
    VERIFY(service);

    {
        DBUS_GET(collPaths, service->collections());
        COMPARE(collPaths.size(), 1);
    }

    m_db->metadata()->setRecycleBinEnabled(true);
    m_db->recycleGroup(subgroup);
    processEvents();

    {
        DBUS_GET(collPaths, service->collections());
        COMPARE(collPaths, {});
    }

    // test setting another exposed group, the collection will be exposed again
    FdoSecrets::settings()->setExposedGroup(m_db, m_db->rootGroup()->uuid());
    processEvents();
    {
        DBUS_GET(collPaths, service->collections());
        COMPARE(collPaths.size(), 1);
    }
}

void TestGuiFdoSecrets::testNoExposeRecycleBin()
{
    // when the recycle bin is underneath the exposed group
    // be careful not to expose entries in there

    FdoSecrets::settings()->setExposedGroup(m_db, m_db->rootGroup()->uuid());
    m_db->metadata()->setRecycleBinEnabled(true);

    auto entry = m_db->rootGroup()->entries().first();
    VERIFY(entry);
    m_db->recycleEntry(entry);
    processEvents();

    auto service = enableService();
    VERIFY(service);

    auto coll = getDefaultCollection(service);
    VERIFY(coll);

    // exposing subgroup does not expose entries in other groups
    DBUS_GET(itemPaths, coll->items());
    QSet<Entry*> exposedEntries;
    for (const auto& itemPath : itemPaths) {
        exposedEntries << m_plugin->dbus()->pathToObject<Item>(itemPath)->backend();
    }
    VERIFY(!exposedEntries.contains(entry));

    // searching should not return the entry
    DBUS_GET2(unlocked, locked, service->SearchItems({{"Title", entry->title()}}));
    COMPARE(locked, {});
    COMPARE(unlocked, {});
}

void TestGuiFdoSecrets::lockDatabaseInBackend()
{
    m_tabWidget->lockDatabases();
    m_db.reset();
    processEvents();
}

void TestGuiFdoSecrets::unlockDatabaseInBackend()
{
    m_dbWidget->performUnlockDatabase("a");
    m_db = m_dbWidget->database();
    processEvents();
}

void TestGuiFdoSecrets::processEvents()
{
    // Couldn't use QApplication::processEvents, because per Qt documentation:
    //     events that are posted while the function runs will be queued until a later round of event processing.
    // and we may post QTimer single shot events during event handling to achieve async method.
    // So we directly call event dispatcher in a loop until no events can be handled
    while (QAbstractEventDispatcher::instance()->processEvents(QEventLoop::AllEvents)) {
        // pass
    }
}

// the following functions have return value, switch macros to the version supporting that
#undef VERIFY
#undef VERIFY2
#undef COMPARE
#define VERIFY(stmt) VERIFY2_RET(stmt, "")
#define VERIFY2 VERIFY2_RET
#define COMPARE COMPARE_RET

QSharedPointer<ServiceProxy> TestGuiFdoSecrets::enableService()
{
    FdoSecrets::settings()->setEnabled(true);
    VERIFY(m_plugin);
    m_plugin->updateServiceState();
    return getProxy<ServiceProxy>(QDBusObjectPath(DBUS_PATH_SECRETS));
}

QSharedPointer<SessionProxy> TestGuiFdoSecrets::openSession(const QSharedPointer<ServiceProxy>& service,
                                                            const QString& algo)
{
    VERIFY(service);

    if (algo == PlainCipher::Algorithm) {
        DBUS_GET2(output, sessPath, service->OpenSession(algo, QDBusVariant("")));

        return getProxy<SessionProxy>(sessPath);
    } else if (algo == DhIetf1024Sha256Aes128CbcPkcs7::Algorithm) {
        DBUS_GET2(output, sessPath, service->OpenSession(algo, QDBusVariant(m_clientCipher->negotiationOutput())));
        m_clientCipher->updateClientPublicKey(output.variant().toByteArray());
        return getProxy<SessionProxy>(sessPath);
    }
    QTest::qFail("Unsupported algorithm", __FILE__, __LINE__);
    return {};
}

QSharedPointer<CollectionProxy> TestGuiFdoSecrets::getDefaultCollection(const QSharedPointer<ServiceProxy>& service)
{
    VERIFY(service);
    DBUS_GET(collPath, service->ReadAlias("default"));
    return getProxy<CollectionProxy>(collPath);
}

QSharedPointer<ItemProxy> TestGuiFdoSecrets::getFirstItem(const QSharedPointer<CollectionProxy>& coll)
{
    VERIFY(coll);
    DBUS_GET(itemPaths, coll->items());
    VERIFY(!itemPaths.isEmpty());
    return getProxy<ItemProxy>(itemPaths.first());
}

QSharedPointer<ItemProxy> TestGuiFdoSecrets::createItem(const QSharedPointer<SessionProxy>& sess,
                                                        const QSharedPointer<CollectionProxy>& coll,
                                                        const QString& label,
                                                        const QString& pass,
                                                        const StringStringMap& attr,
                                                        bool replace,
                                                        bool expectPrompt,
                                                        bool expectUnlockPrompt)
{
    VERIFY(sess);
    VERIFY(coll);

    QVariantMap properties{
        {DBUS_INTERFACE_SECRET_ITEM + ".Label", QVariant::fromValue(label)},
        {DBUS_INTERFACE_SECRET_ITEM + ".Attributes", QVariant::fromValue(attr)},
    };

    wire::Secret ss;
    ss.session = QDBusObjectPath(sess->path());
    ss.value = pass.toLocal8Bit();
    ss.contentType = "plain/text";
    auto encrypted = m_clientCipher->encrypt(ss.unmarshal(m_plugin->dbus())).marshal();

    DBUS_GET2(itemPath, promptPath, coll->CreateItem(properties, encrypted, replace));

    auto prompt = getProxy<PromptProxy>(promptPath);
    VERIFY(prompt);
    QSignalSpy spyPromptCompleted(prompt.data(), SIGNAL(Completed(bool, QDBusVariant)));
    VERIFY(spyPromptCompleted.isValid());

    // drive the prompt
    DBUS_VERIFY(prompt->Prompt(""));

    bool unlockFound = driveUnlockDialog();
    COMPARE(unlockFound, expectUnlockPrompt);

    bool found = driveAccessControlDialog();
    COMPARE(found, expectPrompt);

    VERIFY(waitForSignal(spyPromptCompleted, 1));
    auto args = spyPromptCompleted.takeFirst();
    COMPARE(args.size(), 2);
    COMPARE(args.at(0).toBool(), false);
    itemPath = getSignalVariantArgument<QDBusObjectPath>(args.at(1));

    return getProxy<ItemProxy>(itemPath);
}

FdoSecrets::wire::Secret
TestGuiFdoSecrets::encryptPassword(QByteArray value, QString contentType, const QSharedPointer<SessionProxy>& sess)
{
    wire::Secret ss;
    ss.contentType = std::move(contentType);
    ss.value = std::move(value);
    ss.session = QDBusObjectPath(sess->path());
    return m_clientCipher->encrypt(ss.unmarshal(m_plugin->dbus())).marshal();
}

bool TestGuiFdoSecrets::driveAccessControlDialog(bool remember, bool includeFutureEntries)
{
    processEvents();
    for (auto w : QApplication::topLevelWidgets()) {
        if (!w->isWindow()) {
            continue;
        }
        auto dlg = qobject_cast<AccessControlDialog*>(w);
        if (dlg && dlg->isVisible()) {
            auto rememberCheck = dlg->findChild<QCheckBox*>("rememberCheck");
            VERIFY(rememberCheck);
            rememberCheck->setChecked(remember);

            if (includeFutureEntries) {
                dlg->done(AccessControlDialog::AllowAll);
            } else {
                dlg->done(AccessControlDialog::AllowSelected);
            }

            processEvents();
            VERIFY(dlg->isHidden());
            return true;
        }
    }
    return false;
}

bool TestGuiFdoSecrets::driveNewDatabaseWizard()
{
    // processEvents will block because the NewDatabaseWizard is shown using exec
    // which creates a local QEventLoop.
    // so we do a little trick here to get the return value back
    bool ret = false;
    QTimer::singleShot(0, this, [this, &ret]() {
        ret = [this]() -> bool {
            auto wizard = m_tabWidget->findChild<NewDatabaseWizard*>();
            VERIFY(wizard);

            COMPARE(wizard->currentId(), 0);
            wizard->next();
            wizard->next();
            COMPARE(wizard->currentId(), 2);

            // enter password
            auto* passwordEdit =
                wizard->findChild<PasswordWidget*>("enterPasswordEdit")->findChild<QLineEdit*>("passwordEdit");
            auto* passwordRepeatEdit =
                wizard->findChild<PasswordWidget*>("repeatPasswordEdit")->findChild<QLineEdit*>("passwordEdit");
            VERIFY(passwordEdit);
            VERIFY(passwordRepeatEdit);
            QTest::keyClicks(passwordEdit, "test");
            QTest::keyClick(passwordEdit, Qt::Key::Key_Tab);
            QTest::keyClicks(passwordRepeatEdit, "test");

            // save database to temporary file
            TemporaryFile tmpFile;
            VERIFY(tmpFile.open());
            tmpFile.close();
            fileDialog()->setNextFileName(tmpFile.fileName());

            // click Continue on the warning due to weak password
            MessageBox::setNextAnswer(MessageBox::ContinueWithWeakPass);
            wizard->accept();

            tmpFile.remove();
            return true;
        }();
    });
    processEvents();
    return ret;
}

bool TestGuiFdoSecrets::driveUnlockDialog(DatabaseWidget* target)
{
    processEvents();
    auto dbOpenDlg = m_tabWidget->findChild<DatabaseOpenDialog*>();
    VERIFY(dbOpenDlg);
    if (!dbOpenDlg->isVisible()) {
        return false;
    }
    dbOpenDlg->setActiveDatabaseTab(target);

    auto editPassword = dbOpenDlg->findChild<PasswordWidget*>("editPassword")->findChild<QLineEdit*>("passwordEdit");
    VERIFY(editPassword);
    editPassword->setFocus();
    QTest::keyClicks(editPassword, "a");
    QTest::keyClick(editPassword, Qt::Key_Enter);
    processEvents();
    return true;
}

bool TestGuiFdoSecrets::waitForSignal(QSignalSpy& spy, int expectedCount)
{
    processEvents();
    // If already expected count, do not wait and return immediately
    if (spy.count() == expectedCount) {
        return true;
    } else if (spy.count() > expectedCount) {
        return false;
    }
    spy.wait();
    COMPARE(spy.count(), expectedCount);
    return true;
}

#undef VERIFY
#define VERIFY QVERIFY
#undef COMPARE
#define COMPARE QCOMPARE
