/* * 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/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 "keys/drivers/YubiKey.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/Info.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(); Utils::STDIN = fdopen(m_stdinFile->handle(), "r+"); m_stdoutFile.reset(new TemporaryFile()); m_stdoutFile->open(); Utils::STDOUT = fdopen(m_stdoutFile->handle(), "r+"); m_stderrFile.reset(new TemporaryFile()); m_stderrFile->open(); Utils::STDERR = fdopen(m_stderrFile->handle(), "r+"); } void TestCli::cleanup() { m_dbFile.reset(); m_dbFile2.reset(); m_keyFileProtectedDbFile.reset(); m_keyFileProtectedNoPasswordDbFile.reset(); m_yubiKeyProtectedDbFile.reset(); m_stdinFile.reset(); Utils::STDIN = stdin; m_stdoutFile.reset(); Utils::STDOUT = stdout; m_stderrFile.reset(); Utils::STDERR = stderr; } void TestCli::cleanupTestCase() { } QSharedPointer TestCli::readTestDatabase() const { Utils::Test::setNextPassword("a"); auto db = QSharedPointer(Utils::unlockDatabase(m_dbFile->fileName(), true, "", "", Utils::STDOUT)); m_stdoutFile->seek(ftell(Utils::STDOUT)); // 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("db-create")); QVERIFY(Commands::getCommand("db-info")); 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(), 22); } 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("db-create")); QVERIFY(Commands::getCommand("db-info")); 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(), 22); } 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_stderrFile->readAll(), QByteArray("")); QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); db = readTestDatabase(); entry = db->rootGroup()->findEntryByPath("/newentry-quiet"); QVERIFY(entry); QCOMPARE(entry->password().size(), 20); pos = m_stdoutFile->pos(); posErr = m_stderrFile->pos(); Utils::Test::setNextPassword("a"); Utils::Test::setNextPassword("newpassword"); addCmd.execute( {"add", "-u", "newuser2", "--url", "https://example.net/", "-p", m_dbFile->fileName(), "/newuser-entry2"}); m_stdoutFile->seek(pos); m_stderrFile->seek(posErr); m_stdoutFile->readLine(); // skip password prompt m_stdoutFile->readLine(); // skip password input QCOMPARE(m_stderrFile->readAll(), QByteArray("")); QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully added entry newuser-entry2.\n")); 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_stderrFile->readAll(), QByteArray("")); QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully added entry newuser-entry3.\n")); 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_stderrFile->readAll(), QByteArray("")); QCOMPARE(m_stdoutFile->readAll(), QByteArray("Successfully added entry newuser-entry4.\n")); 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 (QProcessEnvironment::systemEnvironment().contains("WAYLAND_DISPLAY")) { QSKIP("Clip test skipped due to QClipboard and Wayland issues"); } if (errorOutput.contains("Unable to start program") || errorOutput.contains("No program defined for clipboard manipulation")) { QSKIP("Clip test skipped due to missing clipboard tool"); } QTRY_COMPARE(clipboard->text(), QString("Password")); m_stdoutFile->readLine(); // skip prompt line QCOMPARE(m_stdoutFile->readLine(), QByteArray("Entry's \"Password\" attribute 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("")); QTRY_COMPARE(clipboard->text(), QString("Password")); // Username Utils::Test::setNextPassword("a"); clipCmd.execute({"clip", m_dbFile->fileName(), "/Sample Entry", "-a", "username"}); QTRY_COMPARE(clipboard->text(), QString("User Name")); // TOTP Utils::Test::setNextPassword("a"); clipCmd.execute({"clip", m_dbFile->fileName(), "/Sample Entry", "--totp"}); QTRY_VERIFY(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(clipboard->text(), QString("Password")); QTRY_COMPARE_WITH_TIMEOUT(clipboard->text(), QString(""), 2000); 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(isTOTP(clipboard->text())); QTRY_COMPARE_WITH_TIMEOUT(clipboard->text(), QString(""), 2000); 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")); posErr = m_stderrFile->pos(); Utils::Test::setNextPassword("a"); clipCmd.execute({"clip", m_dbFile->fileName(), "-a", "TESTAttribute1", "/Sample Entry"}); m_stderrFile->seek(posErr); QCOMPARE( m_stderrFile->readAll(), QByteArray("ERROR: attribute TESTAttribute1 is ambiguous, it matches TestAttribute1 and testattribute1.\n")); posErr = m_stderrFile->pos(); Utils::Test::setNextPassword("a"); clipCmd.execute({"clip", m_dbFile2->fileName(), "--attribute", "Username", "--totp", "/Sample Entry"}); m_stderrFile->seek(posErr); QCOMPARE(m_stderrFile->readAll(), QByteArray("ERROR: Please specify one of --attribute or --totp, not both.\n")); } void TestCli::testCreate() { Create createCmd; QVERIFY(!createCmd.name.isEmpty()); QVERIFY(createCmd.getDescriptionLine().contains(createCmd.name)); QScopedPointer testDir(new QTemporaryDir()); QString dbFilename; // Testing password option, password mismatch dbFilename = testDir->path() + "/testCreate_pw.kdbx"; Utils::Test::setNextPassword("a"); Utils::Test::setNextPassword("b"); createCmd.execute({"db-create", dbFilename, "-p"}); m_stderrFile->reset(); m_stdoutFile->reset(); QCOMPARE(m_stdoutFile->readLine(), QByteArray("Enter password to encrypt database (optional): \n")); QCOMPARE(m_stdoutFile->readLine(), QByteArray("Repeat password: \n")); QCOMPARE(m_stdoutFile->readLine(), QByteArray("Error: Passwords do not match.\n")); QCOMPARE(m_stderrFile->readLine(), QByteArray("Failed to set database password.\n")); // Testing password option Utils::Test::setNextPassword("a", true); qint64 pos = m_stdoutFile->pos(); qint64 errPos = m_stderrFile->pos(); createCmd.execute({"db-create", dbFilename, "-p"}); 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("Repeat password: \n")); QCOMPARE(m_stdoutFile->readLine(), QByteArray("Successfully created new database.\n")); Utils::Test::setNextPassword("a"); auto db = Utils::unlockDatabase(dbFilename, true, "", "", Utils::DEVNULL); QVERIFY(db); // Testing with empty password (deny it) dbFilename = testDir->path() + "/testCreate_blankpw.kdbx"; Utils::Test::setNextPassword(""); m_stdinFile->reset(); m_stdinFile->write("n\n"); m_stdinFile->reset(); pos = m_stdoutFile->pos(); errPos = m_stderrFile->pos(); createCmd.execute({"db-create", dbFilename, "-p"}); m_stdoutFile->seek(pos); m_stderrFile->seek(errPos); QCOMPARE(m_stdoutFile->readLine(), QByteArray("Enter password to encrypt database (optional): \n")); QVERIFY(m_stdoutFile->readLine().contains("empty password")); QCOMPARE(m_stderrFile->readLine(), QByteArray("Failed to set database password.\n")); // Testing with empty password (accept it) Utils::Test::setNextPassword(""); m_stdinFile->reset(); m_stdinFile->write("y\n"); m_stdinFile->reset(); pos = m_stdoutFile->pos(); errPos = m_stderrFile->pos(); createCmd.execute({"db-create", dbFilename, "-p"}); m_stdoutFile->seek(pos); m_stderrFile->seek(errPos); QCOMPARE(m_stdoutFile->readLine(), QByteArray("Enter password to encrypt database (optional): \n")); QVERIFY(m_stdoutFile->readLine().contains("empty password")); QCOMPARE(m_stdoutFile->readLine(), QByteArray("Successfully created new database.\n")); Utils::Test::setNextPassword(""); db = Utils::unlockDatabase(dbFilename, true, "", "", Utils::DEVNULL); QVERIFY(db); // Should refuse to create the database if it already exists. pos = m_stdoutFile->pos(); errPos = m_stderrFile->pos(); createCmd.execute({"db-create", "-p", dbFilename}); 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 " + dbFilename + " already exists.\n"); QCOMPARE(m_stderrFile->readAll(), errorMessage.toUtf8()); // Should refuse to create without any key provided. dbFilename = testDir->path() + "/testCreate_key.kdbx"; pos = m_stdoutFile->pos(); errPos = m_stderrFile->pos(); createCmd.execute({"db-create", dbFilename}); m_stdoutFile->seek(pos); m_stderrFile->seek(errPos); QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); QCOMPARE(m_stderrFile->readLine(), QByteArray("No key is set. Aborting database creation.\n")); // Testing with keyfile creation dbFilename = testDir->path() + "/testCreate_key2.kdbx"; QString keyfilePath = testDir->path() + "/keyfile.txt"; pos = m_stdoutFile->pos(); errPos = m_stderrFile->pos(); Utils::Test::setNextPassword("a", true); createCmd.execute({"db-create", dbFilename, "-p", "-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("Repeat password: \n")); QCOMPARE(m_stdoutFile->readLine(), QByteArray("Successfully created new database.\n")); Utils::Test::setNextPassword("a"); db = Utils::unlockDatabase(dbFilename, true, keyfilePath, "", Utils::DEVNULL); QVERIFY(db); // Testing with existing keyfile dbFilename = testDir->path() + "/testCreate_key3.kdbx"; pos = m_stdoutFile->pos(); errPos = m_stderrFile->pos(); Utils::Test::setNextPassword("a", true); createCmd.execute({"db-create", dbFilename, "-p", "-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("Repeat password: \n")); QCOMPARE(m_stdoutFile->readLine(), QByteArray("Successfully created new database.\n")); Utils::Test::setNextPassword("a"); db = Utils::unlockDatabase(dbFilename, true, keyfilePath, "", Utils::DEVNULL); QVERIFY(db); // Invalid decryption time (format). dbFilename = testDir->path() + "/testCreate_time.kdbx"; pos = m_stdoutFile->pos(); errPos = m_stderrFile->pos(); createCmd.execute({"db-create", dbFilename, "-p", "-t", "NAN"}); m_stdoutFile->seek(pos); m_stderrFile->seek(errPos); QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); QCOMPARE(m_stderrFile->readAll(), QByteArray("Invalid decryption time NAN.\n")); // Invalid decryption time (range). pos = m_stdoutFile->pos(); errPos = m_stderrFile->pos(); createCmd.execute({"db-create", dbFilename, "-p", "-t", "10"}); m_stdoutFile->seek(pos); m_stderrFile->seek(errPos); QCOMPARE(m_stdoutFile->readAll(), QByteArray("")); QVERIFY(m_stderrFile->readAll().contains(QByteArray("Target decryption time must be between"))); int encryptionTime = 500; // Custom encryption time pos = m_stdoutFile->pos(); errPos = m_stderrFile->pos(); Utils::Test::setNextPassword("a", true); int epochBefore = QDateTime::currentMSecsSinceEpoch(); createCmd.execute({"db-create", dbFilename, "-p", "-t", QString::number(encryptionTime)}); // Removing 100ms to make sure we account for changes in computation time. QVERIFY(QDateTime::currentMSecsSinceEpoch() > (epochBefore + encryptionTime - 100)); m_stdoutFile->seek(pos); m_stderrFile->seek(errPos); QCOMPARE(m_stderrFile->readAll(), QByteArray("")); QCOMPARE(m_stdoutFile->readLine(), QByteArray("Enter password to encrypt database (optional): \n")); QCOMPARE(m_stdoutFile->readLine(), QByteArray("Repeat password: \n")); QCOMPARE(m_stdoutFile->readLine(), QByteArray("Benchmarking key derivation function for 500ms delay.\n")); QVERIFY(m_stdoutFile->readLine().contains(QByteArray("rounds for key derivation function.\n"))); Utils::Test::setNextPassword("a"); db = Utils::unlockDatabase(dbFilename, true, "", "", Utils::DEVNULL); QVERIFY(db); } void TestCli::testInfo() { Info infoCmd; QVERIFY(!infoCmd.name.isEmpty()); QVERIFY(infoCmd.getDescriptionLine().contains(infoCmd.name)); Utils::Test::setNextPassword("a"); infoCmd.execute({"db-info", m_dbFile->fileName()}); m_stdoutFile->reset(); m_stderrFile->reset(); m_stdoutFile->readLine(); // skip prompt line QCOMPARE(m_stderrFile->readAll(), QByteArray("")); QVERIFY(m_stdoutFile->readLine().contains(QByteArray("UUID: "))); QCOMPARE(m_stdoutFile->readLine(), QByteArray("Name: \n")); QCOMPARE(m_stdoutFile->readLine(), QByteArray("Description: \n")); QCOMPARE(m_stdoutFile->readLine(), QByteArray("Cipher: AES 256-bit\n")); QCOMPARE(m_stdoutFile->readLine(), QByteArray("KDF: AES (6000 rounds)\n")); QCOMPARE(m_stdoutFile->readLine(), QByteArray("Recycle bin is enabled.\n")); // Test with quiet option. qint64 pos = m_stdoutFile->pos(); qint64 errPos = m_stderrFile->pos(); Utils::Test::setNextPassword("a"); infoCmd.execute({"db-info", "-q", m_dbFile->fileName()}); m_stdoutFile->seek(pos); m_stderrFile->seek(errPos); QCOMPARE(m_stderrFile->readAll(), QByteArray("")); QVERIFY(m_stdoutFile->readLine().contains(QByteArray("UUID: "))); QCOMPARE(m_stdoutFile->readLine(), QByteArray("Name: \n")); QCOMPARE(m_stdoutFile->readLine(), QByteArray("Description: \n")); QCOMPARE(m_stdoutFile->readLine(), QByteArray("Cipher: AES 256-bit\n")); QCOMPARE(m_stdoutFile->readLine(), QByteArray("KDF: AES (6000 rounds)\n")); QCOMPARE(m_stdoutFile->readLine(), QByteArray("Recycle bin is enabled.\n")); } 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", true); 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("Repeat password: \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", true); importCmd.execute({"import", "-q", m_xmlFile->fileName(), databaseFilenameQuiet}); m_stdoutFile->seek(pos); QCOMPARE(m_stdoutFile->readLine(), QByteArray("Enter password to encrypt database (optional): \n")); QCOMPARE(m_stdoutFile->readLine(), QByteArray("Repeat password: \n")); QCOMPARE(m_stdoutFile->readLine(), QByteArray()); 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::testMergeWithKeys() { Create createCmd; QVERIFY(!createCmd.name.isEmpty()); QVERIFY(createCmd.getDescriptionLine().contains(createCmd.name)); Merge mergeCmd; QVERIFY(!mergeCmd.name.isEmpty()); QVERIFY(mergeCmd.getDescriptionLine().contains(mergeCmd.name)); Kdbx4Writer writer; Kdbx4Reader reader; QScopedPointer testDir(new QTemporaryDir()); QString sourceDatabaseFilename = testDir->path() + "/testSourceDatabase.kdbx"; QString sourceKeyfilePath = testDir->path() + "/testSourceKeyfile.txt"; QString targetDatabaseFilename = testDir->path() + "/testTargetDatabase.kdbx"; QString targetKeyfilePath = testDir->path() + "/testTargetKeyfile.txt"; qint64 pos = m_stdoutFile->pos(); Utils::Test::setNextPassword("a", true); createCmd.execute({"db-create", sourceDatabaseFilename, "-p", "-k", sourceKeyfilePath}); Utils::Test::setNextPassword("b", true); createCmd.execute({"db-create", targetDatabaseFilename, "-p", "-k", targetKeyfilePath}); Utils::Test::setNextPassword("a"); auto sourceDatabase = QSharedPointer( Utils::unlockDatabase(sourceDatabaseFilename, true, sourceKeyfilePath, "", Utils::STDOUT)); QVERIFY(sourceDatabase); Utils::Test::setNextPassword("b"); auto targetDatabase = QSharedPointer( Utils::unlockDatabase(targetDatabaseFilename, true, targetKeyfilePath, "", Utils::STDOUT)); QVERIFY(targetDatabase); auto* rootGroup = new Group(); rootGroup->setName("root"); rootGroup->setUuid(QUuid::createUuid()); auto* group = new Group(); group->setUuid(QUuid::createUuid()); group->setParent(rootGroup); group->setName("Internet"); auto* entry = new Entry(); entry->setUuid(QUuid::createUuid()); entry->setTitle("Some Website"); entry->setPassword("secretsecretsecret"); group->addEntry(entry); sourceDatabase->setRootGroup(rootGroup); auto* otherRootGroup = new Group(); otherRootGroup->setName("root"); otherRootGroup->setUuid(QUuid::createUuid()); auto* otherGroup = new Group(); otherGroup->setUuid(QUuid::createUuid()); otherGroup->setParent(otherRootGroup); otherGroup->setName("Internet"); auto* otherEntry = new Entry(); otherEntry->setUuid(QUuid::createUuid()); otherEntry->setTitle("Some Website 2"); otherEntry->setPassword("secretsecretsecret 2"); otherGroup->addEntry(otherEntry); targetDatabase->setRootGroup(otherRootGroup); QFile sourceDatabaseFile(sourceDatabaseFilename); sourceDatabaseFile.open(QIODevice::WriteOnly); QVERIFY(writer.writeDatabase(&sourceDatabaseFile, sourceDatabase.data())); sourceDatabaseFile.flush(); sourceDatabaseFile.close(); QFile targetDatabaseFile(targetDatabaseFilename); targetDatabaseFile.open(QIODevice::WriteOnly); QVERIFY(writer.writeDatabase(&targetDatabaseFile, targetDatabase.data())); targetDatabaseFile.flush(); targetDatabaseFile.close(); pos = m_stdoutFile->pos(); Utils::Test::setNextPassword("b"); Utils::Test::setNextPassword("a"); mergeCmd.execute({"merge", "-k", targetKeyfilePath, "--key-file-from", sourceKeyfilePath, targetDatabaseFile.fileName(), sourceDatabaseFile.fileName()}); m_stdoutFile->seek(pos); QList lines = m_stdoutFile->readAll().split('\n'); QVERIFY(lines.contains(QString("Successfully merged %1 into %2.") .arg(sourceDatabaseFile.fileName(), targetDatabaseFile.fileName()) .toUtf8())); } 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: PROTECTED\n" "URL: http://www.somesite.com/\n" "Notes: Notes\n")); qint64 pos = m_stdoutFile->pos(); Utils::Test::setNextPassword("a"); showCmd.execute({"show", "-s", m_dbFile->fileName(), "/Sample Entry"}); m_stdoutFile->seek(pos); 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")); 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: PROTECTED\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", "Password", m_dbFile->fileName(), "/Sample Entry"}); m_stdoutFile->seek(pos); m_stdoutFile->readLine(); // skip password prompt QCOMPARE(m_stdoutFile->readAll(), QByteArray("Password\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")); // Test case insensitivity 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")); // Show with ambiguous attributes pos = m_stdoutFile->pos(); posErr = m_stderrFile->pos(); Utils::Test::setNextPassword("a"); showCmd.execute({"show", m_dbFile->fileName(), "-a", "Testattribute1", "/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("ERROR: attribute Testattribute1 is ambiguous, it matches TestAttribute1 and testattribute1.\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)); } }