From cca4b2a895e924ffce3e490bba750182a66c99d5 Mon Sep 17 00:00:00 2001 From: Jacob Sachs Date: Mon, 14 Oct 2019 08:37:26 -0400 Subject: [PATCH] CLI: Add Import XML command (#3572) The CLI now contains an "import" command that creates a new database from the specified XML export. The new database is in kdbx 4 format, and does not currently accept a keyfile in database creation. This change is required to create new databases from XML backups. Fixes #2458 --- src/browser/BrowserService.cpp | 3 +- src/cli/CMakeLists.txt | 1 + src/cli/Command.cpp | 2 + src/cli/Create.cpp | 24 +- src/cli/Create.h | 2 - src/cli/Import.cpp | 100 + src/cli/Import.h | 30 + src/cli/Utils.cpp | 24 +- src/cli/Utils.h | 1 + src/cli/keepassxc-cli.1 | 3 + src/core/Database.cpp | 19 + src/core/Database.h | 1 + tests/TestCli.cpp | 3818 ++++++++++++++++---------------- tests/TestCli.h | 3 + 14 files changed, 2127 insertions(+), 1904 deletions(-) create mode 100644 src/cli/Import.cpp create mode 100644 src/cli/Import.h diff --git a/src/browser/BrowserService.cpp b/src/browser/BrowserService.cpp index ce8b3ab78e..7b7d7a3afa 100644 --- a/src/browser/BrowserService.cpp +++ b/src/browser/BrowserService.cpp @@ -616,8 +616,7 @@ QList BrowserService::searchEntries(const QString& url, const StringPair // Check if database is connected with KeePassXC-Browser auto databaseConnected = [&](const QSharedPointer& db) { for (const StringPair& keyPair : keyList) { - QString key = - db->metadata()->customData()->value(QLatin1String(ASSOCIATE_KEY_PREFIX) + keyPair.first); + QString key = db->metadata()->customData()->value(QLatin1String(ASSOCIATE_KEY_PREFIX) + keyPair.first); if (!key.isEmpty() && keyPair.second == key) { return true; } diff --git a/src/cli/CMakeLists.txt b/src/cli/CMakeLists.txt index cb165f461e..1fdd85aa7e 100644 --- a/src/cli/CMakeLists.txt +++ b/src/cli/CMakeLists.txt @@ -29,6 +29,7 @@ set(cli_SOURCES Export.cpp Generate.cpp Help.cpp + Import.cpp List.cpp Locate.cpp Merge.cpp diff --git a/src/cli/Command.cpp b/src/cli/Command.cpp index c879a09f20..4d3bf82706 100644 --- a/src/cli/Command.cpp +++ b/src/cli/Command.cpp @@ -36,6 +36,7 @@ #include "Export.h" #include "Generate.h" #include "Help.h" +#include "Import.h" #include "List.h" #include "Locate.h" #include "Merge.h" @@ -180,6 +181,7 @@ namespace Commands s_commands.insert(QStringLiteral("quit"), QSharedPointer(new Exit("quit"))); } else { s_commands.insert(QStringLiteral("export"), QSharedPointer(new Export())); + s_commands.insert(QStringLiteral("import"), QSharedPointer(new Import())); } } diff --git a/src/cli/Create.cpp b/src/cli/Create.cpp index 55c3430390..61c5f41356 100644 --- a/src/cli/Create.cpp +++ b/src/cli/Create.cpp @@ -71,7 +71,7 @@ int Create::execute(const QStringList& arguments) auto key = QSharedPointer::create(); - auto password = getPasswordFromStdin(); + auto password = Utils::getPasswordFromStdin(); if (!password.isNull()) { key->addKey(password); } @@ -107,28 +107,6 @@ int Create::execute(const QStringList& arguments) return EXIT_SUCCESS; } -/** - * Read optional password from stdin. - * - * @return Pointer to the PasswordKey or null if passwordkey is skipped - * by user - */ -QSharedPointer Create::getPasswordFromStdin() -{ - QSharedPointer passwordKey; - QTextStream out(Utils::STDOUT, QIODevice::WriteOnly); - - out << QObject::tr("Insert password to encrypt database (Press enter to leave blank): "); - out.flush(); - QString password = Utils::getPassword(); - - if (!password.isEmpty()) { - passwordKey = QSharedPointer(new PasswordKey(password)); - } - - return passwordKey; -} - /** * Load a key file from disk. When the path specified does not exist a * new file will be generated. No folders will be generated so the parent diff --git a/src/cli/Create.h b/src/cli/Create.h index 26e1f22f85..1c5696a6e8 100644 --- a/src/cli/Create.h +++ b/src/cli/Create.h @@ -21,7 +21,6 @@ #include "Command.h" #include "keys/FileKey.h" -#include "keys/PasswordKey.h" class Create : public Command { @@ -30,7 +29,6 @@ class Create : public Command int execute(const QStringList& arguments) override; private: - QSharedPointer getPasswordFromStdin(); bool loadFileKey(const QString& path, QSharedPointer& fileKey); }; diff --git a/src/cli/Import.cpp b/src/cli/Import.cpp new file mode 100644 index 0000000000..d618ae9333 --- /dev/null +++ b/src/cli/Import.cpp @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2019 KeePassXC Team + * + * 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 . + */ + +#include +#include + +#include +#include +#include + +#include "Import.h" + +#include "cli/TextStream.h" +#include "cli/Utils.h" +#include "core/Database.h" +#include "keys/CompositeKey.h" +#include "keys/Key.h" + +/** + * Create a database file from an XML export of another database. + * A password can be specified to encrypt the database. + * If none is specified the function will fail. + * + * If the database is being saved in a non existant directory, the + * function will fail. + * + * @return EXIT_SUCCESS on success, or EXIT_FAILURE on failure + */ +Import::Import() +{ + name = QString("import"); + description = QObject::tr("Import the contents of an XML database."); + positionalArguments.append({QString("xml"), QObject::tr("Path of the XML database export."), QString("")}); + positionalArguments.append({QString("database"), QObject::tr("Path of the new database."), QString("")}); +} + +int Import::execute(const QStringList& arguments) +{ + QSharedPointer parser = getCommandLineParser(arguments); + if (parser.isNull()) { + return EXIT_FAILURE; + } + + TextStream outputTextStream(parser->isSet(Command::QuietOption) ? Utils::DEVNULL : Utils::STDOUT, + QIODevice::WriteOnly); + TextStream errorTextStream(Utils::STDERR, QIODevice::WriteOnly); + + const QStringList args = parser->positionalArguments(); + const QString xmlExportPath = args.at(0); + const QString dbPath = args.at(1); + + if (QFileInfo::exists(dbPath)) { + errorTextStream << QObject::tr("File %1 already exists.").arg(dbPath) << endl; + return EXIT_FAILURE; + } + + auto key = QSharedPointer::create(); + + auto password = Utils::getPasswordFromStdin(); + if (!password.isNull()) { + key->addKey(password); + } + + if (key->isEmpty()) { + errorTextStream << QObject::tr("No key is set. Aborting database creation.") << endl; + return EXIT_FAILURE; + } + + QString errorMessage; + Database db; + db.setKdf(KeePass2::uuidToKdf(KeePass2::KDF_ARGON2)); + db.setKey(key); + + if (!db.import(xmlExportPath, &errorMessage)) { + errorTextStream << QObject::tr("Unable to import XML database export %1").arg(errorMessage) << endl; + return EXIT_FAILURE; + } + + if (!db.save(dbPath, &errorMessage, true, false)) { + errorTextStream << QObject::tr("Failed to save the database: %1.").arg(errorMessage) << endl; + return EXIT_FAILURE; + } + + outputTextStream << QObject::tr("Successfully imported database.") << endl; + return EXIT_SUCCESS; +} diff --git a/src/cli/Import.h b/src/cli/Import.h new file mode 100644 index 0000000000..0f899ac124 --- /dev/null +++ b/src/cli/Import.h @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2019 KeePassXC Team + * + * 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 . + */ + +#ifndef KEEPASSXC_IMPORT_H +#define KEEPASSXC_IMPORT_H + +#include "Command.h" + +class Import : public Command +{ +public: + Import(); + int execute(const QStringList& arguments) override; +}; + +#endif // KEEPASSXC_IMPORT_H diff --git a/src/cli/Utils.cpp b/src/cli/Utils.cpp index 0ed05d79d9..a45967917e 100644 --- a/src/cli/Utils.cpp +++ b/src/cli/Utils.cpp @@ -127,7 +127,7 @@ namespace Utils } if (isPasswordProtected) { - out << QObject::tr("Insert password to unlock %1: ").arg(databaseFilename) << flush; + out << QObject::tr("Enter password to unlock %1: ").arg(databaseFilename) << flush; QString line = Utils::getPassword(outputDescriptor); auto passwordKey = QSharedPointer::create(); passwordKey->setPassword(line); @@ -217,6 +217,28 @@ namespace Utils return line; } + /** + * Read optional password from stdin. + * + * @return Pointer to the PasswordKey or null if passwordkey is skipped + * by user + */ + QSharedPointer getPasswordFromStdin() + { + QSharedPointer passwordKey; + QTextStream out(Utils::STDOUT, QIODevice::WriteOnly); + + out << QObject::tr("Enter password to encrypt database (optional): "); + out.flush(); + QString password = Utils::getPassword(); + + if (!password.isEmpty()) { + passwordKey = QSharedPointer(new PasswordKey(password)); + } + + return passwordKey; + } + /** * A valid and running event loop is needed to use the global QClipboard, * so we need to use this from the CLI. diff --git a/src/cli/Utils.h b/src/cli/Utils.h index 742bb543d9..b7fa63acf2 100644 --- a/src/cli/Utils.h +++ b/src/cli/Utils.h @@ -40,6 +40,7 @@ namespace Utils void setStdinEcho(bool enable); QString getPassword(FILE* outputDescriptor = STDOUT); + QSharedPointer getPasswordFromStdin(); int clipText(const QString& text); QSharedPointer unlockDatabase(const QString& databaseFilename, const bool isPasswordProtected = true, diff --git a/src/cli/keepassxc-cli.1 b/src/cli/keepassxc-cli.1 index 141f980b83..c22e7e73ec 100644 --- a/src/cli/keepassxc-cli.1 +++ b/src/cli/keepassxc-cli.1 @@ -51,6 +51,9 @@ Generates a random password. .IP "help [command]" Displays a list of available commands, or detailed information about the specified command. +.IP "import [options] " +Imports the contents of an XML database to the target database. + .IP "locate [options] " Locates all the entries that match a specific search term in a database. diff --git a/src/core/Database.cpp b/src/core/Database.cpp index 98510649af..2a0499f574 100644 --- a/src/core/Database.cpp +++ b/src/core/Database.cpp @@ -22,6 +22,7 @@ #include "core/Group.h" #include "core/Merger.h" #include "core/Metadata.h" +#include "format/KdbxXmlReader.h" #include "format/KeePass2Reader.h" #include "format/KeePass2Writer.h" #include "keys/FileKey.h" @@ -332,6 +333,24 @@ bool Database::extract(QByteArray& xmlOutput, QString* error) return true; } +bool Database::import(const QString& xmlExportPath, QString* error) +{ + KdbxXmlReader reader(KeePass2::FILE_VERSION_4); + QFile file(xmlExportPath); + file.open(QIODevice::ReadOnly); + + reader.readDatabase(&file, this); + + if (reader.hasError()) { + if (error) { + *error = reader.errorString(); + } + return false; + } + + return true; +} + /** * Remove the old backup and replace it with a new one * backups are named .old. diff --git a/src/core/Database.h b/src/core/Database.h index 86d90c6a7e..5e1e9f2b10 100644 --- a/src/core/Database.h +++ b/src/core/Database.h @@ -73,6 +73,7 @@ class Database : public QObject bool save(QString* error = nullptr, bool atomic = true, bool backup = false); bool save(const QString& filePath, QString* error = nullptr, bool atomic = true, bool backup = false); bool extract(QByteArray&, QString* error = nullptr); + bool import(const QString& xmlExportPath, QString* error = nullptr); bool isInitialized() const; void setInitialized(bool initialized); diff --git a/tests/TestCli.cpp b/tests/TestCli.cpp index 4e27170eab..d9fbc25b4c 100644 --- a/tests/TestCli.cpp +++ b/tests/TestCli.cpp @@ -1,1876 +1,1942 @@ -/* - * Copyright (C) 2019 KeePassXC Team - * - * 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 . - */ - -#include "TestCli.h" - -#include "config-keepassx-tests.h" -#include "core/Bootstrap.h" -#include "core/Config.h" -#include "core/Global.h" -#include "core/PasswordGenerator.h" -#include "core/Tools.h" -#include "crypto/Crypto.h" -#include "format/Kdbx3Reader.h" -#include "format/Kdbx3Writer.h" -#include "format/Kdbx4Reader.h" -#include "format/Kdbx4Writer.h" -#include "format/KdbxXmlReader.h" -#include "format/KeePass2.h" - -#include "cli/Add.h" -#include "cli/AddGroup.h" -#include "cli/Analyze.h" -#include "cli/Clip.h" -#include "cli/Command.h" -#include "cli/Create.h" -#include "cli/Diceware.h" -#include "cli/Edit.h" -#include "cli/Estimate.h" -#include "cli/Export.h" -#include "cli/Generate.h" -#include "cli/Help.h" -#include "cli/List.h" -#include "cli/Locate.h" -#include "cli/Merge.h" -#include "cli/Move.h" -#include "cli/Open.h" -#include "cli/Remove.h" -#include "cli/RemoveGroup.h" -#include "cli/Show.h" -#include "cli/Utils.h" - -#include -#include -#include -#include -#include -#include - -#include - -QTEST_MAIN(TestCli) - -QSharedPointer globalCurrentDatabase; - -void TestCli::initTestCase() -{ - QVERIFY(Crypto::init()); - - Config::createTempFileInstance(); - Bootstrap::bootstrapApplication(); - - // Load the NewDatabase.kdbx file into temporary storage - QFile sourceDbFile(QString(KEEPASSX_TEST_DATA_DIR).append("/NewDatabase.kdbx")); - QVERIFY(sourceDbFile.open(QIODevice::ReadOnly)); - QVERIFY(Tools::readAllFromDevice(&sourceDbFile, m_dbData)); - sourceDbFile.close(); - - // Load the NewDatabase2.kdbx file into temporary storage - QFile sourceDbFile2(QString(KEEPASSX_TEST_DATA_DIR).append("/NewDatabase2.kdbx")); - QVERIFY(sourceDbFile2.open(QIODevice::ReadOnly)); - QVERIFY(Tools::readAllFromDevice(&sourceDbFile2, m_dbData2)); - sourceDbFile2.close(); - - // Load the KeyFileProtected.kdbx file into temporary storage - QFile sourceDbFile3(QString(KEEPASSX_TEST_DATA_DIR).append("/KeyFileProtected.kdbx")); - QVERIFY(sourceDbFile3.open(QIODevice::ReadOnly)); - QVERIFY(Tools::readAllFromDevice(&sourceDbFile3, m_keyFileProtectedDbData)); - sourceDbFile3.close(); - - // Load the KeyFileProtectedNoPassword.kdbx file into temporary storage - QFile sourceDbFile4(QString(KEEPASSX_TEST_DATA_DIR).append("/KeyFileProtectedNoPassword.kdbx")); - QVERIFY(sourceDbFile4.open(QIODevice::ReadOnly)); - QVERIFY(Tools::readAllFromDevice(&sourceDbFile4, m_keyFileProtectedNoPasswordDbData)); - sourceDbFile4.close(); - - QFile sourceDbFileYubiKeyProtected(QString(KEEPASSX_TEST_DATA_DIR).append("/YubiKeyProtectedPasswords.kdbx")); - QVERIFY(sourceDbFileYubiKeyProtected.open(QIODevice::ReadOnly)); - QVERIFY(Tools::readAllFromDevice(&sourceDbFileYubiKeyProtected, m_yubiKeyProtectedDbData)); - sourceDbFileYubiKeyProtected.close(); -} - -void TestCli::init() -{ - m_dbFile.reset(new TemporaryFile()); - m_dbFile->open(); - m_dbFile->write(m_dbData); - m_dbFile->close(); - - m_dbFile2.reset(new TemporaryFile()); - m_dbFile2->open(); - m_dbFile2->write(m_dbData2); - m_dbFile2->close(); - - m_keyFileProtectedDbFile.reset(new TemporaryFile()); - m_keyFileProtectedDbFile->open(); - m_keyFileProtectedDbFile->write(m_keyFileProtectedDbData); - m_keyFileProtectedDbFile->close(); - - m_keyFileProtectedNoPasswordDbFile.reset(new TemporaryFile()); - m_keyFileProtectedNoPasswordDbFile->open(); - m_keyFileProtectedNoPasswordDbFile->write(m_keyFileProtectedNoPasswordDbData); - m_keyFileProtectedNoPasswordDbFile->close(); - - m_yubiKeyProtectedDbFile.reset(new TemporaryFile()); - m_yubiKeyProtectedDbFile->open(); - m_yubiKeyProtectedDbFile->write(m_yubiKeyProtectedDbData); - m_yubiKeyProtectedDbFile->close(); - - m_stdinFile.reset(new TemporaryFile()); - m_stdinFile->open(); - m_stdinHandle = fdopen(m_stdinFile->handle(), "r+"); - Utils::STDIN = m_stdinHandle; - - m_stdoutFile.reset(new TemporaryFile()); - m_stdoutFile->open(); - m_stdoutHandle = fdopen(m_stdoutFile->handle(), "r+"); - Utils::STDOUT = m_stdoutHandle; - - m_stderrFile.reset(new TemporaryFile()); - m_stderrFile->open(); - m_stderrHandle = fdopen(m_stderrFile->handle(), "r+"); - Utils::STDERR = m_stderrHandle; -} - -void TestCli::cleanup() -{ - m_dbFile.reset(); - - m_dbFile2.reset(); - - m_stdinFile.reset(); - m_stdinHandle = stdin; - Utils::STDIN = stdin; - - m_stdoutFile.reset(); - Utils::STDOUT = stdout; - m_stdoutHandle = stdout; - - m_stderrFile.reset(); - m_stderrHandle = stderr; - Utils::STDERR = stderr; -} - -void TestCli::cleanupTestCase() -{ -} - -QSharedPointer TestCli::readTestDatabase() const -{ - Utils::Test::setNextPassword("a"); - auto db = QSharedPointer(Utils::unlockDatabase(m_dbFile->fileName(), true, "", "", m_stdoutHandle)); - m_stdoutFile->seek(ftell(m_stdoutHandle)); // re-synchronize handles - return db; -} - -void TestCli::testBatchCommands() -{ - Commands::setupCommands(false); - QVERIFY(Commands::getCommand("add")); - QVERIFY(Commands::getCommand("analyze")); - QVERIFY(Commands::getCommand("clip")); - QVERIFY(Commands::getCommand("close")); - QVERIFY(Commands::getCommand("create")); - QVERIFY(Commands::getCommand("diceware")); - QVERIFY(Commands::getCommand("edit")); - QVERIFY(Commands::getCommand("estimate")); - QVERIFY(Commands::getCommand("export")); - QVERIFY(Commands::getCommand("generate")); - QVERIFY(Commands::getCommand("help")); - QVERIFY(Commands::getCommand("locate")); - QVERIFY(Commands::getCommand("ls")); - QVERIFY(Commands::getCommand("merge")); - QVERIFY(Commands::getCommand("mkdir")); - QVERIFY(Commands::getCommand("mv")); - QVERIFY(Commands::getCommand("open")); - QVERIFY(Commands::getCommand("rm")); - QVERIFY(Commands::getCommand("rmdir")); - QVERIFY(Commands::getCommand("show")); - QVERIFY(!Commands::getCommand("doesnotexist")); - QCOMPARE(Commands::getCommands().size(), 20); -} - -void TestCli::testInteractiveCommands() -{ - Commands::setupCommands(true); - QVERIFY(Commands::getCommand("add")); - QVERIFY(Commands::getCommand("analyze")); - QVERIFY(Commands::getCommand("clip")); - QVERIFY(Commands::getCommand("close")); - QVERIFY(Commands::getCommand("create")); - QVERIFY(Commands::getCommand("diceware")); - QVERIFY(Commands::getCommand("edit")); - QVERIFY(Commands::getCommand("estimate")); - QVERIFY(Commands::getCommand("exit")); - QVERIFY(Commands::getCommand("generate")); - QVERIFY(Commands::getCommand("help")); - QVERIFY(Commands::getCommand("locate")); - QVERIFY(Commands::getCommand("ls")); - QVERIFY(Commands::getCommand("merge")); - QVERIFY(Commands::getCommand("mkdir")); - QVERIFY(Commands::getCommand("mv")); - QVERIFY(Commands::getCommand("open")); - QVERIFY(Commands::getCommand("quit")); - QVERIFY(Commands::getCommand("rm")); - QVERIFY(Commands::getCommand("rmdir")); - QVERIFY(Commands::getCommand("show")); - QVERIFY(!Commands::getCommand("doesnotexist")); - QCOMPARE(Commands::getCommands().size(), 21); -} - -void TestCli::testAdd() -{ - Add addCmd; - QVERIFY(!addCmd.name.isEmpty()); - QVERIFY(addCmd.getDescriptionLine().contains(addCmd.name)); - - Utils::Test::setNextPassword("a"); - addCmd.execute({"add", - "-u", - "newuser", - "--url", - "https://example.com/", - "-g", - "-L", - "20", - m_dbFile->fileName(), - "/newuser-entry"}); - m_stderrFile->reset(); - m_stdoutFile->reset(); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stderrFile->readAll(), QByteArray("")); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully added entry newuser-entry.\n")); - - auto db = readTestDatabase(); - auto* entry = db->rootGroup()->findEntryByPath("/newuser-entry"); - QVERIFY(entry); - QCOMPARE(entry->username(), QString("newuser")); - QCOMPARE(entry->url(), QString("https://example.com/")); - QCOMPARE(entry->password().size(), 20); - - // Quiet option - qint64 pos = m_stdoutFile->pos(); - qint64 posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - addCmd.execute({"add", "-q", "-u", "newuser", "-g", "-L", "20", m_dbFile->fileName(), "/newentry-quiet"}); - m_stdoutFile->seek(pos); - m_stderrFile->seek(posErr); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - QCOMPARE(m_stderrFile->readAll(), QByteArray("")); - db = readTestDatabase(); - entry = db->rootGroup()->findEntryByPath("/newentry-quiet"); - QVERIFY(entry); - QCOMPARE(entry->password().size(), 20); - - Utils::Test::setNextPassword("a"); - Utils::Test::setNextPassword("newpassword"); - addCmd.execute( - {"add", "-u", "newuser2", "--url", "https://example.net/", "-p", m_dbFile->fileName(), "/newuser-entry2"}); - - db = readTestDatabase(); - entry = db->rootGroup()->findEntryByPath("/newuser-entry2"); - QVERIFY(entry); - QCOMPARE(entry->username(), QString("newuser2")); - QCOMPARE(entry->url(), QString("https://example.net/")); - QCOMPARE(entry->password(), QString("newpassword")); - - // Password generation options - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - addCmd.execute({"add", "-u", "newuser3", "-g", "-L", "34", m_dbFile->fileName(), "/newuser-entry3"}); - m_stdoutFile->seek(pos); - m_stderrFile->seek(posErr); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully added entry newuser-entry3.\n")); - QCOMPARE(m_stderrFile->readAll(), QByteArray("")); - - db = readTestDatabase(); - entry = db->rootGroup()->findEntryByPath("/newuser-entry3"); - QVERIFY(entry); - QCOMPARE(entry->username(), QString("newuser3")); - QCOMPARE(entry->password().size(), 34); - QRegularExpression defaultPasswordClassesRegex("^[a-zA-Z0-9]+$"); - QVERIFY(defaultPasswordClassesRegex.match(entry->password()).hasMatch()); - - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - addCmd.execute({"add", - "-u", - "newuser4", - "-g", - "-L", - "20", - "--every-group", - "-s", - "-n", - "-U", - "-l", - m_dbFile->fileName(), - "/newuser-entry4"}); - m_stdoutFile->seek(pos); - m_stderrFile->seek(posErr); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully added entry newuser-entry4.\n")); - QCOMPARE(m_stderrFile->readAll(), QByteArray("")); - - db = readTestDatabase(); - entry = db->rootGroup()->findEntryByPath("/newuser-entry4"); - QVERIFY(entry); - QCOMPARE(entry->username(), QString("newuser4")); - QCOMPARE(entry->password().size(), 20); - QVERIFY(!defaultPasswordClassesRegex.match(entry->password()).hasMatch()); -} - -void TestCli::testAddGroup() -{ - AddGroup addGroupCmd; - QVERIFY(!addGroupCmd.name.isEmpty()); - QVERIFY(addGroupCmd.getDescriptionLine().contains(addGroupCmd.name)); - - Utils::Test::setNextPassword("a"); - addGroupCmd.execute({"mkdir", m_dbFile->fileName(), "/new_group"}); - m_stderrFile->reset(); - m_stdoutFile->reset(); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stderrFile->readAll(), QByteArray("")); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully added group new_group.\n")); - - auto db = readTestDatabase(); - auto* group = db->rootGroup()->findGroupByPath("new_group"); - QVERIFY(group); - QCOMPARE(group->name(), QString("new_group")); - - // Trying to add the same group should fail. - qint64 pos = m_stdoutFile->pos(); - qint64 posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - addGroupCmd.execute({"mkdir", m_dbFile->fileName(), "/new_group"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->seek(posErr); - QCOMPARE(m_stderrFile->readAll(), QByteArray("Group /new_group already exists!\n")); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - - // Should be able to add groups down the tree. - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - addGroupCmd.execute({"mkdir", m_dbFile->fileName(), "/new_group/newer_group"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->seek(posErr); - QCOMPARE(m_stderrFile->readAll(), QByteArray("")); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully added group newer_group.\n")); - - db = readTestDatabase(); - group = db->rootGroup()->findGroupByPath("new_group/newer_group"); - QVERIFY(group); - QCOMPARE(group->name(), QString("newer_group")); - - // Should fail if the path is invalid. - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - addGroupCmd.execute({"mkdir", m_dbFile->fileName(), "/invalid_group/newer_group"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->seek(posErr); - QCOMPARE(m_stderrFile->readAll(), QByteArray("Group /invalid_group not found.\n")); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - - // Should fail to add the root group. - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - addGroupCmd.execute({"mkdir", m_dbFile->fileName(), "/"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->seek(posErr); - QCOMPARE(m_stderrFile->readAll(), QByteArray("Group / already exists!\n")); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); -} - -bool isTOTP(const QString& value) -{ - QString val = value.trimmed(); - if (val.length() < 5 || val.length() > 6) { - return false; - } - for (int i = 0; i < val.length(); ++i) { - if (!value[i].isDigit()) { - return false; - } - } - return true; -} - -void TestCli::testAnalyze() -{ - Analyze analyzeCmd; - QVERIFY(!analyzeCmd.name.isEmpty()); - QVERIFY(analyzeCmd.getDescriptionLine().contains(analyzeCmd.name)); - - const QString hibpPath = QString(KEEPASSX_TEST_DATA_DIR).append("/hibp.txt"); - - Utils::Test::setNextPassword("a"); - analyzeCmd.execute({"analyze", "--hibp", hibpPath, m_dbFile->fileName()}); - m_stdoutFile->reset(); - m_stdoutFile->readLine(); // skip password prompt - auto output = m_stdoutFile->readAll(); - QVERIFY(output.contains("Sample Entry") && output.contains("123")); -} - -void TestCli::testClip() -{ - QClipboard* clipboard = QGuiApplication::clipboard(); - clipboard->clear(); - - Clip clipCmd; - QVERIFY(!clipCmd.name.isEmpty()); - QVERIFY(clipCmd.getDescriptionLine().contains(clipCmd.name)); - - // Password - Utils::Test::setNextPassword("a"); - clipCmd.execute({"clip", m_dbFile->fileName(), "/Sample Entry"}); - - m_stderrFile->reset(); - m_stdoutFile->reset(); - QString errorOutput(m_stderrFile->readAll()); - - if (errorOutput.contains("Unable to start program") - || errorOutput.contains("No program defined for clipboard manipulation")) { - QSKIP("Clip test skipped due to missing clipboard tool"); - } - - QCOMPARE(clipboard->text(), QString("Password")); - m_stdoutFile->readLine(); // skip prompt line - QCOMPARE(m_stdoutFile->readLine(), QByteArray("Entry's password copied to the clipboard!\n")); - - // Quiet option - qint64 pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - clipCmd.execute({"clip", m_dbFile->fileName(), "/Sample Entry", "-q"}); - m_stdoutFile->seek(pos); - // Output should be empty when quiet option is set. - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - QCOMPARE(clipboard->text(), QString("Password")); - - // TOTP - Utils::Test::setNextPassword("a"); - clipCmd.execute({"clip", m_dbFile->fileName(), "/Sample Entry", "--totp"}); - - QVERIFY(isTOTP(clipboard->text())); - - // Password with timeout - Utils::Test::setNextPassword("a"); - // clang-format off - QFuture future = QtConcurrent::run(&clipCmd, - static_cast(&DatabaseCommand::execute), - QStringList{"clip", m_dbFile->fileName(), "/Sample Entry", "1"}); - // clang-format on - - QTRY_COMPARE_WITH_TIMEOUT(clipboard->text(), QString("Password"), 500); - QTRY_COMPARE_WITH_TIMEOUT(clipboard->text(), QString(""), 1500); - - future.waitForFinished(); - - // TOTP with timeout - Utils::Test::setNextPassword("a"); - future = QtConcurrent::run(&clipCmd, - static_cast(&DatabaseCommand::execute), - QStringList{"clip", m_dbFile->fileName(), "/Sample Entry", "1", "-t"}); - - QTRY_VERIFY_WITH_TIMEOUT(isTOTP(clipboard->text()), 500); - QTRY_COMPARE_WITH_TIMEOUT(clipboard->text(), QString(""), 1500); - - future.waitForFinished(); - - qint64 posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - clipCmd.execute({"clip", m_dbFile->fileName(), "--totp", "/Sample Entry", "0"}); - m_stderrFile->seek(posErr); - QCOMPARE(m_stderrFile->readAll(), QByteArray("Invalid timeout value 0.\n")); - - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - clipCmd.execute({"clip", m_dbFile->fileName(), "--totp", "/Sample Entry", "bleuh"}); - m_stderrFile->seek(posErr); - QCOMPARE(m_stderrFile->readAll(), QByteArray("Invalid timeout value bleuh.\n")); - - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - clipCmd.execute({"clip", m_dbFile2->fileName(), "--totp", "/Sample Entry"}); - m_stderrFile->seek(posErr); - QCOMPARE(m_stderrFile->readAll(), QByteArray("Entry with path /Sample Entry has no TOTP set up.\n")); -} - -void TestCli::testCreate() -{ - Create createCmd; - QVERIFY(!createCmd.name.isEmpty()); - QVERIFY(createCmd.getDescriptionLine().contains(createCmd.name)); - - QScopedPointer testDir(new QTemporaryDir()); - - QString databaseFilename = testDir->path() + "testCreate1.kdbx"; - // Password - Utils::Test::setNextPassword("a"); - createCmd.execute({"create", databaseFilename}); - - m_stderrFile->reset(); - m_stdoutFile->reset(); - - QCOMPARE(m_stdoutFile->readLine(), - QByteArray("Insert password to encrypt database (Press enter to leave blank): \n")); - QCOMPARE(m_stdoutFile->readLine(), QByteArray("Successfully created new database.\n")); - - Utils::Test::setNextPassword("a"); - auto db = QSharedPointer(Utils::unlockDatabase(databaseFilename, true, "", "", Utils::DEVNULL)); - QVERIFY(db); - - // Should refuse to create the database if it already exists. - qint64 pos = m_stdoutFile->pos(); - qint64 errPos = m_stderrFile->pos(); - createCmd.execute({"create", databaseFilename}); - m_stdoutFile->seek(pos); - m_stderrFile->seek(errPos); - // Output should be empty when there is an error. - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - QString errorMessage = QString("File " + databaseFilename + " already exists.\n"); - QCOMPARE(m_stderrFile->readAll(), errorMessage.toUtf8()); - - // Testing with keyfile creation - QString databaseFilename2 = testDir->path() + "testCreate2.kdbx"; - QString keyfilePath = testDir->path() + "keyfile.txt"; - pos = m_stdoutFile->pos(); - errPos = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - createCmd.execute({"create", databaseFilename2, "-k", keyfilePath}); - m_stdoutFile->seek(pos); - m_stderrFile->seek(errPos); - - QCOMPARE(m_stdoutFile->readLine(), - QByteArray("Insert password to encrypt database (Press enter to leave blank): \n")); - QCOMPARE(m_stdoutFile->readLine(), QByteArray("Successfully created new database.\n")); - - Utils::Test::setNextPassword("a"); - auto db2 = - QSharedPointer(Utils::unlockDatabase(databaseFilename2, true, keyfilePath, "", Utils::DEVNULL)); - QVERIFY(db2); - - // Testing with existing keyfile - QString databaseFilename3 = testDir->path() + "testCreate3.kdbx"; - pos = m_stdoutFile->pos(); - errPos = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - createCmd.execute({"create", databaseFilename3, "-k", keyfilePath}); - m_stdoutFile->seek(pos); - m_stderrFile->seek(errPos); - - QCOMPARE(m_stdoutFile->readLine(), - QByteArray("Insert password to encrypt database (Press enter to leave blank): \n")); - QCOMPARE(m_stdoutFile->readLine(), QByteArray("Successfully created new database.\n")); - - Utils::Test::setNextPassword("a"); - auto db3 = - QSharedPointer(Utils::unlockDatabase(databaseFilename3, true, keyfilePath, "", Utils::DEVNULL)); - QVERIFY(db3); -} - -void TestCli::testDiceware() -{ - Diceware dicewareCmd; - QVERIFY(!dicewareCmd.name.isEmpty()); - QVERIFY(dicewareCmd.getDescriptionLine().contains(dicewareCmd.name)); - - dicewareCmd.execute({"diceware"}); - m_stdoutFile->reset(); - QString passphrase(m_stdoutFile->readLine()); - QVERIFY(!passphrase.isEmpty()); - - dicewareCmd.execute({"diceware", "-W", "2"}); - m_stdoutFile->seek(passphrase.toLatin1().size()); - passphrase = m_stdoutFile->readLine(); - QCOMPARE(passphrase.split(" ").size(), 2); - - auto pos = m_stdoutFile->pos(); - dicewareCmd.execute({"diceware", "-W", "10"}); - m_stdoutFile->seek(pos); - passphrase = m_stdoutFile->readLine(); - QCOMPARE(passphrase.split(" ").size(), 10); - - // Testing with invalid word count - auto posErr = m_stderrFile->pos(); - dicewareCmd.execute({"diceware", "-W", "-10"}); - m_stderrFile->seek(posErr); - QCOMPARE(m_stderrFile->readLine(), QByteArray("Invalid word count -10\n")); - - // Testing with invalid word count format - posErr = m_stderrFile->pos(); - dicewareCmd.execute({"diceware", "-W", "bleuh"}); - m_stderrFile->seek(posErr); - QCOMPARE(m_stderrFile->readLine(), QByteArray("Invalid word count bleuh\n")); - - TemporaryFile wordFile; - wordFile.open(); - for (int i = 0; i < 4500; ++i) { - wordFile.write(QString("word" + QString::number(i) + "\n").toLatin1()); - } - wordFile.close(); - - pos = m_stdoutFile->pos(); - dicewareCmd.execute({"diceware", "-W", "11", "-w", wordFile.fileName()}); - m_stdoutFile->seek(pos); - passphrase = m_stdoutFile->readLine(); - const auto words = passphrase.split(" "); - QCOMPARE(words.size(), 11); - QRegularExpression regex("^word\\d+$"); - for (const auto& word : words) { - QVERIFY2(regex.match(word).hasMatch(), qPrintable("Word " + word + " was not on the word list")); - } - - TemporaryFile smallWordFile; - smallWordFile.open(); - for (int i = 0; i < 50; ++i) { - smallWordFile.write(QString("word" + QString::number(i) + "\n").toLatin1()); - } - smallWordFile.close(); - - posErr = m_stderrFile->pos(); - dicewareCmd.execute({"diceware", "-W", "11", "-w", smallWordFile.fileName()}); - m_stderrFile->seek(posErr); - QCOMPARE(m_stderrFile->readLine(), QByteArray("The word list is too small (< 1000 items)\n")); -} - -void TestCli::testEdit() -{ - Edit editCmd; - QVERIFY(!editCmd.name.isEmpty()); - QVERIFY(editCmd.getDescriptionLine().contains(editCmd.name)); - - Utils::Test::setNextPassword("a"); - // clang-format off - editCmd.execute({"edit", "-u", "newuser", "--url", "https://otherurl.example.com/", "-t", "newtitle", m_dbFile->fileName(), "/Sample Entry"}); - // clang-format on - m_stdoutFile->reset(); - m_stdoutFile->readLine(); // skip prompt line - QCOMPARE(m_stdoutFile->readLine(), QByteArray("Successfully edited entry newtitle.\n")); - - auto db = readTestDatabase(); - auto* entry = db->rootGroup()->findEntryByPath("/newtitle"); - QVERIFY(entry); - QCOMPARE(entry->username(), QString("newuser")); - QCOMPARE(entry->url(), QString("https://otherurl.example.com/")); - QCOMPARE(entry->password(), QString("Password")); - - // Quiet option - qint64 pos = m_stdoutFile->pos(); - qint64 posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - editCmd.execute({"edit", m_dbFile->fileName(), "-q", "-t", "newertitle", "/newtitle"}); - m_stdoutFile->seek(pos); - m_stderrFile->seek(posErr); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - QCOMPARE(m_stderrFile->readAll(), QByteArray("")); - - Utils::Test::setNextPassword("a"); - editCmd.execute({"edit", "-g", m_dbFile->fileName(), "/newertitle"}); - db = readTestDatabase(); - entry = db->rootGroup()->findEntryByPath("/newertitle"); - QVERIFY(entry); - QCOMPARE(entry->username(), QString("newuser")); - QCOMPARE(entry->url(), QString("https://otherurl.example.com/")); - QVERIFY(!entry->password().isEmpty()); - QVERIFY(entry->password() != QString("Password")); - - Utils::Test::setNextPassword("a"); - editCmd.execute({"edit", "-g", "-L", "34", "-t", "evennewertitle", m_dbFile->fileName(), "/newertitle"}); - db = readTestDatabase(); - entry = db->rootGroup()->findEntryByPath("/evennewertitle"); - QVERIFY(entry); - QCOMPARE(entry->username(), QString("newuser")); - QCOMPARE(entry->url(), QString("https://otherurl.example.com/")); - QVERIFY(entry->password() != QString("Password")); - QCOMPARE(entry->password().size(), 34); - QRegularExpression defaultPasswordClassesRegex("^[a-zA-Z0-9]+$"); - QVERIFY(defaultPasswordClassesRegex.match(entry->password()).hasMatch()); - - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - editCmd.execute({"edit", - "-g", - "-L", - "20", - "--every-group", - "-s", - "-n", - "--upper", - "-l", - m_dbFile->fileName(), - "/evennewertitle"}); - m_stdoutFile->seek(pos); - m_stderrFile->seek(posErr); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stderrFile->readAll(), QByteArray("")); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully edited entry evennewertitle.\n")); - - db = readTestDatabase(); - entry = db->rootGroup()->findEntryByPath("/evennewertitle"); - QVERIFY(entry); - QCOMPARE(entry->password().size(), 20); - QVERIFY(!defaultPasswordClassesRegex.match(entry->password()).hasMatch()); - - Utils::Test::setNextPassword("a"); - Utils::Test::setNextPassword("newpassword"); - editCmd.execute({"edit", "-p", m_dbFile->fileName(), "/evennewertitle"}); - db = readTestDatabase(); - entry = db->rootGroup()->findEntryByPath("/evennewertitle"); - QVERIFY(entry); - QCOMPARE(entry->password(), QString("newpassword")); -} - -void TestCli::testEstimate_data() -{ - QTest::addColumn("input"); - QTest::addColumn("length"); - QTest::addColumn("entropy"); - QTest::addColumn("log10"); - QTest::addColumn("searchStrings"); - - QTest::newRow("Dictionary") << "password" - << "8" - << "1.0" - << "0.3" << QStringList{"Type: Dictionary", "\tpassword"}; - - QTest::newRow("Spatial") << "zxcv" - << "4" - << "10.3" - << "3.1" << QStringList{"Type: Spatial", "\tzxcv"}; - - QTest::newRow("Spatial(Rep)") << "sdfgsdfg" - << "8" - << "11.3" - << "3.4" << QStringList{"Type: Spatial(Rep)", "\tsdfgsdfg"}; - - QTest::newRow("Dictionary / Sequence") - << "password123" - << "11" - << "4.5" - << "1.3" << QStringList{"Type: Dictionary", "Type: Sequence", "\tpassword", "\t123"}; - - QTest::newRow("Dict+Leet") << "p455w0rd" - << "8" - << "2.5" - << "0.7" << QStringList{"Type: Dict+Leet", "\tp455w0rd"}; - - QTest::newRow("Dictionary(Rep)") << "hellohello" - << "10" - << "7.3" - << "2.2" << QStringList{"Type: Dictionary(Rep)", "\thellohello"}; - - QTest::newRow("Sequence(Rep) / Dictionary") - << "456456foobar" - << "12" - << "16.7" - << "5.0" << QStringList{"Type: Sequence(Rep)", "Type: Dictionary", "\t456456", "\tfoobar"}; - - QTest::newRow("Bruteforce(Rep) / Bruteforce") - << "xzxzy" - << "5" - << "16.1" - << "4.8" << QStringList{"Type: Bruteforce(Rep)", "Type: Bruteforce", "\txzxz", "\ty"}; - - QTest::newRow("Dictionary / Date(Rep)") - << "pass20182018" - << "12" - << "15.1" - << "4.56" << QStringList{"Type: Dictionary", "Type: Date(Rep)", "\tpass", "\t20182018"}; - - QTest::newRow("Dictionary / Date / Bruteforce") - << "mypass2018-2" - << "12" - << "32.9" - << "9.9" << QStringList{"Type: Dictionary", "Type: Date", "Type: Bruteforce", "\tmypass", "\t2018", "\t-2"}; - - QTest::newRow("Strong Password") << "E*!%.Qw{t.X,&bafw)\"Q!ah$%;U/" - << "28" - << "165.7" - << "49.8" << QStringList{"Type: Bruteforce", "\tE*"}; - - // TODO: detect passphrases and adjust entropy calculation accordingly (issue #2347) - QTest::newRow("Strong Passphrase") - << "squint wooing resupply dangle isolation axis headsman" - << "53" - << "151.2" - << "45.5" - << QStringList{ - "Type: Dictionary", "Type: Bruteforce", "Multi-word extra bits 22.0", "\tsquint", "\t ", "\twooing"}; -} - -void TestCli::testEstimate() -{ - QFETCH(QString, input); - QFETCH(QString, length); - QFETCH(QString, entropy); - QFETCH(QString, log10); - QFETCH(QStringList, searchStrings); - - Estimate estimateCmd; - QVERIFY(!estimateCmd.name.isEmpty()); - QVERIFY(estimateCmd.getDescriptionLine().contains(estimateCmd.name)); - - QTextStream in(m_stdinFile.data()); - QTextStream out(m_stdoutFile.data()); - - in << input << endl; - in.seek(0); - estimateCmd.execute({"estimate", "-a"}); - out.seek(0); - auto result = out.readAll(); - QVERIFY(result.contains("Length " + length)); - QVERIFY(result.contains("Entropy " + entropy)); - QVERIFY(result.contains("Log10 " + log10)); - for (const auto& string : asConst(searchStrings)) { - QVERIFY2(result.contains(string), qPrintable("String " + string + " missing")); - } -} - -void TestCli::testExport() -{ - Export exportCmd; - QVERIFY(!exportCmd.name.isEmpty()); - QVERIFY(exportCmd.getDescriptionLine().contains(exportCmd.name)); - - Utils::Test::setNextPassword("a"); - exportCmd.execute({"export", m_dbFile->fileName()}); - - m_stdoutFile->seek(0); - m_stdoutFile->readLine(); // skip prompt line - - KdbxXmlReader reader(KeePass2::FILE_VERSION_3_1); - QScopedPointer db(new Database()); - reader.readDatabase(m_stdoutFile.data(), db.data()); - QVERIFY(!reader.hasError()); - QVERIFY(db.data()); - auto* entry = db->rootGroup()->findEntryByPath("/Sample Entry"); - QVERIFY(entry); - QCOMPARE(entry->password(), QString("Password")); - - m_stdoutFile->reset(); - - // Quiet option - QScopedPointer dbQuiet(new Database()); - qint64 pos = m_stdoutFile->pos(); - qint64 posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - exportCmd.execute({"export", "-f", "xml", "-q", m_dbFile->fileName()}); - m_stdoutFile->seek(pos); - m_stderrFile->seek(posErr); - reader.readDatabase(m_stdoutFile.data(), dbQuiet.data()); - QVERIFY(!reader.hasError()); - QVERIFY(db.data()); - QCOMPARE(m_stderrFile->readAll(), QByteArray("")); - - // CSV exporting - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - exportCmd.execute({"export", "-f", "csv", m_dbFile->fileName()}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip prompt line - m_stderrFile->seek(posErr); - QByteArray csvHeader = m_stdoutFile->readLine(); - QCOMPARE(csvHeader, QByteArray("\"Group\",\"Title\",\"Username\",\"Password\",\"URL\",\"Notes\"\n")); - QByteArray csvData = m_stdoutFile->readAll(); - QVERIFY(csvData.contains(QByteArray( - "\"NewDatabase\",\"Sample Entry\",\"User Name\",\"Password\",\"http://www.somesite.com/\",\"Notes\"\n"))); - QCOMPARE(m_stderrFile->readAll(), QByteArray("")); - - // test invalid format - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - exportCmd.execute({"export", "-f", "yaml", m_dbFile->fileName()}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip prompt line - m_stderrFile->seek(posErr); - QCOMPARE(m_stdoutFile->readLine(), QByteArray("")); - QCOMPARE(m_stderrFile->readLine(), QByteArray("Unsupported format yaml\n")); -} - -void TestCli::testGenerate_data() -{ - QTest::addColumn("parameters"); - QTest::addColumn("pattern"); - - QTest::newRow("default") << QStringList{"generate"} << "^[^\r\n]+$"; - QTest::newRow("length") << QStringList{"generate", "-L", "13"} << "^.{13}$"; - QTest::newRow("lowercase") << QStringList{"generate", "-L", "14", "-l"} << "^[a-z]{14}$"; - QTest::newRow("uppercase") << QStringList{"generate", "-L", "15", "--upper"} << "^[A-Z]{15}$"; - QTest::newRow("numbers") << QStringList{"generate", "-L", "16", "-n"} << "^[0-9]{16}$"; - QTest::newRow("special") << QStringList{"generate", "-L", "200", "-s"} - << R"(^[\(\)\[\]\{\}\.\-*|\\,:;"'\/\_!+-<=>?#$%&^`@~]{200}$)"; - QTest::newRow("special (exclude)") << QStringList{"generate", "-L", "200", "-s", "-x", "+.?@&"} - << R"(^[\(\)\[\]\{\}\.\-*|\\,:;"'\/\_!-<=>#$%^`~]{200}$)"; - QTest::newRow("extended") << QStringList{"generate", "-L", "50", "-e"} - << R"(^[^a-zA-Z0-9\(\)\[\]\{\}\.\-\*\|\\,:;"'\/\_!+-<=>?#$%&^`@~]{50}$)"; - QTest::newRow("numbers + lowercase + uppercase") - << QStringList{"generate", "-L", "16", "-n", "--upper", "-l"} << "^[0-9a-zA-Z]{16}$"; - QTest::newRow("numbers + lowercase + uppercase (exclude)") - << QStringList{"generate", "-L", "500", "-n", "-U", "-l", "-x", "abcdefg0123@"} << "^[^abcdefg0123@]{500}$"; - QTest::newRow("numbers + lowercase + uppercase (exclude similar)") - << QStringList{"generate", "-L", "200", "-n", "-U", "-l", "--exclude-similar"} << "^[^l1IO0]{200}$"; - QTest::newRow("uppercase + lowercase (every)") - << QStringList{"generate", "-L", "2", "--upper", "-l", "--every-group"} << "^[a-z][A-Z]|[A-Z][a-z]$"; - QTest::newRow("numbers + lowercase (every)") - << QStringList{"generate", "-L", "2", "-n", "-l", "--every-group"} << "^[a-z][0-9]|[0-9][a-z]$"; -} - -void TestCli::testGenerate() -{ - QFETCH(QStringList, parameters); - QFETCH(QString, pattern); - - Generate generateCmd; - QVERIFY(!generateCmd.name.isEmpty()); - QVERIFY(generateCmd.getDescriptionLine().contains(generateCmd.name)); - - qint64 pos = 0; - // run multiple times to make accidental passes unlikely - TextStream stream(m_stdoutFile.data()); - for (int i = 0; i < 10; ++i) { - generateCmd.execute(parameters); - stream.seek(pos); - QRegularExpression regex(pattern); - QString password = stream.readLine(); - pos = stream.pos(); - QVERIFY2(regex.match(password).hasMatch(), - qPrintable("Password " + password + " does not match pattern " + pattern)); - } - - // Testing with invalid password length - auto posErr = m_stderrFile->pos(); - generateCmd.execute({"generate", "-L", "-10"}); - m_stderrFile->seek(posErr); - QCOMPARE(m_stderrFile->readLine(), QByteArray("Invalid password length -10\n")); - - posErr = m_stderrFile->pos(); - generateCmd.execute({"generate", "-L", "0"}); - m_stderrFile->seek(posErr); - QCOMPARE(m_stderrFile->readLine(), QByteArray("Invalid password length 0\n")); - - // Testing with invalid word count format - posErr = m_stderrFile->pos(); - generateCmd.execute({"generate", "-L", "bleuh"}); - m_stderrFile->seek(posErr); - QCOMPARE(m_stderrFile->readLine(), QByteArray("Invalid password length bleuh\n")); -} - -void TestCli::testKeyFileOption() -{ - List listCmd; - - QString keyFilePath(QString(KEEPASSX_TEST_DATA_DIR).append("/KeyFileProtected.key")); - Utils::Test::setNextPassword("a"); - listCmd.execute({"ls", "-k", keyFilePath, m_keyFileProtectedDbFile->fileName()}); - m_stdoutFile->reset(); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stdoutFile->readAll(), - QByteArray("entry1\n" - "entry2\n")); - - // Should raise an error with no key file. - qint64 pos = m_stdoutFile->pos(); - qint64 posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - listCmd.execute({"ls", m_keyFileProtectedDbFile->fileName()}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->seek(posErr); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - QVERIFY(m_stderrFile->readAll().contains("Invalid credentials were provided")); - - // Should raise an error if key file path is invalid. - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - listCmd.execute({"ls", "-k", "invalidpath", m_keyFileProtectedDbFile->fileName()}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->seek(posErr); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - QCOMPARE(m_stderrFile->readAll().split(':').at(0), QByteArray("Failed to load key file invalidpath")); -} - -void TestCli::testNoPasswordOption() -{ - List listCmd; - - QString keyFilePath(QString(KEEPASSX_TEST_DATA_DIR).append("/KeyFileProtectedNoPassword.key")); - listCmd.execute({"ls", "-k", keyFilePath, "--no-password", m_keyFileProtectedNoPasswordDbFile->fileName()}); - m_stdoutFile->reset(); - QCOMPARE(m_stdoutFile->readAll(), - QByteArray("entry1\n" - "entry2\n")); - - // Should raise an error with no key file. - qint64 pos = m_stdoutFile->pos(); - qint64 posErr = m_stderrFile->pos(); - listCmd.execute({"ls", "--no-password", m_keyFileProtectedNoPasswordDbFile->fileName()}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->seek(posErr); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - QVERIFY(m_stderrFile->readAll().contains("Invalid credentials were provided")); -} - -void TestCli::testList() -{ - List listCmd; - QVERIFY(!listCmd.name.isEmpty()); - QVERIFY(listCmd.getDescriptionLine().contains(listCmd.name)); - - Utils::Test::setNextPassword("a"); - listCmd.execute({"ls", m_dbFile->fileName()}); - m_stdoutFile->reset(); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stdoutFile->readAll(), - QByteArray("Sample Entry\n" - "General/\n" - "Windows/\n" - "Network/\n" - "Internet/\n" - "eMail/\n" - "Homebanking/\n")); - - // Quiet option - qint64 pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - listCmd.execute({"ls", "-q", m_dbFile->fileName()}); - m_stdoutFile->seek(pos); - QCOMPARE(m_stdoutFile->readAll(), - QByteArray("Sample Entry\n" - "General/\n" - "Windows/\n" - "Network/\n" - "Internet/\n" - "eMail/\n" - "Homebanking/\n")); - - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - listCmd.execute({"ls", "-R", m_dbFile->fileName()}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stdoutFile->readAll(), - QByteArray("Sample Entry\n" - "General/\n" - " [empty]\n" - "Windows/\n" - " [empty]\n" - "Network/\n" - " [empty]\n" - "Internet/\n" - " [empty]\n" - "eMail/\n" - " [empty]\n" - "Homebanking/\n" - " Subgroup/\n" - " Subgroup Entry\n")); - - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - listCmd.execute({"ls", "-R", "-f", m_dbFile->fileName()}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stdoutFile->readAll(), - QByteArray("Sample Entry\n" - "General/\n" - "General/[empty]\n" - "Windows/\n" - "Windows/[empty]\n" - "Network/\n" - "Network/[empty]\n" - "Internet/\n" - "Internet/[empty]\n" - "eMail/\n" - "eMail/[empty]\n" - "Homebanking/\n" - "Homebanking/Subgroup/\n" - "Homebanking/Subgroup/Subgroup Entry\n")); - - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - listCmd.execute({"ls", "-R", "-f", m_dbFile->fileName(), "/Homebanking"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stdoutFile->readAll(), - QByteArray("Subgroup/\n" - "Subgroup/Subgroup Entry\n")); - - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - listCmd.execute({"ls", m_dbFile->fileName(), "/General/"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("[empty]\n")); - - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - listCmd.execute({"ls", m_dbFile->fileName(), "/DoesNotExist/"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->reset(); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - QCOMPARE(m_stderrFile->readAll(), QByteArray("Cannot find group /DoesNotExist/.\n")); -} - -void TestCli::testLocate() -{ - Locate locateCmd; - QVERIFY(!locateCmd.name.isEmpty()); - QVERIFY(locateCmd.getDescriptionLine().contains(locateCmd.name)); - - Utils::Test::setNextPassword("a"); - locateCmd.execute({"locate", m_dbFile->fileName(), "Sample"}); - m_stdoutFile->reset(); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stdoutFile->readAll(), QByteArray("/Sample Entry\n")); - - // Quiet option - qint64 pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - locateCmd.execute({"locate", m_dbFile->fileName(), "-q", "Sample"}); - m_stdoutFile->seek(pos); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("/Sample Entry\n")); - - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - locateCmd.execute({"locate", m_dbFile->fileName(), "Does Not Exist"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->reset(); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - QCOMPARE(m_stderrFile->readAll(), QByteArray("No results for that search term.\n")); - - // write a modified database - auto db = readTestDatabase(); - QVERIFY(db); - auto* group = db->rootGroup()->findGroupByPath("/General/"); - QVERIFY(group); - auto* entry = new Entry(); - entry->setUuid(QUuid::createUuid()); - entry->setTitle("New Entry"); - group->addEntry(entry); - TemporaryFile tmpFile; - tmpFile.open(); - Kdbx4Writer writer; - writer.writeDatabase(&tmpFile, db.data()); - tmpFile.close(); - - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - locateCmd.execute({"locate", tmpFile.fileName(), "New"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stdoutFile->readAll(), QByteArray("/General/New Entry\n")); - - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - locateCmd.execute({"locate", tmpFile.fileName(), "Entry"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stdoutFile->readAll(), - QByteArray("/Sample Entry\n/General/New Entry\n/Homebanking/Subgroup/Subgroup Entry\n")); -} - -void TestCli::testMerge() -{ - Merge mergeCmd; - QVERIFY(!mergeCmd.name.isEmpty()); - QVERIFY(mergeCmd.getDescriptionLine().contains(mergeCmd.name)); - - Kdbx4Writer writer; - Kdbx4Reader reader; - - // load test database and save copies - auto db = readTestDatabase(); - QVERIFY(db); - TemporaryFile targetFile1; - targetFile1.open(); - writer.writeDatabase(&targetFile1, db.data()); - targetFile1.close(); - TemporaryFile targetFile2; - targetFile2.open(); - writer.writeDatabase(&targetFile2, db.data()); - targetFile2.close(); - - // save another copy with a different password - TemporaryFile targetFile3; - targetFile3.open(); - auto oldKey = db->key(); - auto key = QSharedPointer::create(); - key->addKey(QSharedPointer::create("b")); - db->setKey(key); - writer.writeDatabase(&targetFile3, db.data()); - targetFile3.close(); - db->setKey(oldKey); - - // then add a new entry to the in-memory database and save another copy - auto* entry = new Entry(); - entry->setUuid(QUuid::createUuid()); - entry->setTitle("Some Website"); - entry->setPassword("secretsecretsecret"); - auto* group = db->rootGroup()->findGroupByPath("/Internet/"); - QVERIFY(group); - group->addEntry(entry); - TemporaryFile sourceFile; - sourceFile.open(); - writer.writeDatabase(&sourceFile, db.data()); - sourceFile.close(); - - qint64 pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - mergeCmd.execute({"merge", "-s", targetFile1.fileName(), sourceFile.fileName()}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); - m_stderrFile->reset(); - QList outLines1 = m_stdoutFile->readAll().split('\n'); - QCOMPARE(outLines1.at(0).split('[').at(0), QByteArray("\tOverwriting Internet ")); - QCOMPARE(outLines1.at(1).split('[').at(0), QByteArray("\tCreating missing Some Website ")); - QCOMPARE(outLines1.at(2), - QString("Successfully merged %1 into %2.").arg(sourceFile.fileName(), targetFile1.fileName()).toUtf8()); - - QFile readBack(targetFile1.fileName()); - readBack.open(QIODevice::ReadOnly); - auto mergedDb = QSharedPointer::create(); - reader.readDatabase(&readBack, oldKey, mergedDb.data()); - readBack.close(); - QVERIFY(mergedDb); - auto* entry1 = mergedDb->rootGroup()->findEntryByPath("/Internet/Some Website"); - QVERIFY(entry1); - QCOMPARE(entry1->title(), QString("Some Website")); - QCOMPARE(entry1->password(), QString("secretsecretsecret")); - - // the dry run option should not modify the target database. - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - mergeCmd.execute({"merge", "--dry-run", "-s", targetFile2.fileName(), sourceFile.fileName()}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); - m_stderrFile->reset(); - QList outLines2 = m_stdoutFile->readAll().split('\n'); - QCOMPARE(outLines2.at(0).split('[').at(0), QByteArray("\tOverwriting Internet ")); - QCOMPARE(outLines2.at(1).split('[').at(0), QByteArray("\tCreating missing Some Website ")); - QCOMPARE(outLines2.at(2), QByteArray("Database was not modified by merge operation.")); - - QFile readBack2(targetFile2.fileName()); - readBack2.open(QIODevice::ReadOnly); - mergedDb = QSharedPointer::create(); - reader.readDatabase(&readBack2, oldKey, mergedDb.data()); - readBack2.close(); - QVERIFY(mergedDb); - entry1 = mergedDb->rootGroup()->findEntryByPath("/Internet/Some Website"); - QVERIFY(!entry1); - - // the dry run option can be used with the quiet option - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - mergeCmd.execute({"merge", "--dry-run", "-s", "-q", targetFile2.fileName(), sourceFile.fileName()}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); - m_stderrFile->reset(); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - - readBack2.setFileName(targetFile2.fileName()); - readBack2.open(QIODevice::ReadOnly); - mergedDb = QSharedPointer::create(); - reader.readDatabase(&readBack2, oldKey, mergedDb.data()); - readBack2.close(); - QVERIFY(mergedDb); - entry1 = mergedDb->rootGroup()->findEntryByPath("/Internet/Some Website"); - QVERIFY(!entry1); - - // try again with different passwords for both files - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("b"); - Utils::Test::setNextPassword("a"); - mergeCmd.execute({"merge", targetFile3.fileName(), sourceFile.fileName()}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); - m_stdoutFile->readLine(); - QList outLines3 = m_stdoutFile->readAll().split('\n'); - QCOMPARE(outLines3.at(2), - QString("Successfully merged %1 into %2.").arg(sourceFile.fileName(), targetFile3.fileName()).toUtf8()); - - readBack.setFileName(targetFile3.fileName()); - readBack.open(QIODevice::ReadOnly); - mergedDb = QSharedPointer::create(); - reader.readDatabase(&readBack, key, mergedDb.data()); - readBack.close(); - QVERIFY(mergedDb); - entry1 = mergedDb->rootGroup()->findEntryByPath("/Internet/Some Website"); - QVERIFY(entry1); - QCOMPARE(entry1->title(), QString("Some Website")); - QCOMPARE(entry1->password(), QString("secretsecretsecret")); - - // making sure that the message is different if the database was not - // modified by the merge operation. - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - mergeCmd.execute({"merge", "-s", sourceFile.fileName(), sourceFile.fileName()}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("Database was not modified by merge operation.\n")); - - // Quiet option - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - mergeCmd.execute({"merge", "-q", "-s", sourceFile.fileName(), sourceFile.fileName()}); - m_stdoutFile->seek(pos); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - - // Quiet option without the -s option - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - Utils::Test::setNextPassword("a"); - mergeCmd.execute({"merge", "-q", sourceFile.fileName(), sourceFile.fileName()}); - m_stdoutFile->seek(pos); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); -} - -void TestCli::testMove() -{ - Move moveCmd; - QVERIFY(!moveCmd.name.isEmpty()); - QVERIFY(moveCmd.getDescriptionLine().contains(moveCmd.name)); - - qint64 pos = m_stdoutFile->pos(); - qint64 posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - moveCmd.execute({"mv", m_dbFile->fileName(), "invalid_entry_path", "invalid_group_path"}); - m_stdoutFile->seek(pos); - m_stderrFile->seek(posErr); - m_stdoutFile->readLine(); // skip prompt line - QCOMPARE(m_stdoutFile->readLine(), QByteArray("")); - QCOMPARE(m_stderrFile->readLine(), QByteArray("Could not find entry with path invalid_entry_path.\n")); - - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - moveCmd.execute({"mv", m_dbFile->fileName(), "Sample Entry", "invalid_group_path"}); - m_stdoutFile->seek(pos); - m_stderrFile->seek(posErr); - m_stdoutFile->readLine(); // skip prompt line - QCOMPARE(m_stdoutFile->readLine(), QByteArray("")); - QCOMPARE(m_stderrFile->readLine(), QByteArray("Could not find group with path invalid_group_path.\n")); - - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - moveCmd.execute({"mv", m_dbFile->fileName(), "Sample Entry", "General/"}); - m_stdoutFile->seek(pos); - m_stderrFile->seek(posErr); - m_stdoutFile->readLine(); // skip prompt line - QCOMPARE(m_stdoutFile->readLine(), QByteArray("Successfully moved entry Sample Entry to group General/.\n")); - QCOMPARE(m_stderrFile->readLine(), QByteArray("")); - - auto db = readTestDatabase(); - auto* entry = db->rootGroup()->findEntryByPath("General/Sample Entry"); - QVERIFY(entry); - - // Test that not modified if the same group is destination. - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - moveCmd.execute({"mv", m_dbFile->fileName(), "General/Sample Entry", "General/"}); - m_stdoutFile->seek(pos); - m_stderrFile->seek(posErr); - m_stdoutFile->readLine(); // skip prompt line - QCOMPARE(m_stdoutFile->readLine(), QByteArray("")); - QCOMPARE(m_stderrFile->readLine(), QByteArray("Entry is already in group General/.\n")); - - // sanity check - db = readTestDatabase(); - entry = db->rootGroup()->findEntryByPath("General/Sample Entry"); - QVERIFY(entry); -} - -void TestCli::testRemove() -{ - Remove removeCmd; - QVERIFY(!removeCmd.name.isEmpty()); - QVERIFY(removeCmd.getDescriptionLine().contains(removeCmd.name)); - - Kdbx3Reader reader; - Kdbx3Writer writer; - - // load test database and save a copy with disabled recycle bin - auto db = readTestDatabase(); - QVERIFY(db); - TemporaryFile fileCopy; - fileCopy.open(); - db->metadata()->setRecycleBinEnabled(false); - writer.writeDatabase(&fileCopy, db.data()); - fileCopy.close(); - - qint64 pos = m_stdoutFile->pos(); - qint64 posErr = m_stderrFile->pos(); - - // delete entry and verify - Utils::Test::setNextPassword("a"); - removeCmd.execute({"rm", m_dbFile->fileName(), "/Sample Entry"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully recycled entry Sample Entry.\n")); - QCOMPARE(m_stderrFile->readAll(), QByteArray("")); - - auto key = QSharedPointer::create(); - key->addKey(QSharedPointer::create("a")); - QFile readBack(m_dbFile->fileName()); - readBack.open(QIODevice::ReadOnly); - auto readBackDb = QSharedPointer::create(); - reader.readDatabase(&readBack, key, readBackDb.data()); - readBack.close(); - QVERIFY(readBackDb); - QVERIFY(!readBackDb->rootGroup()->findEntryByPath("/Sample Entry")); - QVERIFY(readBackDb->rootGroup()->findEntryByPath(QString("/%1/Sample Entry").arg(Group::tr("Recycle Bin")))); - - pos = m_stdoutFile->pos(); - pos = m_stdoutFile->pos(); - - // try again, this time without recycle bin - Utils::Test::setNextPassword("a"); - removeCmd.execute({"rm", fileCopy.fileName(), "/Sample Entry"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully deleted entry Sample Entry.\n")); - - readBack.setFileName(fileCopy.fileName()); - readBack.open(QIODevice::ReadOnly); - readBackDb = QSharedPointer::create(); - reader.readDatabase(&readBack, key, readBackDb.data()); - readBack.close(); - QVERIFY(readBackDb); - QVERIFY(!readBackDb->rootGroup()->findEntryByPath("/Sample Entry")); - QVERIFY(!readBackDb->rootGroup()->findEntryByPath(QString("/%1/Sample Entry").arg(Group::tr("Recycle Bin")))); - - // finally, try deleting a non-existent entry - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - removeCmd.execute({"rm", fileCopy.fileName(), "/Sample Entry"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->seek(posErr); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - QCOMPARE(m_stderrFile->readAll(), QByteArray("Entry /Sample Entry not found.\n")); - - // try deleting a directory, should fail - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - removeCmd.execute({"rm", fileCopy.fileName(), "/General"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->seek(posErr); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - QCOMPARE(m_stderrFile->readAll(), QByteArray("Entry /General not found.\n")); -} - -void TestCli::testRemoveGroup() -{ - RemoveGroup removeGroupCmd; - QVERIFY(!removeGroupCmd.name.isEmpty()); - QVERIFY(removeGroupCmd.getDescriptionLine().contains(removeGroupCmd.name)); - - Kdbx3Reader reader; - Kdbx3Writer writer; - - // try deleting a directory, should recycle it first. - qint64 pos = m_stdoutFile->pos(); - qint64 posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - removeGroupCmd.execute({"rmdir", m_dbFile->fileName(), "/General"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->seek(posErr); - QCOMPARE(m_stderrFile->readAll(), QByteArray("")); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully recycled group /General.\n")); - - auto db = readTestDatabase(); - auto* group = db->rootGroup()->findGroupByPath("General"); - QVERIFY(!group); - - // try deleting a directory again, should delete it permanently. - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - removeGroupCmd.execute({"rmdir", m_dbFile->fileName(), "Recycle Bin/General"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->seek(posErr); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully deleted group Recycle Bin/General.\n")); - QCOMPARE(m_stderrFile->readAll(), QByteArray("")); - - db = readTestDatabase(); - group = db->rootGroup()->findGroupByPath("Recycle Bin/General"); - QVERIFY(!group); - - // try deleting an invalid group, should fail. - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - removeGroupCmd.execute({"rmdir", m_dbFile->fileName(), "invalid"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->seek(posErr); - QCOMPARE(m_stderrFile->readAll(), QByteArray("Group invalid not found.\n")); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - - // Should fail to remove the root group. - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - removeGroupCmd.execute({"rmdir", m_dbFile->fileName(), "/"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->seek(posErr); - QCOMPARE(m_stderrFile->readAll(), QByteArray("Cannot remove root group from database.\n")); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); -} - -void TestCli::testRemoveQuiet() -{ - Remove removeCmd; - QVERIFY(!removeCmd.name.isEmpty()); - QVERIFY(removeCmd.getDescriptionLine().contains(removeCmd.name)); - - Kdbx3Reader reader; - Kdbx3Writer writer; - - qint64 pos = m_stdoutFile->pos(); - - // delete entry and verify - Utils::Test::setNextPassword("a"); - removeCmd.execute({"rm", "-q", m_dbFile->fileName(), "/Sample Entry"}); - m_stdoutFile->seek(pos); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - - auto key = QSharedPointer::create(); - key->addKey(QSharedPointer::create("a")); - QFile readBack(m_dbFile->fileName()); - readBack.open(QIODevice::ReadOnly); - auto readBackDb = QSharedPointer::create(); - reader.readDatabase(&readBack, key, readBackDb.data()); - readBack.close(); - QVERIFY(readBackDb); - QVERIFY(!readBackDb->rootGroup()->findEntryByPath("/Sample Entry")); - QVERIFY(readBackDb->rootGroup()->findEntryByPath(QString("/%1/Sample Entry").arg(Group::tr("Recycle Bin")))); - - pos = m_stdoutFile->pos(); - - // remove the entry completely - Utils::Test::setNextPassword("a"); - removeCmd.execute({"rm", "-q", m_dbFile->fileName(), QString("/%1/Sample Entry").arg(Group::tr("Recycle Bin"))}); - m_stdoutFile->seek(pos); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - - readBack.setFileName(m_dbFile->fileName()); - readBack.open(QIODevice::ReadOnly); - readBackDb = QSharedPointer::create(); - reader.readDatabase(&readBack, key, readBackDb.data()); - readBack.close(); - QVERIFY(readBackDb); - QVERIFY(!readBackDb->rootGroup()->findEntryByPath("/Sample Entry")); - QVERIFY(!readBackDb->rootGroup()->findEntryByPath(QString("/%1/Sample Entry").arg(Group::tr("Recycle Bin")))); -} - -void TestCli::testShow() -{ - Show showCmd; - QVERIFY(!showCmd.name.isEmpty()); - QVERIFY(showCmd.getDescriptionLine().contains(showCmd.name)); - - Utils::Test::setNextPassword("a"); - showCmd.execute({"show", m_dbFile->fileName(), "/Sample Entry"}); - m_stdoutFile->reset(); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stdoutFile->readAll(), - QByteArray("Title: Sample Entry\n" - "UserName: User Name\n" - "Password: Password\n" - "URL: http://www.somesite.com/\n" - "Notes: Notes\n")); - - qint64 pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - showCmd.execute({"show", m_dbFile->fileName(), "-q", "/Sample Entry"}); - m_stdoutFile->seek(pos); - QCOMPARE(m_stdoutFile->readAll(), - QByteArray("Title: Sample Entry\n" - "UserName: User Name\n" - "Password: Password\n" - "URL: http://www.somesite.com/\n" - "Notes: Notes\n")); - - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - showCmd.execute({"show", "-a", "Title", m_dbFile->fileName(), "/Sample Entry"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stdoutFile->readAll(), QByteArray("Sample Entry\n")); - - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - showCmd.execute({"show", "-a", "Title", "-a", "URL", m_dbFile->fileName(), "/Sample Entry"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stdoutFile->readAll(), - QByteArray("Sample Entry\n" - "http://www.somesite.com/\n")); - - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - showCmd.execute({"show", "-a", "DoesNotExist", m_dbFile->fileName(), "/Sample Entry"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->reset(); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - QCOMPARE(m_stderrFile->readAll(), QByteArray("ERROR: unknown attribute DoesNotExist.\n")); - - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - showCmd.execute({"show", "-t", m_dbFile->fileName(), "/Sample Entry"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - QVERIFY(isTOTP(m_stdoutFile->readAll())); - - pos = m_stdoutFile->pos(); - Utils::Test::setNextPassword("a"); - showCmd.execute({"show", "-a", "Title", m_dbFile->fileName(), "--totp", "/Sample Entry"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stdoutFile->readLine(), QByteArray("Sample Entry\n")); - QVERIFY(isTOTP(m_stdoutFile->readAll())); - - pos = m_stdoutFile->pos(); - qint64 posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - showCmd.execute({"show", m_dbFile2->fileName(), "--totp", "/Sample Entry"}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->seek(posErr); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - QCOMPARE(m_stderrFile->readAll(), QByteArray("Entry with path /Sample Entry has no TOTP set up.\n")); -} - -void TestCli::testInvalidDbFiles() -{ - Show showCmd; - QString nonExistentDbPath("/foo/bar/baz"); - QString directoryName("/"); - - qint64 pos = m_stderrFile->pos(); - showCmd.execute({"show", nonExistentDbPath, "-q", "/Sample Entry"}); - m_stderrFile->seek(pos); - QCOMPARE(QString(m_stderrFile->readAll()), - QObject::tr("Failed to open database file %1: not found").arg(nonExistentDbPath) + "\n"); - - pos = m_stderrFile->pos(); - showCmd.execute({"show", directoryName, "-q", "whatever"}); - m_stderrFile->seek(pos); - QCOMPARE(QString(m_stderrFile->readAll()), - QObject::tr("Failed to open database file %1: not a plain file").arg(directoryName) + "\n"); - - // Create a write-only file and try to open it. - // QFileInfo.isReadable returns 'true' on Windows, even after the call to - // setPermissions(WriteOwner) and with NTFS permissions enabled, so this - // check doesn't work. -#if !defined(Q_OS_WIN) - QTemporaryFile tempFile; - QVERIFY(tempFile.open()); - QString path = QFileInfo(tempFile).absoluteFilePath(); - QVERIFY(tempFile.setPermissions(QFileDevice::WriteOwner)); - pos = m_stderrFile->pos(); - showCmd.execute({"show", path, "some entry"}); - m_stderrFile->seek(pos); - QCOMPARE(QString(m_stderrFile->readAll()), - QObject::tr("Failed to open database file %1: not readable").arg(path) + "\n"); -#endif // Q_OS_WIN -} - -/** - * Secret key for the YubiKey slot used by the unit test is - * 1c e3 0f d7 8d 20 dc fa 40 b5 0c 18 77 9a fb 0f 02 28 8d b7 - * This secret should be configured at slot 2, and the slot - * should be configured as passive. - */ -void TestCli::testYubiKeyOption() -{ - if (!YubiKey::instance()->init()) { - QSKIP("Unable to connect to YubiKey"); - } - - QString errorMessage; - bool isBlocking = YubiKey::instance()->checkSlotIsBlocking(2, errorMessage); - if (isBlocking && errorMessage.isEmpty()) { - QSKIP("Skipping YubiKey in press mode."); - } - - QByteArray challenge("CLITest"); - QByteArray response; - YubiKey::instance()->challenge(2, false, challenge, response); - QByteArray expected("\xA2\x3B\x94\x00\xBE\x47\x9A\x30\xA9\xEB\x50\x9B\x85\x56\x5B\x6B\x30\x25\xB4\x8E", 20); - QVERIFY2(response == expected, "YubiKey Slot 2 is not configured with correct secret key."); - - List listCmd; - Add addCmd; - - Utils::Test::setNextPassword("a"); - listCmd.execute({"ls", "-y", "2", m_yubiKeyProtectedDbFile->fileName()}); - m_stdoutFile->reset(); - m_stderrFile->reset(); - m_stdoutFile->readLine(); // skip password prompt - QCOMPARE(m_stdoutFile->readAll(), - QByteArray("entry1\n" - "entry2\n")); - - // Should raise an error with no yubikey slot. - qint64 pos = m_stdoutFile->pos(); - qint64 posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - listCmd.execute({"ls", m_yubiKeyProtectedDbFile->fileName()}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->seek(posErr); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - QCOMPARE(m_stderrFile->readLine(), - QByteArray("Error while reading the database: Invalid credentials were provided, please try again.\n")); - QCOMPARE(m_stderrFile->readLine(), - QByteArray("If this reoccurs, then your database file may be corrupt. (HMAC mismatch)\n")); - - // Should raise an error if yubikey slot is not a string - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - listCmd.execute({"ls", "-y", "invalidslot", m_yubiKeyProtectedDbFile->fileName()}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->seek(posErr); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - QCOMPARE(m_stderrFile->readAll().split(':').at(0), QByteArray("Invalid YubiKey slot invalidslot\n")); - - // Should raise an error if yubikey slot is invalid. - pos = m_stdoutFile->pos(); - posErr = m_stderrFile->pos(); - Utils::Test::setNextPassword("a"); - listCmd.execute({"ls", "-y", "3", m_yubiKeyProtectedDbFile->fileName()}); - m_stdoutFile->seek(pos); - m_stdoutFile->readLine(); // skip password prompt - m_stderrFile->seek(posErr); - QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); - QCOMPARE(m_stderrFile->readAll().split(':').at(0), QByteArray("Invalid YubiKey slot 3\n")); -} - -namespace -{ - - void expectParseResult(const QString& input, const QStringList& expectedOutput) - { - QStringList result = Utils::splitCommandString(input); - QCOMPARE(result.size(), expectedOutput.size()); - for (int i = 0; i < expectedOutput.size(); ++i) { - QCOMPARE(result[i], expectedOutput[i]); - } - } - -} // namespace - -void TestCli::testCommandParsing_data() -{ - QTest::addColumn("input"); - QTest::addColumn("expectedOutput"); - - QTest::newRow("basic") << "hello world" << QStringList({"hello", "world"}); - QTest::newRow("basic escaping") << "hello\\ world" << QStringList({"hello world"}); - QTest::newRow("quoted string") << "\"hello world\"" << QStringList({"hello world"}); - QTest::newRow("multiple params") << "show Passwords/Internet" << QStringList({"show", "Passwords/Internet"}); - QTest::newRow("quoted string inside param") - << R"(ls foo\ bar\ baz"quoted")" << QStringList({"ls", "foo bar baz\"quoted\""}); - QTest::newRow("multiple whitespace") << "hello world" << QStringList({"hello", "world"}); - QTest::newRow("single slash char") << "\\" << QStringList({"\\"}); - QTest::newRow("double backslash entry name") << "show foo\\\\\\\\bar" << QStringList({"show", "foo\\\\bar"}); -} - -void TestCli::testCommandParsing() -{ - QFETCH(QString, input); - QFETCH(QStringList, expectedOutput); - - expectParseResult(input, expectedOutput); -} - -void TestCli::testOpen() -{ - Open o; - - Utils::Test::setNextPassword("a"); - o.execute({"open", m_dbFile->fileName()}); - m_stdoutFile->reset(); - QVERIFY(o.currentDatabase); - - List l; - // Set a current database, simulating interactive mode. - l.currentDatabase = o.currentDatabase; - l.execute({"ls"}); - m_stdoutFile->reset(); - QByteArray expectedOutput("Sample Entry\n" - "General/\n" - "Windows/\n" - "Network/\n" - "Internet/\n" - "eMail/\n" - "Homebanking/\n"); - QByteArray actualOutput = m_stdoutFile->readAll(); - actualOutput.truncate(expectedOutput.length()); - QCOMPARE(actualOutput, expectedOutput); -} - -void TestCli::testHelp() -{ - Help h; - Commands::setupCommands(false); - - { - h.execute({"help"}); - m_stderrFile->reset(); - QString output(m_stderrFile->readAll()); - QVERIFY(output.contains(QObject::tr("Available commands"))); - } - - { - List l; - h.execute({"help", "ls"}); - m_stderrFile->reset(); - QString output(m_stderrFile->readAll()); - QVERIFY(output.contains(l.description)); - } -} +/* + * Copyright (C) 2019 KeePassXC Team + * + * 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 . + */ + +#include "TestCli.h" + +#include "config-keepassx-tests.h" +#include "core/Bootstrap.h" +#include "core/Config.h" +#include "core/Global.h" +#include "core/PasswordGenerator.h" +#include "core/Tools.h" +#include "crypto/Crypto.h" +#include "format/Kdbx3Reader.h" +#include "format/Kdbx3Writer.h" +#include "format/Kdbx4Reader.h" +#include "format/Kdbx4Writer.h" +#include "format/KdbxXmlReader.h" +#include "format/KeePass2.h" + +#include "cli/Add.h" +#include "cli/AddGroup.h" +#include "cli/Analyze.h" +#include "cli/Clip.h" +#include "cli/Command.h" +#include "cli/Create.h" +#include "cli/Diceware.h" +#include "cli/Edit.h" +#include "cli/Estimate.h" +#include "cli/Export.h" +#include "cli/Generate.h" +#include "cli/Help.h" +#include "cli/Import.h" +#include "cli/List.h" +#include "cli/Locate.h" +#include "cli/Merge.h" +#include "cli/Move.h" +#include "cli/Open.h" +#include "cli/Remove.h" +#include "cli/RemoveGroup.h" +#include "cli/Show.h" +#include "cli/Utils.h" + +#include +#include +#include +#include +#include +#include + +#include + +QTEST_MAIN(TestCli) + +QSharedPointer globalCurrentDatabase; + +void TestCli::initTestCase() +{ + QVERIFY(Crypto::init()); + + Config::createTempFileInstance(); + Bootstrap::bootstrapApplication(); + + // Load the NewDatabase.kdbx file into temporary storage + QFile sourceDbFile(QString(KEEPASSX_TEST_DATA_DIR).append("/NewDatabase.kdbx")); + QVERIFY(sourceDbFile.open(QIODevice::ReadOnly)); + QVERIFY(Tools::readAllFromDevice(&sourceDbFile, m_dbData)); + sourceDbFile.close(); + + // Load the NewDatabase2.kdbx file into temporary storage + QFile sourceDbFile2(QString(KEEPASSX_TEST_DATA_DIR).append("/NewDatabase2.kdbx")); + QVERIFY(sourceDbFile2.open(QIODevice::ReadOnly)); + QVERIFY(Tools::readAllFromDevice(&sourceDbFile2, m_dbData2)); + sourceDbFile2.close(); + + // Load the KeyFileProtected.kdbx file into temporary storage + QFile sourceDbFile3(QString(KEEPASSX_TEST_DATA_DIR).append("/KeyFileProtected.kdbx")); + QVERIFY(sourceDbFile3.open(QIODevice::ReadOnly)); + QVERIFY(Tools::readAllFromDevice(&sourceDbFile3, m_keyFileProtectedDbData)); + sourceDbFile3.close(); + + // Load the KeyFileProtectedNoPassword.kdbx file into temporary storage + QFile sourceDbFile4(QString(KEEPASSX_TEST_DATA_DIR).append("/KeyFileProtectedNoPassword.kdbx")); + QVERIFY(sourceDbFile4.open(QIODevice::ReadOnly)); + QVERIFY(Tools::readAllFromDevice(&sourceDbFile4, m_keyFileProtectedNoPasswordDbData)); + sourceDbFile4.close(); + + QFile sourceDbFileYubiKeyProtected(QString(KEEPASSX_TEST_DATA_DIR).append("/YubiKeyProtectedPasswords.kdbx")); + QVERIFY(sourceDbFileYubiKeyProtected.open(QIODevice::ReadOnly)); + QVERIFY(Tools::readAllFromDevice(&sourceDbFileYubiKeyProtected, m_yubiKeyProtectedDbData)); + sourceDbFileYubiKeyProtected.close(); + + // Load the NewDatabase.xml file into temporary storage + QFile sourceXmlFile(QString(KEEPASSX_TEST_DATA_DIR).append("/NewDatabase.xml")); + QVERIFY(sourceXmlFile.open(QIODevice::ReadOnly)); + QVERIFY(Tools::readAllFromDevice(&sourceXmlFile, m_xmlData)); + sourceXmlFile.close(); +} + +void TestCli::init() +{ + m_dbFile.reset(new TemporaryFile()); + m_dbFile->open(); + m_dbFile->write(m_dbData); + m_dbFile->close(); + + m_dbFile2.reset(new TemporaryFile()); + m_dbFile2->open(); + m_dbFile2->write(m_dbData2); + m_dbFile2->close(); + + m_xmlFile.reset(new TemporaryFile()); + m_xmlFile->open(); + m_xmlFile->write(m_xmlData); + m_xmlFile->close(); + + m_keyFileProtectedDbFile.reset(new TemporaryFile()); + m_keyFileProtectedDbFile->open(); + m_keyFileProtectedDbFile->write(m_keyFileProtectedDbData); + m_keyFileProtectedDbFile->close(); + + m_keyFileProtectedNoPasswordDbFile.reset(new TemporaryFile()); + m_keyFileProtectedNoPasswordDbFile->open(); + m_keyFileProtectedNoPasswordDbFile->write(m_keyFileProtectedNoPasswordDbData); + m_keyFileProtectedNoPasswordDbFile->close(); + + m_yubiKeyProtectedDbFile.reset(new TemporaryFile()); + m_yubiKeyProtectedDbFile->open(); + m_yubiKeyProtectedDbFile->write(m_yubiKeyProtectedDbData); + m_yubiKeyProtectedDbFile->close(); + + m_stdinFile.reset(new TemporaryFile()); + m_stdinFile->open(); + m_stdinHandle = fdopen(m_stdinFile->handle(), "r+"); + Utils::STDIN = m_stdinHandle; + + m_stdoutFile.reset(new TemporaryFile()); + m_stdoutFile->open(); + m_stdoutHandle = fdopen(m_stdoutFile->handle(), "r+"); + Utils::STDOUT = m_stdoutHandle; + + m_stderrFile.reset(new TemporaryFile()); + m_stderrFile->open(); + m_stderrHandle = fdopen(m_stderrFile->handle(), "r+"); + Utils::STDERR = m_stderrHandle; +} + +void TestCli::cleanup() +{ + m_dbFile.reset(); + + m_dbFile2.reset(); + + m_stdinFile.reset(); + m_stdinHandle = stdin; + Utils::STDIN = stdin; + + m_stdoutFile.reset(); + Utils::STDOUT = stdout; + m_stdoutHandle = stdout; + + m_stderrFile.reset(); + m_stderrHandle = stderr; + Utils::STDERR = stderr; +} + +void TestCli::cleanupTestCase() +{ +} + +QSharedPointer TestCli::readTestDatabase() const +{ + Utils::Test::setNextPassword("a"); + auto db = QSharedPointer(Utils::unlockDatabase(m_dbFile->fileName(), true, "", "", m_stdoutHandle)); + m_stdoutFile->seek(ftell(m_stdoutHandle)); // re-synchronize handles + return db; +} + +void TestCli::testBatchCommands() +{ + Commands::setupCommands(false); + QVERIFY(Commands::getCommand("add")); + QVERIFY(Commands::getCommand("analyze")); + QVERIFY(Commands::getCommand("clip")); + QVERIFY(Commands::getCommand("close")); + QVERIFY(Commands::getCommand("create")); + QVERIFY(Commands::getCommand("diceware")); + QVERIFY(Commands::getCommand("edit")); + QVERIFY(Commands::getCommand("estimate")); + QVERIFY(Commands::getCommand("export")); + QVERIFY(Commands::getCommand("generate")); + QVERIFY(Commands::getCommand("help")); + QVERIFY(Commands::getCommand("import")); + QVERIFY(Commands::getCommand("locate")); + QVERIFY(Commands::getCommand("ls")); + QVERIFY(Commands::getCommand("merge")); + QVERIFY(Commands::getCommand("mkdir")); + QVERIFY(Commands::getCommand("mv")); + QVERIFY(Commands::getCommand("open")); + QVERIFY(Commands::getCommand("rm")); + QVERIFY(Commands::getCommand("rmdir")); + QVERIFY(Commands::getCommand("show")); + QVERIFY(!Commands::getCommand("doesnotexist")); + QCOMPARE(Commands::getCommands().size(), 21); +} + +void TestCli::testInteractiveCommands() +{ + Commands::setupCommands(true); + QVERIFY(Commands::getCommand("add")); + QVERIFY(Commands::getCommand("analyze")); + QVERIFY(Commands::getCommand("clip")); + QVERIFY(Commands::getCommand("close")); + QVERIFY(Commands::getCommand("create")); + QVERIFY(Commands::getCommand("diceware")); + QVERIFY(Commands::getCommand("edit")); + QVERIFY(Commands::getCommand("estimate")); + QVERIFY(Commands::getCommand("exit")); + QVERIFY(Commands::getCommand("generate")); + QVERIFY(Commands::getCommand("help")); + QVERIFY(Commands::getCommand("locate")); + QVERIFY(Commands::getCommand("ls")); + QVERIFY(Commands::getCommand("merge")); + QVERIFY(Commands::getCommand("mkdir")); + QVERIFY(Commands::getCommand("mv")); + QVERIFY(Commands::getCommand("open")); + QVERIFY(Commands::getCommand("quit")); + QVERIFY(Commands::getCommand("rm")); + QVERIFY(Commands::getCommand("rmdir")); + QVERIFY(Commands::getCommand("show")); + QVERIFY(!Commands::getCommand("doesnotexist")); + QCOMPARE(Commands::getCommands().size(), 21); +} + +void TestCli::testAdd() +{ + Add addCmd; + QVERIFY(!addCmd.name.isEmpty()); + QVERIFY(addCmd.getDescriptionLine().contains(addCmd.name)); + + Utils::Test::setNextPassword("a"); + addCmd.execute({"add", + "-u", + "newuser", + "--url", + "https://example.com/", + "-g", + "-L", + "20", + m_dbFile->fileName(), + "/newuser-entry"}); + m_stderrFile->reset(); + m_stdoutFile->reset(); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stderrFile->readAll(), QByteArray("")); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully added entry newuser-entry.\n")); + + auto db = readTestDatabase(); + auto* entry = db->rootGroup()->findEntryByPath("/newuser-entry"); + QVERIFY(entry); + QCOMPARE(entry->username(), QString("newuser")); + QCOMPARE(entry->url(), QString("https://example.com/")); + QCOMPARE(entry->password().size(), 20); + + // Quiet option + qint64 pos = m_stdoutFile->pos(); + qint64 posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + addCmd.execute({"add", "-q", "-u", "newuser", "-g", "-L", "20", m_dbFile->fileName(), "/newentry-quiet"}); + m_stdoutFile->seek(pos); + m_stderrFile->seek(posErr); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + QCOMPARE(m_stderrFile->readAll(), QByteArray("")); + db = readTestDatabase(); + entry = db->rootGroup()->findEntryByPath("/newentry-quiet"); + QVERIFY(entry); + QCOMPARE(entry->password().size(), 20); + + Utils::Test::setNextPassword("a"); + Utils::Test::setNextPassword("newpassword"); + addCmd.execute( + {"add", "-u", "newuser2", "--url", "https://example.net/", "-p", m_dbFile->fileName(), "/newuser-entry2"}); + + db = readTestDatabase(); + entry = db->rootGroup()->findEntryByPath("/newuser-entry2"); + QVERIFY(entry); + QCOMPARE(entry->username(), QString("newuser2")); + QCOMPARE(entry->url(), QString("https://example.net/")); + QCOMPARE(entry->password(), QString("newpassword")); + + // Password generation options + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + addCmd.execute({"add", "-u", "newuser3", "-g", "-L", "34", m_dbFile->fileName(), "/newuser-entry3"}); + m_stdoutFile->seek(pos); + m_stderrFile->seek(posErr); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully added entry newuser-entry3.\n")); + QCOMPARE(m_stderrFile->readAll(), QByteArray("")); + + db = readTestDatabase(); + entry = db->rootGroup()->findEntryByPath("/newuser-entry3"); + QVERIFY(entry); + QCOMPARE(entry->username(), QString("newuser3")); + QCOMPARE(entry->password().size(), 34); + QRegularExpression defaultPasswordClassesRegex("^[a-zA-Z0-9]+$"); + QVERIFY(defaultPasswordClassesRegex.match(entry->password()).hasMatch()); + + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + addCmd.execute({"add", + "-u", + "newuser4", + "-g", + "-L", + "20", + "--every-group", + "-s", + "-n", + "-U", + "-l", + m_dbFile->fileName(), + "/newuser-entry4"}); + m_stdoutFile->seek(pos); + m_stderrFile->seek(posErr); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully added entry newuser-entry4.\n")); + QCOMPARE(m_stderrFile->readAll(), QByteArray("")); + + db = readTestDatabase(); + entry = db->rootGroup()->findEntryByPath("/newuser-entry4"); + QVERIFY(entry); + QCOMPARE(entry->username(), QString("newuser4")); + QCOMPARE(entry->password().size(), 20); + QVERIFY(!defaultPasswordClassesRegex.match(entry->password()).hasMatch()); +} + +void TestCli::testAddGroup() +{ + AddGroup addGroupCmd; + QVERIFY(!addGroupCmd.name.isEmpty()); + QVERIFY(addGroupCmd.getDescriptionLine().contains(addGroupCmd.name)); + + Utils::Test::setNextPassword("a"); + addGroupCmd.execute({"mkdir", m_dbFile->fileName(), "/new_group"}); + m_stderrFile->reset(); + m_stdoutFile->reset(); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stderrFile->readAll(), QByteArray("")); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully added group new_group.\n")); + + auto db = readTestDatabase(); + auto* group = db->rootGroup()->findGroupByPath("new_group"); + QVERIFY(group); + QCOMPARE(group->name(), QString("new_group")); + + // Trying to add the same group should fail. + qint64 pos = m_stdoutFile->pos(); + qint64 posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + addGroupCmd.execute({"mkdir", m_dbFile->fileName(), "/new_group"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->seek(posErr); + QCOMPARE(m_stderrFile->readAll(), QByteArray("Group /new_group already exists!\n")); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + + // Should be able to add groups down the tree. + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + addGroupCmd.execute({"mkdir", m_dbFile->fileName(), "/new_group/newer_group"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->seek(posErr); + QCOMPARE(m_stderrFile->readAll(), QByteArray("")); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully added group newer_group.\n")); + + db = readTestDatabase(); + group = db->rootGroup()->findGroupByPath("new_group/newer_group"); + QVERIFY(group); + QCOMPARE(group->name(), QString("newer_group")); + + // Should fail if the path is invalid. + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + addGroupCmd.execute({"mkdir", m_dbFile->fileName(), "/invalid_group/newer_group"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->seek(posErr); + QCOMPARE(m_stderrFile->readAll(), QByteArray("Group /invalid_group not found.\n")); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + + // Should fail to add the root group. + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + addGroupCmd.execute({"mkdir", m_dbFile->fileName(), "/"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->seek(posErr); + QCOMPARE(m_stderrFile->readAll(), QByteArray("Group / already exists!\n")); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); +} + +bool isTOTP(const QString& value) +{ + QString val = value.trimmed(); + if (val.length() < 5 || val.length() > 6) { + return false; + } + for (int i = 0; i < val.length(); ++i) { + if (!value[i].isDigit()) { + return false; + } + } + return true; +} + +void TestCli::testAnalyze() +{ + Analyze analyzeCmd; + QVERIFY(!analyzeCmd.name.isEmpty()); + QVERIFY(analyzeCmd.getDescriptionLine().contains(analyzeCmd.name)); + + const QString hibpPath = QString(KEEPASSX_TEST_DATA_DIR).append("/hibp.txt"); + + Utils::Test::setNextPassword("a"); + analyzeCmd.execute({"analyze", "--hibp", hibpPath, m_dbFile->fileName()}); + m_stdoutFile->reset(); + m_stdoutFile->readLine(); // skip password prompt + auto output = m_stdoutFile->readAll(); + QVERIFY(output.contains("Sample Entry") && output.contains("123")); +} + +void TestCli::testClip() +{ + QClipboard* clipboard = QGuiApplication::clipboard(); + clipboard->clear(); + + Clip clipCmd; + QVERIFY(!clipCmd.name.isEmpty()); + QVERIFY(clipCmd.getDescriptionLine().contains(clipCmd.name)); + + // Password + Utils::Test::setNextPassword("a"); + clipCmd.execute({"clip", m_dbFile->fileName(), "/Sample Entry"}); + + m_stderrFile->reset(); + m_stdoutFile->reset(); + QString errorOutput(m_stderrFile->readAll()); + + if (errorOutput.contains("Unable to start program") + || errorOutput.contains("No program defined for clipboard manipulation")) { + QSKIP("Clip test skipped due to missing clipboard tool"); + } + + QCOMPARE(clipboard->text(), QString("Password")); + m_stdoutFile->readLine(); // skip prompt line + QCOMPARE(m_stdoutFile->readLine(), QByteArray("Entry's password copied to the clipboard!\n")); + + // Quiet option + qint64 pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + clipCmd.execute({"clip", m_dbFile->fileName(), "/Sample Entry", "-q"}); + m_stdoutFile->seek(pos); + // Output should be empty when quiet option is set. + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + QCOMPARE(clipboard->text(), QString("Password")); + + // TOTP + Utils::Test::setNextPassword("a"); + clipCmd.execute({"clip", m_dbFile->fileName(), "/Sample Entry", "--totp"}); + + QVERIFY(isTOTP(clipboard->text())); + + // Password with timeout + Utils::Test::setNextPassword("a"); + // clang-format off + QFuture future = QtConcurrent::run(&clipCmd, + static_cast(&DatabaseCommand::execute), + QStringList{"clip", m_dbFile->fileName(), "/Sample Entry", "1"}); + // clang-format on + + QTRY_COMPARE_WITH_TIMEOUT(clipboard->text(), QString("Password"), 500); + QTRY_COMPARE_WITH_TIMEOUT(clipboard->text(), QString(""), 1500); + + future.waitForFinished(); + + // TOTP with timeout + Utils::Test::setNextPassword("a"); + future = QtConcurrent::run(&clipCmd, + static_cast(&DatabaseCommand::execute), + QStringList{"clip", m_dbFile->fileName(), "/Sample Entry", "1", "-t"}); + + QTRY_VERIFY_WITH_TIMEOUT(isTOTP(clipboard->text()), 500); + QTRY_COMPARE_WITH_TIMEOUT(clipboard->text(), QString(""), 1500); + + future.waitForFinished(); + + qint64 posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + clipCmd.execute({"clip", m_dbFile->fileName(), "--totp", "/Sample Entry", "0"}); + m_stderrFile->seek(posErr); + QCOMPARE(m_stderrFile->readAll(), QByteArray("Invalid timeout value 0.\n")); + + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + clipCmd.execute({"clip", m_dbFile->fileName(), "--totp", "/Sample Entry", "bleuh"}); + m_stderrFile->seek(posErr); + QCOMPARE(m_stderrFile->readAll(), QByteArray("Invalid timeout value bleuh.\n")); + + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + clipCmd.execute({"clip", m_dbFile2->fileName(), "--totp", "/Sample Entry"}); + m_stderrFile->seek(posErr); + QCOMPARE(m_stderrFile->readAll(), QByteArray("Entry with path /Sample Entry has no TOTP set up.\n")); +} + +void TestCli::testCreate() +{ + Create createCmd; + QVERIFY(!createCmd.name.isEmpty()); + QVERIFY(createCmd.getDescriptionLine().contains(createCmd.name)); + + QScopedPointer testDir(new QTemporaryDir()); + + QString databaseFilename = testDir->path() + "testCreate1.kdbx"; + // Password + Utils::Test::setNextPassword("a"); + createCmd.execute({"create", databaseFilename}); + + m_stderrFile->reset(); + m_stdoutFile->reset(); + + QCOMPARE(m_stdoutFile->readLine(), + QByteArray("Enter password to encrypt database (optional): \n")); + QCOMPARE(m_stdoutFile->readLine(), QByteArray("Successfully created new database.\n")); + + Utils::Test::setNextPassword("a"); + auto db = QSharedPointer(Utils::unlockDatabase(databaseFilename, true, "", "", Utils::DEVNULL)); + QVERIFY(db); + + // Should refuse to create the database if it already exists. + qint64 pos = m_stdoutFile->pos(); + qint64 errPos = m_stderrFile->pos(); + createCmd.execute({"create", databaseFilename}); + m_stdoutFile->seek(pos); + m_stderrFile->seek(errPos); + // Output should be empty when there is an error. + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + QString errorMessage = QString("File " + databaseFilename + " already exists.\n"); + QCOMPARE(m_stderrFile->readAll(), errorMessage.toUtf8()); + + // Testing with keyfile creation + QString databaseFilename2 = testDir->path() + "testCreate2.kdbx"; + QString keyfilePath = testDir->path() + "keyfile.txt"; + pos = m_stdoutFile->pos(); + errPos = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + createCmd.execute({"create", databaseFilename2, "-k", keyfilePath}); + m_stdoutFile->seek(pos); + m_stderrFile->seek(errPos); + + QCOMPARE(m_stdoutFile->readLine(), + QByteArray("Enter password to encrypt database (optional): \n")); + QCOMPARE(m_stdoutFile->readLine(), QByteArray("Successfully created new database.\n")); + + Utils::Test::setNextPassword("a"); + auto db2 = + QSharedPointer(Utils::unlockDatabase(databaseFilename2, true, keyfilePath, "", Utils::DEVNULL)); + QVERIFY(db2); + + // Testing with existing keyfile + QString databaseFilename3 = testDir->path() + "testCreate3.kdbx"; + pos = m_stdoutFile->pos(); + errPos = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + createCmd.execute({"create", databaseFilename3, "-k", keyfilePath}); + m_stdoutFile->seek(pos); + m_stderrFile->seek(errPos); + + QCOMPARE(m_stdoutFile->readLine(), + QByteArray("Enter password to encrypt database (optional): \n")); + QCOMPARE(m_stdoutFile->readLine(), QByteArray("Successfully created new database.\n")); + + Utils::Test::setNextPassword("a"); + auto db3 = + QSharedPointer(Utils::unlockDatabase(databaseFilename3, true, keyfilePath, "", Utils::DEVNULL)); + QVERIFY(db3); +} + +void TestCli::testDiceware() +{ + Diceware dicewareCmd; + QVERIFY(!dicewareCmd.name.isEmpty()); + QVERIFY(dicewareCmd.getDescriptionLine().contains(dicewareCmd.name)); + + dicewareCmd.execute({"diceware"}); + m_stdoutFile->reset(); + QString passphrase(m_stdoutFile->readLine()); + QVERIFY(!passphrase.isEmpty()); + + dicewareCmd.execute({"diceware", "-W", "2"}); + m_stdoutFile->seek(passphrase.toLatin1().size()); + passphrase = m_stdoutFile->readLine(); + QCOMPARE(passphrase.split(" ").size(), 2); + + auto pos = m_stdoutFile->pos(); + dicewareCmd.execute({"diceware", "-W", "10"}); + m_stdoutFile->seek(pos); + passphrase = m_stdoutFile->readLine(); + QCOMPARE(passphrase.split(" ").size(), 10); + + // Testing with invalid word count + auto posErr = m_stderrFile->pos(); + dicewareCmd.execute({"diceware", "-W", "-10"}); + m_stderrFile->seek(posErr); + QCOMPARE(m_stderrFile->readLine(), QByteArray("Invalid word count -10\n")); + + // Testing with invalid word count format + posErr = m_stderrFile->pos(); + dicewareCmd.execute({"diceware", "-W", "bleuh"}); + m_stderrFile->seek(posErr); + QCOMPARE(m_stderrFile->readLine(), QByteArray("Invalid word count bleuh\n")); + + TemporaryFile wordFile; + wordFile.open(); + for (int i = 0; i < 4500; ++i) { + wordFile.write(QString("word" + QString::number(i) + "\n").toLatin1()); + } + wordFile.close(); + + pos = m_stdoutFile->pos(); + dicewareCmd.execute({"diceware", "-W", "11", "-w", wordFile.fileName()}); + m_stdoutFile->seek(pos); + passphrase = m_stdoutFile->readLine(); + const auto words = passphrase.split(" "); + QCOMPARE(words.size(), 11); + QRegularExpression regex("^word\\d+$"); + for (const auto& word : words) { + QVERIFY2(regex.match(word).hasMatch(), qPrintable("Word " + word + " was not on the word list")); + } + + TemporaryFile smallWordFile; + smallWordFile.open(); + for (int i = 0; i < 50; ++i) { + smallWordFile.write(QString("word" + QString::number(i) + "\n").toLatin1()); + } + smallWordFile.close(); + + posErr = m_stderrFile->pos(); + dicewareCmd.execute({"diceware", "-W", "11", "-w", smallWordFile.fileName()}); + m_stderrFile->seek(posErr); + QCOMPARE(m_stderrFile->readLine(), QByteArray("The word list is too small (< 1000 items)\n")); +} + +void TestCli::testEdit() +{ + Edit editCmd; + QVERIFY(!editCmd.name.isEmpty()); + QVERIFY(editCmd.getDescriptionLine().contains(editCmd.name)); + + Utils::Test::setNextPassword("a"); + // clang-format off + editCmd.execute({"edit", "-u", "newuser", "--url", "https://otherurl.example.com/", "-t", "newtitle", m_dbFile->fileName(), "/Sample Entry"}); + // clang-format on + m_stdoutFile->reset(); + m_stdoutFile->readLine(); // skip prompt line + QCOMPARE(m_stdoutFile->readLine(), QByteArray("Successfully edited entry newtitle.\n")); + + auto db = readTestDatabase(); + auto* entry = db->rootGroup()->findEntryByPath("/newtitle"); + QVERIFY(entry); + QCOMPARE(entry->username(), QString("newuser")); + QCOMPARE(entry->url(), QString("https://otherurl.example.com/")); + QCOMPARE(entry->password(), QString("Password")); + + // Quiet option + qint64 pos = m_stdoutFile->pos(); + qint64 posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + editCmd.execute({"edit", m_dbFile->fileName(), "-q", "-t", "newertitle", "/newtitle"}); + m_stdoutFile->seek(pos); + m_stderrFile->seek(posErr); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + QCOMPARE(m_stderrFile->readAll(), QByteArray("")); + + Utils::Test::setNextPassword("a"); + editCmd.execute({"edit", "-g", m_dbFile->fileName(), "/newertitle"}); + db = readTestDatabase(); + entry = db->rootGroup()->findEntryByPath("/newertitle"); + QVERIFY(entry); + QCOMPARE(entry->username(), QString("newuser")); + QCOMPARE(entry->url(), QString("https://otherurl.example.com/")); + QVERIFY(!entry->password().isEmpty()); + QVERIFY(entry->password() != QString("Password")); + + Utils::Test::setNextPassword("a"); + editCmd.execute({"edit", "-g", "-L", "34", "-t", "evennewertitle", m_dbFile->fileName(), "/newertitle"}); + db = readTestDatabase(); + entry = db->rootGroup()->findEntryByPath("/evennewertitle"); + QVERIFY(entry); + QCOMPARE(entry->username(), QString("newuser")); + QCOMPARE(entry->url(), QString("https://otherurl.example.com/")); + QVERIFY(entry->password() != QString("Password")); + QCOMPARE(entry->password().size(), 34); + QRegularExpression defaultPasswordClassesRegex("^[a-zA-Z0-9]+$"); + QVERIFY(defaultPasswordClassesRegex.match(entry->password()).hasMatch()); + + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + editCmd.execute({"edit", + "-g", + "-L", + "20", + "--every-group", + "-s", + "-n", + "--upper", + "-l", + m_dbFile->fileName(), + "/evennewertitle"}); + m_stdoutFile->seek(pos); + m_stderrFile->seek(posErr); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stderrFile->readAll(), QByteArray("")); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully edited entry evennewertitle.\n")); + + db = readTestDatabase(); + entry = db->rootGroup()->findEntryByPath("/evennewertitle"); + QVERIFY(entry); + QCOMPARE(entry->password().size(), 20); + QVERIFY(!defaultPasswordClassesRegex.match(entry->password()).hasMatch()); + + Utils::Test::setNextPassword("a"); + Utils::Test::setNextPassword("newpassword"); + editCmd.execute({"edit", "-p", m_dbFile->fileName(), "/evennewertitle"}); + db = readTestDatabase(); + entry = db->rootGroup()->findEntryByPath("/evennewertitle"); + QVERIFY(entry); + QCOMPARE(entry->password(), QString("newpassword")); +} + +void TestCli::testEstimate_data() +{ + QTest::addColumn("input"); + QTest::addColumn("length"); + QTest::addColumn("entropy"); + QTest::addColumn("log10"); + QTest::addColumn("searchStrings"); + + QTest::newRow("Dictionary") << "password" + << "8" + << "1.0" + << "0.3" << QStringList{"Type: Dictionary", "\tpassword"}; + + QTest::newRow("Spatial") << "zxcv" + << "4" + << "10.3" + << "3.1" << QStringList{"Type: Spatial", "\tzxcv"}; + + QTest::newRow("Spatial(Rep)") << "sdfgsdfg" + << "8" + << "11.3" + << "3.4" << QStringList{"Type: Spatial(Rep)", "\tsdfgsdfg"}; + + QTest::newRow("Dictionary / Sequence") + << "password123" + << "11" + << "4.5" + << "1.3" << QStringList{"Type: Dictionary", "Type: Sequence", "\tpassword", "\t123"}; + + QTest::newRow("Dict+Leet") << "p455w0rd" + << "8" + << "2.5" + << "0.7" << QStringList{"Type: Dict+Leet", "\tp455w0rd"}; + + QTest::newRow("Dictionary(Rep)") << "hellohello" + << "10" + << "7.3" + << "2.2" << QStringList{"Type: Dictionary(Rep)", "\thellohello"}; + + QTest::newRow("Sequence(Rep) / Dictionary") + << "456456foobar" + << "12" + << "16.7" + << "5.0" << QStringList{"Type: Sequence(Rep)", "Type: Dictionary", "\t456456", "\tfoobar"}; + + QTest::newRow("Bruteforce(Rep) / Bruteforce") + << "xzxzy" + << "5" + << "16.1" + << "4.8" << QStringList{"Type: Bruteforce(Rep)", "Type: Bruteforce", "\txzxz", "\ty"}; + + QTest::newRow("Dictionary / Date(Rep)") + << "pass20182018" + << "12" + << "15.1" + << "4.56" << QStringList{"Type: Dictionary", "Type: Date(Rep)", "\tpass", "\t20182018"}; + + QTest::newRow("Dictionary / Date / Bruteforce") + << "mypass2018-2" + << "12" + << "32.9" + << "9.9" << QStringList{"Type: Dictionary", "Type: Date", "Type: Bruteforce", "\tmypass", "\t2018", "\t-2"}; + + QTest::newRow("Strong Password") << "E*!%.Qw{t.X,&bafw)\"Q!ah$%;U/" + << "28" + << "165.7" + << "49.8" << QStringList{"Type: Bruteforce", "\tE*"}; + + // TODO: detect passphrases and adjust entropy calculation accordingly (issue #2347) + QTest::newRow("Strong Passphrase") + << "squint wooing resupply dangle isolation axis headsman" + << "53" + << "151.2" + << "45.5" + << QStringList{ + "Type: Dictionary", "Type: Bruteforce", "Multi-word extra bits 22.0", "\tsquint", "\t ", "\twooing"}; +} + +void TestCli::testEstimate() +{ + QFETCH(QString, input); + QFETCH(QString, length); + QFETCH(QString, entropy); + QFETCH(QString, log10); + QFETCH(QStringList, searchStrings); + + Estimate estimateCmd; + QVERIFY(!estimateCmd.name.isEmpty()); + QVERIFY(estimateCmd.getDescriptionLine().contains(estimateCmd.name)); + + QTextStream in(m_stdinFile.data()); + QTextStream out(m_stdoutFile.data()); + + in << input << endl; + in.seek(0); + estimateCmd.execute({"estimate", "-a"}); + out.seek(0); + auto result = out.readAll(); + QVERIFY(result.contains("Length " + length)); + QVERIFY(result.contains("Entropy " + entropy)); + QVERIFY(result.contains("Log10 " + log10)); + for (const auto& string : asConst(searchStrings)) { + QVERIFY2(result.contains(string), qPrintable("String " + string + " missing")); + } +} + +void TestCli::testExport() +{ + Export exportCmd; + QVERIFY(!exportCmd.name.isEmpty()); + QVERIFY(exportCmd.getDescriptionLine().contains(exportCmd.name)); + + Utils::Test::setNextPassword("a"); + exportCmd.execute({"export", m_dbFile->fileName()}); + + m_stdoutFile->seek(0); + m_stdoutFile->readLine(); // skip prompt line + + KdbxXmlReader reader(KeePass2::FILE_VERSION_3_1); + QScopedPointer db(new Database()); + reader.readDatabase(m_stdoutFile.data(), db.data()); + QVERIFY(!reader.hasError()); + QVERIFY(db.data()); + auto* entry = db->rootGroup()->findEntryByPath("/Sample Entry"); + QVERIFY(entry); + QCOMPARE(entry->password(), QString("Password")); + + m_stdoutFile->reset(); + + // Quiet option + QScopedPointer dbQuiet(new Database()); + qint64 pos = m_stdoutFile->pos(); + qint64 posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + exportCmd.execute({"export", "-f", "xml", "-q", m_dbFile->fileName()}); + m_stdoutFile->seek(pos); + m_stderrFile->seek(posErr); + reader.readDatabase(m_stdoutFile.data(), dbQuiet.data()); + QVERIFY(!reader.hasError()); + QVERIFY(db.data()); + QCOMPARE(m_stderrFile->readAll(), QByteArray("")); + + // CSV exporting + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + exportCmd.execute({"export", "-f", "csv", m_dbFile->fileName()}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip prompt line + m_stderrFile->seek(posErr); + QByteArray csvHeader = m_stdoutFile->readLine(); + QCOMPARE(csvHeader, QByteArray("\"Group\",\"Title\",\"Username\",\"Password\",\"URL\",\"Notes\"\n")); + QByteArray csvData = m_stdoutFile->readAll(); + QVERIFY(csvData.contains(QByteArray( + "\"NewDatabase\",\"Sample Entry\",\"User Name\",\"Password\",\"http://www.somesite.com/\",\"Notes\"\n"))); + QCOMPARE(m_stderrFile->readAll(), QByteArray("")); + + // test invalid format + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + exportCmd.execute({"export", "-f", "yaml", m_dbFile->fileName()}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip prompt line + m_stderrFile->seek(posErr); + QCOMPARE(m_stdoutFile->readLine(), QByteArray("")); + QCOMPARE(m_stderrFile->readLine(), QByteArray("Unsupported format yaml\n")); +} + +void TestCli::testGenerate_data() +{ + QTest::addColumn("parameters"); + QTest::addColumn("pattern"); + + QTest::newRow("default") << QStringList{"generate"} << "^[^\r\n]+$"; + QTest::newRow("length") << QStringList{"generate", "-L", "13"} << "^.{13}$"; + QTest::newRow("lowercase") << QStringList{"generate", "-L", "14", "-l"} << "^[a-z]{14}$"; + QTest::newRow("uppercase") << QStringList{"generate", "-L", "15", "--upper"} << "^[A-Z]{15}$"; + QTest::newRow("numbers") << QStringList{"generate", "-L", "16", "-n"} << "^[0-9]{16}$"; + QTest::newRow("special") << QStringList{"generate", "-L", "200", "-s"} + << R"(^[\(\)\[\]\{\}\.\-*|\\,:;"'\/\_!+-<=>?#$%&^`@~]{200}$)"; + QTest::newRow("special (exclude)") << QStringList{"generate", "-L", "200", "-s", "-x", "+.?@&"} + << R"(^[\(\)\[\]\{\}\.\-*|\\,:;"'\/\_!-<=>#$%^`~]{200}$)"; + QTest::newRow("extended") << QStringList{"generate", "-L", "50", "-e"} + << R"(^[^a-zA-Z0-9\(\)\[\]\{\}\.\-\*\|\\,:;"'\/\_!+-<=>?#$%&^`@~]{50}$)"; + QTest::newRow("numbers + lowercase + uppercase") + << QStringList{"generate", "-L", "16", "-n", "--upper", "-l"} << "^[0-9a-zA-Z]{16}$"; + QTest::newRow("numbers + lowercase + uppercase (exclude)") + << QStringList{"generate", "-L", "500", "-n", "-U", "-l", "-x", "abcdefg0123@"} << "^[^abcdefg0123@]{500}$"; + QTest::newRow("numbers + lowercase + uppercase (exclude similar)") + << QStringList{"generate", "-L", "200", "-n", "-U", "-l", "--exclude-similar"} << "^[^l1IO0]{200}$"; + QTest::newRow("uppercase + lowercase (every)") + << QStringList{"generate", "-L", "2", "--upper", "-l", "--every-group"} << "^[a-z][A-Z]|[A-Z][a-z]$"; + QTest::newRow("numbers + lowercase (every)") + << QStringList{"generate", "-L", "2", "-n", "-l", "--every-group"} << "^[a-z][0-9]|[0-9][a-z]$"; +} + +void TestCli::testGenerate() +{ + QFETCH(QStringList, parameters); + QFETCH(QString, pattern); + + Generate generateCmd; + QVERIFY(!generateCmd.name.isEmpty()); + QVERIFY(generateCmd.getDescriptionLine().contains(generateCmd.name)); + + qint64 pos = 0; + // run multiple times to make accidental passes unlikely + TextStream stream(m_stdoutFile.data()); + for (int i = 0; i < 10; ++i) { + generateCmd.execute(parameters); + stream.seek(pos); + QRegularExpression regex(pattern); + QString password = stream.readLine(); + pos = stream.pos(); + QVERIFY2(regex.match(password).hasMatch(), + qPrintable("Password " + password + " does not match pattern " + pattern)); + } + + // Testing with invalid password length + auto posErr = m_stderrFile->pos(); + generateCmd.execute({"generate", "-L", "-10"}); + m_stderrFile->seek(posErr); + QCOMPARE(m_stderrFile->readLine(), QByteArray("Invalid password length -10\n")); + + posErr = m_stderrFile->pos(); + generateCmd.execute({"generate", "-L", "0"}); + m_stderrFile->seek(posErr); + QCOMPARE(m_stderrFile->readLine(), QByteArray("Invalid password length 0\n")); + + // Testing with invalid word count format + posErr = m_stderrFile->pos(); + generateCmd.execute({"generate", "-L", "bleuh"}); + m_stderrFile->seek(posErr); + QCOMPARE(m_stderrFile->readLine(), QByteArray("Invalid password length bleuh\n")); +} + +void TestCli::testImport() +{ + Import importCmd; + QVERIFY(!importCmd.name.isEmpty()); + QVERIFY(importCmd.getDescriptionLine().contains(importCmd.name)); + + QScopedPointer testDir(new QTemporaryDir()); + QString databaseFilename = testDir->path() + "testImport1.kdbx"; + + Utils::Test::setNextPassword("a"); + importCmd.execute({"import", m_xmlFile->fileName(), databaseFilename}); + + m_stderrFile->reset(); + m_stdoutFile->reset(); + + QCOMPARE(m_stdoutFile->readLine(), QByteArray("Enter password to encrypt database (optional): \n")); + QCOMPARE(m_stdoutFile->readLine(), QByteArray("Successfully imported database.\n")); + + Utils::Test::setNextPassword("a"); + auto db = QSharedPointer(Utils::unlockDatabase(databaseFilename, true, "", "", Utils::DEVNULL)); + QVERIFY(db); + auto* entry = db->rootGroup()->findEntryByPath("/Sample Entry 1"); + QVERIFY(entry); + QCOMPARE(entry->username(), QString("User Name")); + + // Should refuse to create the database if it already exists. + qint64 pos = m_stdoutFile->pos(); + qint64 errPos = m_stderrFile->pos(); + importCmd.execute({"import", m_xmlFile->fileName(), databaseFilename}); + m_stdoutFile->seek(pos); + m_stderrFile->seek(errPos); + // Output should be empty when there is an error. + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + QString errorMessage = QString("File " + databaseFilename + " already exists.\n"); + QCOMPARE(m_stderrFile->readAll(), errorMessage.toUtf8()); + + // Quiet option + QScopedPointer testDirQuiet(new QTemporaryDir()); + QString databaseFilenameQuiet = testDirQuiet->path() + "testImport2.kdbx"; + + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + importCmd.execute({"import", "-q", m_xmlFile->fileName(), databaseFilenameQuiet}); + m_stdoutFile->seek(pos); + + QCOMPARE(m_stdoutFile->readAll(), + QByteArray("Enter password to encrypt database (optional): \n")); + + Utils::Test::setNextPassword("a"); + auto dbQuiet = QSharedPointer(Utils::unlockDatabase(databaseFilenameQuiet, true, "", "", Utils::DEVNULL)); + QVERIFY(dbQuiet); +} + +void TestCli::testKeyFileOption() +{ + List listCmd; + + QString keyFilePath(QString(KEEPASSX_TEST_DATA_DIR).append("/KeyFileProtected.key")); + Utils::Test::setNextPassword("a"); + listCmd.execute({"ls", "-k", keyFilePath, m_keyFileProtectedDbFile->fileName()}); + m_stdoutFile->reset(); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stdoutFile->readAll(), + QByteArray("entry1\n" + "entry2\n")); + + // Should raise an error with no key file. + qint64 pos = m_stdoutFile->pos(); + qint64 posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + listCmd.execute({"ls", m_keyFileProtectedDbFile->fileName()}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->seek(posErr); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + QVERIFY(m_stderrFile->readAll().contains("Invalid credentials were provided")); + + // Should raise an error if key file path is invalid. + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + listCmd.execute({"ls", "-k", "invalidpath", m_keyFileProtectedDbFile->fileName()}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->seek(posErr); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + QCOMPARE(m_stderrFile->readAll().split(':').at(0), QByteArray("Failed to load key file invalidpath")); +} + +void TestCli::testNoPasswordOption() +{ + List listCmd; + + QString keyFilePath(QString(KEEPASSX_TEST_DATA_DIR).append("/KeyFileProtectedNoPassword.key")); + listCmd.execute({"ls", "-k", keyFilePath, "--no-password", m_keyFileProtectedNoPasswordDbFile->fileName()}); + m_stdoutFile->reset(); + QCOMPARE(m_stdoutFile->readAll(), + QByteArray("entry1\n" + "entry2\n")); + + // Should raise an error with no key file. + qint64 pos = m_stdoutFile->pos(); + qint64 posErr = m_stderrFile->pos(); + listCmd.execute({"ls", "--no-password", m_keyFileProtectedNoPasswordDbFile->fileName()}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->seek(posErr); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + QVERIFY(m_stderrFile->readAll().contains("Invalid credentials were provided")); +} + +void TestCli::testList() +{ + List listCmd; + QVERIFY(!listCmd.name.isEmpty()); + QVERIFY(listCmd.getDescriptionLine().contains(listCmd.name)); + + Utils::Test::setNextPassword("a"); + listCmd.execute({"ls", m_dbFile->fileName()}); + m_stdoutFile->reset(); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stdoutFile->readAll(), + QByteArray("Sample Entry\n" + "General/\n" + "Windows/\n" + "Network/\n" + "Internet/\n" + "eMail/\n" + "Homebanking/\n")); + + // Quiet option + qint64 pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + listCmd.execute({"ls", "-q", m_dbFile->fileName()}); + m_stdoutFile->seek(pos); + QCOMPARE(m_stdoutFile->readAll(), + QByteArray("Sample Entry\n" + "General/\n" + "Windows/\n" + "Network/\n" + "Internet/\n" + "eMail/\n" + "Homebanking/\n")); + + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + listCmd.execute({"ls", "-R", m_dbFile->fileName()}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stdoutFile->readAll(), + QByteArray("Sample Entry\n" + "General/\n" + " [empty]\n" + "Windows/\n" + " [empty]\n" + "Network/\n" + " [empty]\n" + "Internet/\n" + " [empty]\n" + "eMail/\n" + " [empty]\n" + "Homebanking/\n" + " Subgroup/\n" + " Subgroup Entry\n")); + + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + listCmd.execute({"ls", "-R", "-f", m_dbFile->fileName()}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stdoutFile->readAll(), + QByteArray("Sample Entry\n" + "General/\n" + "General/[empty]\n" + "Windows/\n" + "Windows/[empty]\n" + "Network/\n" + "Network/[empty]\n" + "Internet/\n" + "Internet/[empty]\n" + "eMail/\n" + "eMail/[empty]\n" + "Homebanking/\n" + "Homebanking/Subgroup/\n" + "Homebanking/Subgroup/Subgroup Entry\n")); + + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + listCmd.execute({"ls", "-R", "-f", m_dbFile->fileName(), "/Homebanking"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stdoutFile->readAll(), + QByteArray("Subgroup/\n" + "Subgroup/Subgroup Entry\n")); + + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + listCmd.execute({"ls", m_dbFile->fileName(), "/General/"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("[empty]\n")); + + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + listCmd.execute({"ls", m_dbFile->fileName(), "/DoesNotExist/"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->reset(); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + QCOMPARE(m_stderrFile->readAll(), QByteArray("Cannot find group /DoesNotExist/.\n")); +} + +void TestCli::testLocate() +{ + Locate locateCmd; + QVERIFY(!locateCmd.name.isEmpty()); + QVERIFY(locateCmd.getDescriptionLine().contains(locateCmd.name)); + + Utils::Test::setNextPassword("a"); + locateCmd.execute({"locate", m_dbFile->fileName(), "Sample"}); + m_stdoutFile->reset(); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stdoutFile->readAll(), QByteArray("/Sample Entry\n")); + + // Quiet option + qint64 pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + locateCmd.execute({"locate", m_dbFile->fileName(), "-q", "Sample"}); + m_stdoutFile->seek(pos); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("/Sample Entry\n")); + + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + locateCmd.execute({"locate", m_dbFile->fileName(), "Does Not Exist"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->reset(); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + QCOMPARE(m_stderrFile->readAll(), QByteArray("No results for that search term.\n")); + + // write a modified database + auto db = readTestDatabase(); + QVERIFY(db); + auto* group = db->rootGroup()->findGroupByPath("/General/"); + QVERIFY(group); + auto* entry = new Entry(); + entry->setUuid(QUuid::createUuid()); + entry->setTitle("New Entry"); + group->addEntry(entry); + TemporaryFile tmpFile; + tmpFile.open(); + Kdbx4Writer writer; + writer.writeDatabase(&tmpFile, db.data()); + tmpFile.close(); + + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + locateCmd.execute({"locate", tmpFile.fileName(), "New"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stdoutFile->readAll(), QByteArray("/General/New Entry\n")); + + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + locateCmd.execute({"locate", tmpFile.fileName(), "Entry"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stdoutFile->readAll(), + QByteArray("/Sample Entry\n/General/New Entry\n/Homebanking/Subgroup/Subgroup Entry\n")); +} + +void TestCli::testMerge() +{ + Merge mergeCmd; + QVERIFY(!mergeCmd.name.isEmpty()); + QVERIFY(mergeCmd.getDescriptionLine().contains(mergeCmd.name)); + + Kdbx4Writer writer; + Kdbx4Reader reader; + + // load test database and save copies + auto db = readTestDatabase(); + QVERIFY(db); + TemporaryFile targetFile1; + targetFile1.open(); + writer.writeDatabase(&targetFile1, db.data()); + targetFile1.close(); + TemporaryFile targetFile2; + targetFile2.open(); + writer.writeDatabase(&targetFile2, db.data()); + targetFile2.close(); + + // save another copy with a different password + TemporaryFile targetFile3; + targetFile3.open(); + auto oldKey = db->key(); + auto key = QSharedPointer::create(); + key->addKey(QSharedPointer::create("b")); + db->setKey(key); + writer.writeDatabase(&targetFile3, db.data()); + targetFile3.close(); + db->setKey(oldKey); + + // then add a new entry to the in-memory database and save another copy + auto* entry = new Entry(); + entry->setUuid(QUuid::createUuid()); + entry->setTitle("Some Website"); + entry->setPassword("secretsecretsecret"); + auto* group = db->rootGroup()->findGroupByPath("/Internet/"); + QVERIFY(group); + group->addEntry(entry); + TemporaryFile sourceFile; + sourceFile.open(); + writer.writeDatabase(&sourceFile, db.data()); + sourceFile.close(); + + qint64 pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + mergeCmd.execute({"merge", "-s", targetFile1.fileName(), sourceFile.fileName()}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); + m_stderrFile->reset(); + QList outLines1 = m_stdoutFile->readAll().split('\n'); + QCOMPARE(outLines1.at(0).split('[').at(0), QByteArray("\tOverwriting Internet ")); + QCOMPARE(outLines1.at(1).split('[').at(0), QByteArray("\tCreating missing Some Website ")); + QCOMPARE(outLines1.at(2), + QString("Successfully merged %1 into %2.").arg(sourceFile.fileName(), targetFile1.fileName()).toUtf8()); + + QFile readBack(targetFile1.fileName()); + readBack.open(QIODevice::ReadOnly); + auto mergedDb = QSharedPointer::create(); + reader.readDatabase(&readBack, oldKey, mergedDb.data()); + readBack.close(); + QVERIFY(mergedDb); + auto* entry1 = mergedDb->rootGroup()->findEntryByPath("/Internet/Some Website"); + QVERIFY(entry1); + QCOMPARE(entry1->title(), QString("Some Website")); + QCOMPARE(entry1->password(), QString("secretsecretsecret")); + + // the dry run option should not modify the target database. + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + mergeCmd.execute({"merge", "--dry-run", "-s", targetFile2.fileName(), sourceFile.fileName()}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); + m_stderrFile->reset(); + QList outLines2 = m_stdoutFile->readAll().split('\n'); + QCOMPARE(outLines2.at(0).split('[').at(0), QByteArray("\tOverwriting Internet ")); + QCOMPARE(outLines2.at(1).split('[').at(0), QByteArray("\tCreating missing Some Website ")); + QCOMPARE(outLines2.at(2), QByteArray("Database was not modified by merge operation.")); + + QFile readBack2(targetFile2.fileName()); + readBack2.open(QIODevice::ReadOnly); + mergedDb = QSharedPointer::create(); + reader.readDatabase(&readBack2, oldKey, mergedDb.data()); + readBack2.close(); + QVERIFY(mergedDb); + entry1 = mergedDb->rootGroup()->findEntryByPath("/Internet/Some Website"); + QVERIFY(!entry1); + + // the dry run option can be used with the quiet option + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + mergeCmd.execute({"merge", "--dry-run", "-s", "-q", targetFile2.fileName(), sourceFile.fileName()}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); + m_stderrFile->reset(); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + + readBack2.setFileName(targetFile2.fileName()); + readBack2.open(QIODevice::ReadOnly); + mergedDb = QSharedPointer::create(); + reader.readDatabase(&readBack2, oldKey, mergedDb.data()); + readBack2.close(); + QVERIFY(mergedDb); + entry1 = mergedDb->rootGroup()->findEntryByPath("/Internet/Some Website"); + QVERIFY(!entry1); + + // try again with different passwords for both files + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("b"); + Utils::Test::setNextPassword("a"); + mergeCmd.execute({"merge", targetFile3.fileName(), sourceFile.fileName()}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); + m_stdoutFile->readLine(); + QList outLines3 = m_stdoutFile->readAll().split('\n'); + QCOMPARE(outLines3.at(2), + QString("Successfully merged %1 into %2.").arg(sourceFile.fileName(), targetFile3.fileName()).toUtf8()); + + readBack.setFileName(targetFile3.fileName()); + readBack.open(QIODevice::ReadOnly); + mergedDb = QSharedPointer::create(); + reader.readDatabase(&readBack, key, mergedDb.data()); + readBack.close(); + QVERIFY(mergedDb); + entry1 = mergedDb->rootGroup()->findEntryByPath("/Internet/Some Website"); + QVERIFY(entry1); + QCOMPARE(entry1->title(), QString("Some Website")); + QCOMPARE(entry1->password(), QString("secretsecretsecret")); + + // making sure that the message is different if the database was not + // modified by the merge operation. + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + mergeCmd.execute({"merge", "-s", sourceFile.fileName(), sourceFile.fileName()}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("Database was not modified by merge operation.\n")); + + // Quiet option + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + mergeCmd.execute({"merge", "-q", "-s", sourceFile.fileName(), sourceFile.fileName()}); + m_stdoutFile->seek(pos); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + + // Quiet option without the -s option + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + Utils::Test::setNextPassword("a"); + mergeCmd.execute({"merge", "-q", sourceFile.fileName(), sourceFile.fileName()}); + m_stdoutFile->seek(pos); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); +} + +void TestCli::testMove() +{ + Move moveCmd; + QVERIFY(!moveCmd.name.isEmpty()); + QVERIFY(moveCmd.getDescriptionLine().contains(moveCmd.name)); + + qint64 pos = m_stdoutFile->pos(); + qint64 posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + moveCmd.execute({"mv", m_dbFile->fileName(), "invalid_entry_path", "invalid_group_path"}); + m_stdoutFile->seek(pos); + m_stderrFile->seek(posErr); + m_stdoutFile->readLine(); // skip prompt line + QCOMPARE(m_stdoutFile->readLine(), QByteArray("")); + QCOMPARE(m_stderrFile->readLine(), QByteArray("Could not find entry with path invalid_entry_path.\n")); + + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + moveCmd.execute({"mv", m_dbFile->fileName(), "Sample Entry", "invalid_group_path"}); + m_stdoutFile->seek(pos); + m_stderrFile->seek(posErr); + m_stdoutFile->readLine(); // skip prompt line + QCOMPARE(m_stdoutFile->readLine(), QByteArray("")); + QCOMPARE(m_stderrFile->readLine(), QByteArray("Could not find group with path invalid_group_path.\n")); + + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + moveCmd.execute({"mv", m_dbFile->fileName(), "Sample Entry", "General/"}); + m_stdoutFile->seek(pos); + m_stderrFile->seek(posErr); + m_stdoutFile->readLine(); // skip prompt line + QCOMPARE(m_stdoutFile->readLine(), QByteArray("Successfully moved entry Sample Entry to group General/.\n")); + QCOMPARE(m_stderrFile->readLine(), QByteArray("")); + + auto db = readTestDatabase(); + auto* entry = db->rootGroup()->findEntryByPath("General/Sample Entry"); + QVERIFY(entry); + + // Test that not modified if the same group is destination. + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + moveCmd.execute({"mv", m_dbFile->fileName(), "General/Sample Entry", "General/"}); + m_stdoutFile->seek(pos); + m_stderrFile->seek(posErr); + m_stdoutFile->readLine(); // skip prompt line + QCOMPARE(m_stdoutFile->readLine(), QByteArray("")); + QCOMPARE(m_stderrFile->readLine(), QByteArray("Entry is already in group General/.\n")); + + // sanity check + db = readTestDatabase(); + entry = db->rootGroup()->findEntryByPath("General/Sample Entry"); + QVERIFY(entry); +} + +void TestCli::testRemove() +{ + Remove removeCmd; + QVERIFY(!removeCmd.name.isEmpty()); + QVERIFY(removeCmd.getDescriptionLine().contains(removeCmd.name)); + + Kdbx3Reader reader; + Kdbx3Writer writer; + + // load test database and save a copy with disabled recycle bin + auto db = readTestDatabase(); + QVERIFY(db); + TemporaryFile fileCopy; + fileCopy.open(); + db->metadata()->setRecycleBinEnabled(false); + writer.writeDatabase(&fileCopy, db.data()); + fileCopy.close(); + + qint64 pos = m_stdoutFile->pos(); + qint64 posErr = m_stderrFile->pos(); + + // delete entry and verify + Utils::Test::setNextPassword("a"); + removeCmd.execute({"rm", m_dbFile->fileName(), "/Sample Entry"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully recycled entry Sample Entry.\n")); + QCOMPARE(m_stderrFile->readAll(), QByteArray("")); + + auto key = QSharedPointer::create(); + key->addKey(QSharedPointer::create("a")); + QFile readBack(m_dbFile->fileName()); + readBack.open(QIODevice::ReadOnly); + auto readBackDb = QSharedPointer::create(); + reader.readDatabase(&readBack, key, readBackDb.data()); + readBack.close(); + QVERIFY(readBackDb); + QVERIFY(!readBackDb->rootGroup()->findEntryByPath("/Sample Entry")); + QVERIFY(readBackDb->rootGroup()->findEntryByPath(QString("/%1/Sample Entry").arg(Group::tr("Recycle Bin")))); + + pos = m_stdoutFile->pos(); + pos = m_stdoutFile->pos(); + + // try again, this time without recycle bin + Utils::Test::setNextPassword("a"); + removeCmd.execute({"rm", fileCopy.fileName(), "/Sample Entry"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully deleted entry Sample Entry.\n")); + + readBack.setFileName(fileCopy.fileName()); + readBack.open(QIODevice::ReadOnly); + readBackDb = QSharedPointer::create(); + reader.readDatabase(&readBack, key, readBackDb.data()); + readBack.close(); + QVERIFY(readBackDb); + QVERIFY(!readBackDb->rootGroup()->findEntryByPath("/Sample Entry")); + QVERIFY(!readBackDb->rootGroup()->findEntryByPath(QString("/%1/Sample Entry").arg(Group::tr("Recycle Bin")))); + + // finally, try deleting a non-existent entry + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + removeCmd.execute({"rm", fileCopy.fileName(), "/Sample Entry"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->seek(posErr); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + QCOMPARE(m_stderrFile->readAll(), QByteArray("Entry /Sample Entry not found.\n")); + + // try deleting a directory, should fail + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + removeCmd.execute({"rm", fileCopy.fileName(), "/General"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->seek(posErr); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + QCOMPARE(m_stderrFile->readAll(), QByteArray("Entry /General not found.\n")); +} + +void TestCli::testRemoveGroup() +{ + RemoveGroup removeGroupCmd; + QVERIFY(!removeGroupCmd.name.isEmpty()); + QVERIFY(removeGroupCmd.getDescriptionLine().contains(removeGroupCmd.name)); + + Kdbx3Reader reader; + Kdbx3Writer writer; + + // try deleting a directory, should recycle it first. + qint64 pos = m_stdoutFile->pos(); + qint64 posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + removeGroupCmd.execute({"rmdir", m_dbFile->fileName(), "/General"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->seek(posErr); + QCOMPARE(m_stderrFile->readAll(), QByteArray("")); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully recycled group /General.\n")); + + auto db = readTestDatabase(); + auto* group = db->rootGroup()->findGroupByPath("General"); + QVERIFY(!group); + + // try deleting a directory again, should delete it permanently. + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + removeGroupCmd.execute({"rmdir", m_dbFile->fileName(), "Recycle Bin/General"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->seek(posErr); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully deleted group Recycle Bin/General.\n")); + QCOMPARE(m_stderrFile->readAll(), QByteArray("")); + + db = readTestDatabase(); + group = db->rootGroup()->findGroupByPath("Recycle Bin/General"); + QVERIFY(!group); + + // try deleting an invalid group, should fail. + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + removeGroupCmd.execute({"rmdir", m_dbFile->fileName(), "invalid"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->seek(posErr); + QCOMPARE(m_stderrFile->readAll(), QByteArray("Group invalid not found.\n")); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + + // Should fail to remove the root group. + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + removeGroupCmd.execute({"rmdir", m_dbFile->fileName(), "/"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->seek(posErr); + QCOMPARE(m_stderrFile->readAll(), QByteArray("Cannot remove root group from database.\n")); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); +} + +void TestCli::testRemoveQuiet() +{ + Remove removeCmd; + QVERIFY(!removeCmd.name.isEmpty()); + QVERIFY(removeCmd.getDescriptionLine().contains(removeCmd.name)); + + Kdbx3Reader reader; + Kdbx3Writer writer; + + qint64 pos = m_stdoutFile->pos(); + + // delete entry and verify + Utils::Test::setNextPassword("a"); + removeCmd.execute({"rm", "-q", m_dbFile->fileName(), "/Sample Entry"}); + m_stdoutFile->seek(pos); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + + auto key = QSharedPointer::create(); + key->addKey(QSharedPointer::create("a")); + QFile readBack(m_dbFile->fileName()); + readBack.open(QIODevice::ReadOnly); + auto readBackDb = QSharedPointer::create(); + reader.readDatabase(&readBack, key, readBackDb.data()); + readBack.close(); + QVERIFY(readBackDb); + QVERIFY(!readBackDb->rootGroup()->findEntryByPath("/Sample Entry")); + QVERIFY(readBackDb->rootGroup()->findEntryByPath(QString("/%1/Sample Entry").arg(Group::tr("Recycle Bin")))); + + pos = m_stdoutFile->pos(); + + // remove the entry completely + Utils::Test::setNextPassword("a"); + removeCmd.execute({"rm", "-q", m_dbFile->fileName(), QString("/%1/Sample Entry").arg(Group::tr("Recycle Bin"))}); + m_stdoutFile->seek(pos); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + + readBack.setFileName(m_dbFile->fileName()); + readBack.open(QIODevice::ReadOnly); + readBackDb = QSharedPointer::create(); + reader.readDatabase(&readBack, key, readBackDb.data()); + readBack.close(); + QVERIFY(readBackDb); + QVERIFY(!readBackDb->rootGroup()->findEntryByPath("/Sample Entry")); + QVERIFY(!readBackDb->rootGroup()->findEntryByPath(QString("/%1/Sample Entry").arg(Group::tr("Recycle Bin")))); +} + +void TestCli::testShow() +{ + Show showCmd; + QVERIFY(!showCmd.name.isEmpty()); + QVERIFY(showCmd.getDescriptionLine().contains(showCmd.name)); + + Utils::Test::setNextPassword("a"); + showCmd.execute({"show", m_dbFile->fileName(), "/Sample Entry"}); + m_stdoutFile->reset(); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stdoutFile->readAll(), + QByteArray("Title: Sample Entry\n" + "UserName: User Name\n" + "Password: Password\n" + "URL: http://www.somesite.com/\n" + "Notes: Notes\n")); + + qint64 pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + showCmd.execute({"show", m_dbFile->fileName(), "-q", "/Sample Entry"}); + m_stdoutFile->seek(pos); + QCOMPARE(m_stdoutFile->readAll(), + QByteArray("Title: Sample Entry\n" + "UserName: User Name\n" + "Password: Password\n" + "URL: http://www.somesite.com/\n" + "Notes: Notes\n")); + + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + showCmd.execute({"show", "-a", "Title", m_dbFile->fileName(), "/Sample Entry"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stdoutFile->readAll(), QByteArray("Sample Entry\n")); + + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + showCmd.execute({"show", "-a", "Title", "-a", "URL", m_dbFile->fileName(), "/Sample Entry"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stdoutFile->readAll(), + QByteArray("Sample Entry\n" + "http://www.somesite.com/\n")); + + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + showCmd.execute({"show", "-a", "DoesNotExist", m_dbFile->fileName(), "/Sample Entry"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->reset(); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + QCOMPARE(m_stderrFile->readAll(), QByteArray("ERROR: unknown attribute DoesNotExist.\n")); + + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + showCmd.execute({"show", "-t", m_dbFile->fileName(), "/Sample Entry"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + QVERIFY(isTOTP(m_stdoutFile->readAll())); + + pos = m_stdoutFile->pos(); + Utils::Test::setNextPassword("a"); + showCmd.execute({"show", "-a", "Title", m_dbFile->fileName(), "--totp", "/Sample Entry"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stdoutFile->readLine(), QByteArray("Sample Entry\n")); + QVERIFY(isTOTP(m_stdoutFile->readAll())); + + pos = m_stdoutFile->pos(); + qint64 posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + showCmd.execute({"show", m_dbFile2->fileName(), "--totp", "/Sample Entry"}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->seek(posErr); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + QCOMPARE(m_stderrFile->readAll(), QByteArray("Entry with path /Sample Entry has no TOTP set up.\n")); +} + +void TestCli::testInvalidDbFiles() +{ + Show showCmd; + QString nonExistentDbPath("/foo/bar/baz"); + QString directoryName("/"); + + qint64 pos = m_stderrFile->pos(); + showCmd.execute({"show", nonExistentDbPath, "-q", "/Sample Entry"}); + m_stderrFile->seek(pos); + QCOMPARE(QString(m_stderrFile->readAll()), + QObject::tr("Failed to open database file %1: not found").arg(nonExistentDbPath) + "\n"); + + pos = m_stderrFile->pos(); + showCmd.execute({"show", directoryName, "-q", "whatever"}); + m_stderrFile->seek(pos); + QCOMPARE(QString(m_stderrFile->readAll()), + QObject::tr("Failed to open database file %1: not a plain file").arg(directoryName) + "\n"); + + // Create a write-only file and try to open it. + // QFileInfo.isReadable returns 'true' on Windows, even after the call to + // setPermissions(WriteOwner) and with NTFS permissions enabled, so this + // check doesn't work. +#if !defined(Q_OS_WIN) + QTemporaryFile tempFile; + QVERIFY(tempFile.open()); + QString path = QFileInfo(tempFile).absoluteFilePath(); + QVERIFY(tempFile.setPermissions(QFileDevice::WriteOwner)); + pos = m_stderrFile->pos(); + showCmd.execute({"show", path, "some entry"}); + m_stderrFile->seek(pos); + QCOMPARE(QString(m_stderrFile->readAll()), + QObject::tr("Failed to open database file %1: not readable").arg(path) + "\n"); +#endif // Q_OS_WIN +} + +/** + * Secret key for the YubiKey slot used by the unit test is + * 1c e3 0f d7 8d 20 dc fa 40 b5 0c 18 77 9a fb 0f 02 28 8d b7 + * This secret should be configured at slot 2, and the slot + * should be configured as passive. + */ +void TestCli::testYubiKeyOption() +{ + if (!YubiKey::instance()->init()) { + QSKIP("Unable to connect to YubiKey"); + } + + QString errorMessage; + bool isBlocking = YubiKey::instance()->checkSlotIsBlocking(2, errorMessage); + if (isBlocking && errorMessage.isEmpty()) { + QSKIP("Skipping YubiKey in press mode."); + } + + QByteArray challenge("CLITest"); + QByteArray response; + YubiKey::instance()->challenge(2, false, challenge, response); + QByteArray expected("\xA2\x3B\x94\x00\xBE\x47\x9A\x30\xA9\xEB\x50\x9B\x85\x56\x5B\x6B\x30\x25\xB4\x8E", 20); + QVERIFY2(response == expected, "YubiKey Slot 2 is not configured with correct secret key."); + + List listCmd; + Add addCmd; + + Utils::Test::setNextPassword("a"); + listCmd.execute({"ls", "-y", "2", m_yubiKeyProtectedDbFile->fileName()}); + m_stdoutFile->reset(); + m_stderrFile->reset(); + m_stdoutFile->readLine(); // skip password prompt + QCOMPARE(m_stdoutFile->readAll(), + QByteArray("entry1\n" + "entry2\n")); + + // Should raise an error with no yubikey slot. + qint64 pos = m_stdoutFile->pos(); + qint64 posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + listCmd.execute({"ls", m_yubiKeyProtectedDbFile->fileName()}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->seek(posErr); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + QCOMPARE(m_stderrFile->readLine(), + QByteArray("Error while reading the database: Invalid credentials were provided, please try again.\n")); + QCOMPARE(m_stderrFile->readLine(), + QByteArray("If this reoccurs, then your database file may be corrupt. (HMAC mismatch)\n")); + + // Should raise an error if yubikey slot is not a string + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + listCmd.execute({"ls", "-y", "invalidslot", m_yubiKeyProtectedDbFile->fileName()}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->seek(posErr); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + QCOMPARE(m_stderrFile->readAll().split(':').at(0), QByteArray("Invalid YubiKey slot invalidslot\n")); + + // Should raise an error if yubikey slot is invalid. + pos = m_stdoutFile->pos(); + posErr = m_stderrFile->pos(); + Utils::Test::setNextPassword("a"); + listCmd.execute({"ls", "-y", "3", m_yubiKeyProtectedDbFile->fileName()}); + m_stdoutFile->seek(pos); + m_stdoutFile->readLine(); // skip password prompt + m_stderrFile->seek(posErr); + QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); + QCOMPARE(m_stderrFile->readAll().split(':').at(0), QByteArray("Invalid YubiKey slot 3\n")); +} + +namespace +{ + + void expectParseResult(const QString& input, const QStringList& expectedOutput) + { + QStringList result = Utils::splitCommandString(input); + QCOMPARE(result.size(), expectedOutput.size()); + for (int i = 0; i < expectedOutput.size(); ++i) { + QCOMPARE(result[i], expectedOutput[i]); + } + } + +} // namespace + +void TestCli::testCommandParsing_data() +{ + QTest::addColumn("input"); + QTest::addColumn("expectedOutput"); + + QTest::newRow("basic") << "hello world" << QStringList({"hello", "world"}); + QTest::newRow("basic escaping") << "hello\\ world" << QStringList({"hello world"}); + QTest::newRow("quoted string") << "\"hello world\"" << QStringList({"hello world"}); + QTest::newRow("multiple params") << "show Passwords/Internet" << QStringList({"show", "Passwords/Internet"}); + QTest::newRow("quoted string inside param") + << R"(ls foo\ bar\ baz"quoted")" << QStringList({"ls", "foo bar baz\"quoted\""}); + QTest::newRow("multiple whitespace") << "hello world" << QStringList({"hello", "world"}); + QTest::newRow("single slash char") << "\\" << QStringList({"\\"}); + QTest::newRow("double backslash entry name") << "show foo\\\\\\\\bar" << QStringList({"show", "foo\\\\bar"}); +} + +void TestCli::testCommandParsing() +{ + QFETCH(QString, input); + QFETCH(QStringList, expectedOutput); + + expectParseResult(input, expectedOutput); +} + +void TestCli::testOpen() +{ + Open o; + + Utils::Test::setNextPassword("a"); + o.execute({"open", m_dbFile->fileName()}); + m_stdoutFile->reset(); + QVERIFY(o.currentDatabase); + + List l; + // Set a current database, simulating interactive mode. + l.currentDatabase = o.currentDatabase; + l.execute({"ls"}); + m_stdoutFile->reset(); + QByteArray expectedOutput("Sample Entry\n" + "General/\n" + "Windows/\n" + "Network/\n" + "Internet/\n" + "eMail/\n" + "Homebanking/\n"); + QByteArray actualOutput = m_stdoutFile->readAll(); + actualOutput.truncate(expectedOutput.length()); + QCOMPARE(actualOutput, expectedOutput); +} + +void TestCli::testHelp() +{ + Help h; + Commands::setupCommands(false); + + { + h.execute({"help"}); + m_stderrFile->reset(); + QString output(m_stderrFile->readAll()); + QVERIFY(output.contains(QObject::tr("Available commands"))); + } + + { + List l; + h.execute({"help", "ls"}); + m_stderrFile->reset(); + QString output(m_stderrFile->readAll()); + QVERIFY(output.contains(l.description)); + } +} diff --git a/tests/TestCli.h b/tests/TestCli.h index d1969943cc..46fff944bd 100644 --- a/tests/TestCli.h +++ b/tests/TestCli.h @@ -58,6 +58,7 @@ private slots: void testExport(); void testGenerate_data(); void testGenerate(); + void testImport(); void testKeyFileOption(); void testNoPasswordOption(); void testHelp(); @@ -77,11 +78,13 @@ private slots: private: QByteArray m_dbData; QByteArray m_dbData2; + QByteArray m_xmlData; QByteArray m_yubiKeyProtectedDbData; QByteArray m_keyFileProtectedDbData; QByteArray m_keyFileProtectedNoPasswordDbData; QScopedPointer m_dbFile; QScopedPointer m_dbFile2; + QScopedPointer m_xmlFile; QScopedPointer m_keyFileProtectedDbFile; QScopedPointer m_keyFileProtectedNoPasswordDbFile; QScopedPointer m_yubiKeyProtectedDbFile;