keepassxc

Форк
0
/
TestCli.cpp 
2395 строк · 91.9 Кб
1
/*
2
 *  Copyright (C) 2020 KeePassXC Team <team@keepassxc.org>
3
 *
4
 *  This program is free software: you can redistribute it and/or modify
5
 *  it under the terms of the GNU General Public License as published by
6
 *  the Free Software Foundation, either version 2 or (at your option)
7
 *  version 3 of the License.
8
 *
9
 *  This program is distributed in the hope that it will be useful,
10
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
11
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
 *  GNU General Public License for more details.
13
 *
14
 *  You should have received a copy of the GNU General Public License
15
 *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
16
 */
17

18
#include "TestCli.h"
19

20
#include "config-keepassx-tests.h"
21
#include "core/Bootstrap.h"
22
#include "core/Config.h"
23
#include "core/Group.h"
24
#include "core/Metadata.h"
25
#include "core/Tools.h"
26
#include "crypto/Crypto.h"
27
#include "keys/FileKey.h"
28
#include "keys/drivers/YubiKey.h"
29
#include "zxcvbn/zxcvbn.h"
30

31
#include "cli/Add.h"
32
#include "cli/AddGroup.h"
33
#include "cli/Analyze.h"
34
#include "cli/AttachmentExport.h"
35
#include "cli/AttachmentImport.h"
36
#include "cli/AttachmentRemove.h"
37
#include "cli/Clip.h"
38
#include "cli/DatabaseCreate.h"
39
#include "cli/DatabaseEdit.h"
40
#include "cli/DatabaseInfo.h"
41
#include "cli/Diceware.h"
42
#include "cli/Edit.h"
43
#include "cli/Estimate.h"
44
#include "cli/Export.h"
45
#include "cli/Generate.h"
46
#include "cli/Help.h"
47
#include "cli/Import.h"
48
#include "cli/List.h"
49
#include "cli/Merge.h"
50
#include "cli/Move.h"
51
#include "cli/Open.h"
52
#include "cli/Remove.h"
53
#include "cli/RemoveGroup.h"
54
#include "cli/Search.h"
55
#include "cli/Show.h"
56
#include "cli/Utils.h"
57

58
#include <QClipboard>
59
#include <QSignalSpy>
60
#include <QTest>
61
#include <QtConcurrent>
62

63
QTEST_MAIN(TestCli)
64

65
void TestCli::initTestCase()
66
{
67
    QVERIFY(Crypto::init());
68

69
    Config::createTempFileInstance();
70
    QLocale::setDefault(QLocale::c());
71
    Bootstrap::bootstrap();
72

73
    m_devNull.reset(new QFile());
74
#ifdef Q_OS_WIN
75
    m_devNull->open(fopen("nul", "w"), QIODevice::WriteOnly);
76
#else
77
    m_devNull->open(fopen("/dev/null", "w"), QIODevice::WriteOnly);
78
#endif
79
    Utils::DEVNULL.setDevice(m_devNull.data());
80
}
81

82
void TestCli::init()
83
{
84
    const auto file = QString(KEEPASSX_TEST_DATA_DIR).append("/%1");
85

86
    m_dbFile.reset(new TemporaryFile());
87
    m_dbFile->copyFromFile(file.arg("NewDatabase.kdbx"));
88

89
    m_dbFile2.reset(new TemporaryFile());
90
    m_dbFile2->copyFromFile(file.arg("NewDatabase2.kdbx"));
91

92
    m_dbFileMulti.reset(new TemporaryFile());
93
    m_dbFileMulti->copyFromFile(file.arg("NewDatabaseMulti.kdbx"));
94

95
    m_xmlFile.reset(new TemporaryFile());
96
    m_xmlFile->copyFromFile(file.arg("NewDatabase.xml"));
97

98
    m_keyFileProtectedDbFile.reset(new TemporaryFile());
99
    m_keyFileProtectedDbFile->copyFromFile(file.arg("KeyFileProtected.kdbx"));
100

101
    m_keyFileProtectedNoPasswordDbFile.reset(new TemporaryFile());
102
    m_keyFileProtectedNoPasswordDbFile->copyFromFile(file.arg("KeyFileProtectedNoPassword.kdbx"));
103

104
    m_yubiKeyProtectedDbFile.reset(new TemporaryFile());
105
    m_yubiKeyProtectedDbFile->copyFromFile(file.arg("YubiKeyProtectedPasswords.kdbx"));
106

107
    m_nonAsciiDbFile.reset(new TemporaryFile());
108
    m_nonAsciiDbFile->copyFromFile(file.arg("NonAscii.kdbx"));
109

110
    m_stdout.reset(new QBuffer());
111
    m_stdout->open(QIODevice::ReadWrite);
112
    Utils::STDOUT.setDevice(m_stdout.data());
113

114
    m_stderr.reset(new QBuffer());
115
    m_stderr->open(QIODevice::ReadWrite);
116
    Utils::STDERR.setDevice(m_stderr.data());
117

118
    m_stdin.reset(new QBuffer());
119
    m_stdin->open(QIODevice::ReadWrite);
120
    Utils::STDIN.setDevice(m_stdin.data());
121
}
122

123
void TestCli::cleanup()
124
{
125
    m_dbFile.reset();
126
    m_dbFile2.reset();
127
    m_dbFileMulti.reset();
128
    m_keyFileProtectedDbFile.reset();
129
    m_keyFileProtectedNoPasswordDbFile.reset();
130
    m_yubiKeyProtectedDbFile.reset();
131

132
    Utils::STDOUT.setDevice(nullptr);
133
    Utils::STDERR.setDevice(nullptr);
134
    Utils::STDIN.setDevice(nullptr);
135
}
136

137
void TestCli::cleanupTestCase()
138
{
139
    m_devNull.reset();
140
}
141

142
QSharedPointer<Database> TestCli::readDatabase(const QString& filename, const QString& pw, const QString& keyfile)
143
{
144
    auto db = QSharedPointer<Database>::create();
145
    auto key = QSharedPointer<CompositeKey>::create();
146

147
    if (filename.isEmpty()) {
148
        // Open the default test database
149
        key->addKey(QSharedPointer<PasswordKey>::create("a"));
150
        if (!db->open(m_dbFile->fileName(), key)) {
151
            return {};
152
        }
153
    } else {
154
        // Open the specified database file using supplied credentials
155
        key->addKey(QSharedPointer<PasswordKey>::create(pw));
156
        if (!keyfile.isEmpty()) {
157
            auto filekey = QSharedPointer<FileKey>::create();
158
            filekey->load(keyfile);
159
            key->addKey(filekey);
160
        }
161

162
        if (!db->open(filename, key)) {
163
            return {};
164
        }
165
    }
166

167
    return db;
168
}
169

170
int TestCli::execCmd(Command& cmd, const QStringList& args) const
171
{
172
    // Move to end of stream
173
    m_stdout->readAll();
174
    m_stderr->readAll();
175

176
    // Record stream position
177
    auto outPos = m_stdout->pos();
178
    auto errPos = m_stderr->pos();
179

180
    // Execute command
181
    int ret = cmd.execute(args);
182

183
    // Move back to recorded position
184
    m_stdout->seek(outPos);
185
    m_stderr->seek(errPos);
186

187
    // Skip over blank lines
188
    QByteArray newline("\n");
189
    while (m_stdout->peek(1) == newline) {
190
        m_stdout->readLine();
191
    }
192
    while (m_stderr->peek(1) == newline) {
193
        m_stderr->readLine();
194
    }
195

196
    return ret;
197
}
198

199
bool TestCli::isTotp(const QString& value)
200
{
201
    static const QRegularExpression totp("^\\d{6}$");
202
    return totp.match(value.trimmed()).hasMatch();
203
}
204

205
void TestCli::setInput(const QString& input)
206
{
207
    setInput(QStringList(input));
208
}
209

210
void TestCli::setInput(const QStringList& input)
211
{
212
    auto ba = input.join("\n").toLatin1();
213
    // Always end in newline
214
    if (!ba.endsWith("\n")) {
215
        ba.append("\n");
216
    }
217
    auto pos = m_stdin->pos();
218
    m_stdin->write(ba);
219
    m_stdin->seek(pos);
220
}
221

222
void TestCli::testBatchCommands()
223
{
224
    Commands::setupCommands(false);
225
    QVERIFY(Commands::getCommand("add"));
226
    QVERIFY(Commands::getCommand("analyze"));
227
    QVERIFY(Commands::getCommand("attachment-export"));
228
    QVERIFY(Commands::getCommand("attachment-import"));
229
    QVERIFY(Commands::getCommand("attachment-rm"));
230
    QVERIFY(Commands::getCommand("clip"));
231
    QVERIFY(Commands::getCommand("close"));
232
    QVERIFY(Commands::getCommand("db-create"));
233
    QVERIFY(Commands::getCommand("db-info"));
234
    QVERIFY(Commands::getCommand("diceware"));
235
    QVERIFY(Commands::getCommand("edit"));
236
    QVERIFY(Commands::getCommand("estimate"));
237
    QVERIFY(Commands::getCommand("export"));
238
    QVERIFY(Commands::getCommand("generate"));
239
    QVERIFY(Commands::getCommand("help"));
240
    QVERIFY(Commands::getCommand("import"));
241
    QVERIFY(Commands::getCommand("ls"));
242
    QVERIFY(Commands::getCommand("merge"));
243
    QVERIFY(Commands::getCommand("mkdir"));
244
    QVERIFY(Commands::getCommand("mv"));
245
    QVERIFY(Commands::getCommand("open"));
246
    QVERIFY(Commands::getCommand("rm"));
247
    QVERIFY(Commands::getCommand("rmdir"));
248
    QVERIFY(Commands::getCommand("show"));
249
    QVERIFY(Commands::getCommand("search"));
250
    QVERIFY(!Commands::getCommand("doesnotexist"));
251
    QCOMPARE(Commands::getCommands().size(), 26);
252
}
253

254
void TestCli::testInteractiveCommands()
255
{
256
    Commands::setupCommands(true);
257
    QVERIFY(Commands::getCommand("add"));
258
    QVERIFY(Commands::getCommand("analyze"));
259
    QVERIFY(Commands::getCommand("attachment-export"));
260
    QVERIFY(Commands::getCommand("attachment-import"));
261
    QVERIFY(Commands::getCommand("attachment-rm"));
262
    QVERIFY(Commands::getCommand("clip"));
263
    QVERIFY(Commands::getCommand("close"));
264
    QVERIFY(Commands::getCommand("db-create"));
265
    QVERIFY(Commands::getCommand("db-info"));
266
    QVERIFY(Commands::getCommand("diceware"));
267
    QVERIFY(Commands::getCommand("edit"));
268
    QVERIFY(Commands::getCommand("estimate"));
269
    QVERIFY(Commands::getCommand("exit"));
270
    QVERIFY(Commands::getCommand("generate"));
271
    QVERIFY(Commands::getCommand("help"));
272
    QVERIFY(Commands::getCommand("ls"));
273
    QVERIFY(Commands::getCommand("merge"));
274
    QVERIFY(Commands::getCommand("mkdir"));
275
    QVERIFY(Commands::getCommand("mv"));
276
    QVERIFY(Commands::getCommand("open"));
277
    QVERIFY(Commands::getCommand("quit"));
278
    QVERIFY(Commands::getCommand("rm"));
279
    QVERIFY(Commands::getCommand("rmdir"));
280
    QVERIFY(Commands::getCommand("show"));
281
    QVERIFY(Commands::getCommand("search"));
282
    QVERIFY(!Commands::getCommand("doesnotexist"));
283
    QCOMPARE(Commands::getCommands().size(), 26);
284
}
285

286
void TestCli::testAdd()
287
{
288
    Add addCmd;
289
    QVERIFY(!addCmd.name.isEmpty());
290
    QVERIFY(addCmd.getDescriptionLine().contains(addCmd.name));
291

292
    setInput("a");
293
    execCmd(addCmd,
294
            {"add",
295
             "-u",
296
             "newuser",
297
             "--url",
298
             "https://example.com/",
299
             "-g",
300
             "-L",
301
             "20",
302
             "--notes",
303
             "some notes",
304
             m_dbFile->fileName(),
305
             "/newuser-entry"});
306
    m_stderr->readLine(); // Skip password prompt
307
    QCOMPARE(m_stderr->readAll(), QByteArray());
308
    QVERIFY(m_stdout->readAll().contains("Successfully added entry newuser-entry."));
309

310
    auto db = readDatabase();
311
    auto* entry = db->rootGroup()->findEntryByPath("/newuser-entry");
312
    QVERIFY(entry);
313
    QCOMPARE(entry->username(), QString("newuser"));
314
    QCOMPARE(entry->url(), QString("https://example.com/"));
315
    QCOMPARE(entry->password().size(), 20);
316
    QCOMPARE(entry->notes(), QString("some notes"));
317

318
    // Quiet option
319
    setInput("a");
320
    execCmd(addCmd, {"add", "-q", "-u", "newuser", "-g", "-L", "20", m_dbFile->fileName(), "/newentry-quiet"});
321
    QCOMPARE(m_stderr->readAll(), QByteArray());
322
    QCOMPARE(m_stdout->readAll(), QByteArray());
323
    db = readDatabase();
324
    entry = db->rootGroup()->findEntryByPath("/newentry-quiet");
325
    QVERIFY(entry);
326
    QCOMPARE(entry->password().size(), 20);
327

328
    setInput({"a", "newpassword"});
329
    execCmd(addCmd,
330
            {"add", "-u", "newuser2", "--url", "https://example.net/", "-p", m_dbFile->fileName(), "/newuser-entry2"});
331
    QVERIFY(m_stdout->readAll().contains("Successfully added entry newuser-entry2."));
332

333
    db = readDatabase();
334
    entry = db->rootGroup()->findEntryByPath("/newuser-entry2");
335
    QVERIFY(entry);
336
    QCOMPARE(entry->username(), QString("newuser2"));
337
    QCOMPARE(entry->url(), QString("https://example.net/"));
338
    QCOMPARE(entry->password(), QString("newpassword"));
339

340
    // Password generation options
341
    setInput("a");
342
    execCmd(addCmd, {"add", "-u", "newuser3", "-g", "-L", "34", m_dbFile->fileName(), "/newuser-entry3"});
343
    QVERIFY(m_stdout->readAll().contains("Successfully added entry newuser-entry3."));
344

345
    db = readDatabase();
346
    entry = db->rootGroup()->findEntryByPath("/newuser-entry3");
347
    QVERIFY(entry);
348
    QCOMPARE(entry->username(), QString("newuser3"));
349
    QCOMPARE(entry->password().size(), 34);
350
    QRegularExpression defaultPasswordClassesRegex("^[a-zA-Z0-9]+$");
351
    QVERIFY(defaultPasswordClassesRegex.match(entry->password()).hasMatch());
352

353
    setInput("a");
354
    execCmd(addCmd,
355
            {"add",
356
             "-u",
357
             "newuser4",
358
             "-g",
359
             "-L",
360
             "20",
361
             "--every-group",
362
             "-s",
363
             "-n",
364
             "-U",
365
             "-l",
366
             m_dbFile->fileName(),
367
             "/newuser-entry4"});
368
    QVERIFY(m_stdout->readAll().contains("Successfully added entry newuser-entry4."));
369

370
    db = readDatabase();
371
    entry = db->rootGroup()->findEntryByPath("/newuser-entry4");
372
    QVERIFY(entry);
373
    QCOMPARE(entry->username(), QString("newuser4"));
374
    QCOMPARE(entry->password().size(), 20);
375
    QVERIFY(!defaultPasswordClassesRegex.match(entry->password()).hasMatch());
376

377
    setInput("a");
378
    execCmd(addCmd, {"add", "-u", "newuser5", "--notes", "test\\nnew line", m_dbFile->fileName(), "/newuser-entry5"});
379
    m_stderr->readLine(); // skip password prompt
380
    QCOMPARE(m_stderr->readAll(), QByteArray(""));
381
    QCOMPARE(m_stdout->readAll(), QByteArray("Successfully added entry newuser-entry5.\n"));
382

383
    db = readDatabase();
384
    entry = db->rootGroup()->findEntryByPath("/newuser-entry5");
385
    QVERIFY(entry);
386
    QCOMPARE(entry->username(), QString("newuser5"));
387
    QCOMPARE(entry->notes(), QString("test\nnew line"));
388
}
389

390
void TestCli::testAddGroup()
391
{
392
    AddGroup addGroupCmd;
393
    QVERIFY(!addGroupCmd.name.isEmpty());
394
    QVERIFY(addGroupCmd.getDescriptionLine().contains(addGroupCmd.name));
395

396
    setInput("a");
397
    execCmd(addGroupCmd, {"mkdir", m_dbFile->fileName(), "/new_group"});
398
    m_stderr->readLine(); // Skip password prompt
399
    QCOMPARE(m_stderr->readAll(), QByteArray());
400
    QCOMPARE(m_stdout->readAll(), QByteArray("Successfully added group new_group.\n"));
401

402
    auto db = readDatabase();
403
    auto* group = db->rootGroup()->findGroupByPath("new_group");
404
    QVERIFY(group);
405
    QCOMPARE(group->name(), QString("new_group"));
406

407
    // Trying to add the same group should fail.
408
    setInput("a");
409
    execCmd(addGroupCmd, {"mkdir", m_dbFile->fileName(), "/new_group"});
410
    QVERIFY(m_stderr->readAll().contains("Group /new_group already exists!"));
411
    QCOMPARE(m_stdout->readAll(), QByteArray());
412

413
    // Should be able to add groups down the tree.
414
    setInput("a");
415
    execCmd(addGroupCmd, {"mkdir", m_dbFile->fileName(), "/new_group/newer_group"});
416
    QVERIFY(m_stdout->readAll().contains("Successfully added group newer_group."));
417

418
    db = readDatabase();
419
    group = db->rootGroup()->findGroupByPath("new_group/newer_group");
420
    QVERIFY(group);
421
    QCOMPARE(group->name(), QString("newer_group"));
422

423
    // Should fail if the path is invalid.
424
    setInput("a");
425
    execCmd(addGroupCmd, {"mkdir", m_dbFile->fileName(), "/invalid_group/newer_group"});
426
    QVERIFY(m_stderr->readAll().contains("Group /invalid_group not found."));
427
    QCOMPARE(m_stdout->readAll(), QByteArray());
428

429
    // Should fail to add the root group.
430
    setInput("a");
431
    execCmd(addGroupCmd, {"mkdir", m_dbFile->fileName(), "/"});
432
    QVERIFY(m_stderr->readAll().contains("Group / already exists!"));
433
    QCOMPARE(m_stdout->readAll(), QByteArray());
434
}
435

436
void TestCli::testAnalyze()
437
{
438
    Analyze analyzeCmd;
439
    QVERIFY(!analyzeCmd.name.isEmpty());
440
    QVERIFY(analyzeCmd.getDescriptionLine().contains(analyzeCmd.name));
441

442
    const QString hibpPath = QString(KEEPASSX_TEST_DATA_DIR).append("/hibp.txt");
443

444
    setInput("a");
445
    execCmd(analyzeCmd, {"analyze", "--hibp", hibpPath, m_dbFile->fileName()});
446
    auto output = m_stdout->readAll();
447
    QVERIFY(output.contains("Sample Entry"));
448
    QVERIFY(output.contains("123"));
449
    m_stderr->readLine(); // Skip password prompt
450
    QCOMPARE(m_stderr->readAll(), QByteArray());
451
}
452

453
void TestCli::testAttachmentExport()
454
{
455
    AttachmentExport attachmentExportCmd;
456
    QVERIFY(!attachmentExportCmd.name.isEmpty());
457
    QVERIFY(attachmentExportCmd.getDescriptionLine().contains(attachmentExportCmd.name));
458

459
    TemporaryFile exportOutput;
460
    exportOutput.open(QIODevice::WriteOnly);
461
    exportOutput.close();
462

463
    // Try exporting an attachment of a non-existent entry
464
    setInput("a");
465
    execCmd(attachmentExportCmd,
466
            {"attachment-export",
467
             m_dbFile->fileName(),
468
             "invalid_entry_path",
469
             "invalid_attachment_name",
470
             exportOutput.fileName()});
471
    m_stderr->readLine(); // skip password prompt
472
    QCOMPARE(m_stderr->readAll(), QByteArray("Could not find entry with path invalid_entry_path.\n"));
473
    QCOMPARE(m_stdout->readAll(), QByteArray());
474

475
    // Try exporting a non-existent attachment
476
    setInput("a");
477
    execCmd(attachmentExportCmd,
478
            {"attachment-export",
479
             m_dbFile->fileName(),
480
             "/Sample Entry",
481
             "invalid_attachment_name",
482
             exportOutput.fileName()});
483
    m_stderr->readLine(); // skip password prompt
484
    QCOMPARE(m_stderr->readAll(), QByteArray("Could not find attachment with name invalid_attachment_name.\n"));
485
    QCOMPARE(m_stdout->readAll(), QByteArray());
486

487
    // Export an existing attachment to a file
488
    setInput("a");
489
    execCmd(
490
        attachmentExportCmd,
491
        {"attachment-export", m_dbFile->fileName(), "/Sample Entry", "Sample attachment.txt", exportOutput.fileName()});
492
    m_stderr->readLine(); // skip password prompt
493
    QCOMPARE(m_stderr->readAll(), QByteArray());
494
    QCOMPARE(m_stdout->readAll(),
495
             QByteArray(qPrintable(QString("Successfully exported attachment %1 of entry %2 to %3.\n")
496
                                       .arg("Sample attachment.txt", "/Sample Entry", exportOutput.fileName()))));
497

498
    exportOutput.open(QIODevice::ReadOnly);
499
    QCOMPARE(exportOutput.readAll(), QByteArray("Sample content\n"));
500

501
    // Export an existing attachment to stdout
502
    setInput("a");
503
    execCmd(attachmentExportCmd,
504
            {"attachment-export", "--stdout", m_dbFile->fileName(), "/Sample Entry", "Sample attachment.txt"});
505
    m_stderr->readLine(); // skip password prompt
506
    QCOMPARE(m_stderr->readAll(), QByteArray());
507
    QCOMPARE(m_stdout->readAll(), QByteArray("Sample content\n"));
508

509
    // Ensure --stdout works even in quiet mode
510
    setInput("a");
511
    execCmd(
512
        attachmentExportCmd,
513
        {"attachment-export", "--quiet", "--stdout", m_dbFile->fileName(), "/Sample Entry", "Sample attachment.txt"});
514
    m_stderr->readLine(); // skip password prompt
515
    QCOMPARE(m_stderr->readAll(), QByteArray());
516
    QCOMPARE(m_stdout->readAll(), QByteArray("Sample content\n"));
517
}
518

519
void TestCli::testAttachmentImport()
520
{
521
    AttachmentImport attachmentImportCmd;
522
    QVERIFY(!attachmentImportCmd.name.isEmpty());
523
    QVERIFY(attachmentImportCmd.getDescriptionLine().contains(attachmentImportCmd.name));
524

525
    const QString attachmentPath = QString(KEEPASSX_TEST_DATA_DIR).append("/Attachment.txt");
526
    QVERIFY(QFile::exists(attachmentPath));
527

528
    // Try importing an attachment to a non-existent entry
529
    setInput("a");
530
    execCmd(attachmentImportCmd,
531
            {"attachment-import",
532
             m_dbFile->fileName(),
533
             "invalid_entry_path",
534
             "invalid_attachment_name",
535
             "invalid_attachment_path"});
536
    m_stderr->readLine(); // skip password prompt
537
    QCOMPARE(m_stderr->readAll(), QByteArray("Could not find entry with path invalid_entry_path.\n"));
538
    QCOMPARE(m_stdout->readAll(), QByteArray());
539

540
    // Try importing an attachment with an occupied name without -f option
541
    setInput("a");
542
    execCmd(attachmentImportCmd,
543
            {"attachment-import",
544
             m_dbFile->fileName(),
545
             "/Sample Entry",
546
             "Sample attachment.txt",
547
             "invalid_attachment_path"});
548
    m_stderr->readLine(); // skip password prompt
549
    QCOMPARE(m_stderr->readAll(),
550
             QByteArray("Attachment Sample attachment.txt already exists for entry /Sample Entry.\n"));
551
    QCOMPARE(m_stdout->readAll(), QByteArray());
552

553
    // Try importing a non-existent attachment
554
    setInput("a");
555
    execCmd(attachmentImportCmd,
556
            {"attachment-import",
557
             m_dbFile->fileName(),
558
             "/Sample Entry",
559
             "Sample attachment 2.txt",
560
             "invalid_attachment_path"});
561
    m_stderr->readLine(); // skip password prompt
562
    QCOMPARE(m_stderr->readAll(), QByteArray("Could not open attachment file invalid_attachment_path.\n"));
563
    QCOMPARE(m_stdout->readAll(), QByteArray());
564

565
    // Try importing an attachment with an occupied name with -f option
566
    setInput("a");
567
    execCmd(
568
        attachmentImportCmd,
569
        {"attachment-import", "-f", m_dbFile->fileName(), "/Sample Entry", "Sample attachment.txt", attachmentPath});
570
    m_stderr->readLine(); // skip password prompt
571
    QCOMPARE(m_stderr->readAll(), QByteArray());
572
    QCOMPARE(m_stdout->readAll(),
573
             QByteArray(qPrintable(
574
                 QString("Successfully imported attachment %1 as Sample attachment.txt to entry /Sample Entry.\n")
575
                     .arg(attachmentPath))));
576

577
    // Try importing an attachment with an unoccupied name
578
    setInput("a");
579
    execCmd(attachmentImportCmd,
580
            {"attachment-import", m_dbFile->fileName(), "/Sample Entry", "Attachment.txt", attachmentPath});
581
    m_stderr->readLine(); // skip password prompt
582
    QCOMPARE(m_stderr->readAll(), QByteArray());
583
    QCOMPARE(
584
        m_stdout->readAll(),
585
        QByteArray(qPrintable(QString("Successfully imported attachment %1 as Attachment.txt to entry /Sample Entry.\n")
586
                                  .arg(attachmentPath))));
587
}
588

589
void TestCli::testAttachmentRemove()
590
{
591
    AttachmentRemove attachmentRemoveCmd;
592
    QVERIFY(!attachmentRemoveCmd.name.isEmpty());
593
    QVERIFY(attachmentRemoveCmd.getDescriptionLine().contains(attachmentRemoveCmd.name));
594

595
    // Try deleting an attachment belonging to an non-existent entry
596
    setInput("a");
597
    execCmd(attachmentRemoveCmd,
598
            {"attachment-rm", m_dbFile->fileName(), "invalid_entry_path", "invalid_attachment_name"});
599
    m_stderr->readLine(); // skip password prompt
600
    QCOMPARE(m_stderr->readAll(), QByteArray("Could not find entry with path invalid_entry_path.\n"));
601
    QCOMPARE(m_stdout->readAll(), QByteArray());
602

603
    // Try deleting a non-existent attachment from an entry
604
    setInput("a");
605
    execCmd(attachmentRemoveCmd, {"attachment-rm", m_dbFile->fileName(), "/Sample Entry", "invalid_attachment_name"});
606
    m_stderr->readLine(); // skip password prompt
607
    QCOMPARE(m_stderr->readAll(), QByteArray("Could not find attachment with name invalid_attachment_name.\n"));
608
    QCOMPARE(m_stdout->readAll(), QByteArray());
609

610
    // Finally delete an existing attachment from an existing entry
611
    auto db = readDatabase();
612
    QVERIFY(db);
613

614
    const Entry* entry = db->rootGroup()->findEntryByPath("/Sample Entry");
615
    QVERIFY(entry);
616

617
    QVERIFY(entry->attachments()->hasKey("Sample attachment.txt"));
618

619
    setInput("a");
620
    execCmd(attachmentRemoveCmd, {"attachment-rm", m_dbFile->fileName(), "/Sample Entry", "Sample attachment.txt"});
621
    m_stderr->readLine(); // skip password prompt
622
    QCOMPARE(m_stderr->readAll(), QByteArray());
623
    QCOMPARE(m_stdout->readAll(),
624
             QByteArray("Successfully removed attachment Sample attachment.txt from entry /Sample Entry.\n"));
625

626
    db = readDatabase();
627
    QVERIFY(db);
628
    QVERIFY(!db->rootGroup()->findEntryByPath("/Sample Entry")->attachments()->hasKey("Sample attachment.txt"));
629
}
630

631
void TestCli::testClip()
632
{
633
    if (QProcessEnvironment::systemEnvironment().contains("WAYLAND_DISPLAY")) {
634
        QSKIP("Clip test skipped due to QClipboard and Wayland issues on Linux");
635
    }
636

637
    QClipboard* clipboard = QGuiApplication::clipboard();
638
    clipboard->clear();
639

640
    Clip clipCmd;
641
    QVERIFY(!clipCmd.name.isEmpty());
642
    QVERIFY(clipCmd.getDescriptionLine().contains(clipCmd.name));
643

644
    // Password
645
    setInput("a");
646
    execCmd(clipCmd, {"clip", m_dbFile->fileName(), "/Sample Entry", "0"});
647
    QString errorOutput(m_stderr->readAll());
648

649
    if (errorOutput.contains("Unable to start program")
650
        || errorOutput.contains("No program defined for clipboard manipulation")) {
651
        QSKIP("Clip test skipped due to missing clipboard tool");
652
    }
653
    QVERIFY(!errorOutput.contains("All clipping programs failed"));
654

655
    m_stderr->readLine(); // Skip password prompt
656
    QCOMPARE(m_stderr->readAll(), QByteArray());
657
    QTRY_COMPARE(clipboard->text(), QString("Password"));
658
    QCOMPARE(m_stdout->readLine(), QByteArray("Entry's \"Password\" attribute copied to the clipboard!\n"));
659

660
    // Quiet option
661
    setInput("a");
662
    execCmd(clipCmd, {"clip", m_dbFile->fileName(), "/Sample Entry", "0", "-q"});
663
    QCOMPARE(m_stderr->readAll(), QByteArray());
664
    QCOMPARE(m_stdout->readAll(), QByteArray());
665
    QTRY_COMPARE(clipboard->text(), QString("Password"));
666

667
    // Username
668
    setInput("a");
669
    execCmd(clipCmd, {"clip", m_dbFile->fileName(), "/Sample Entry", "0", "-a", "username"});
670
    QTRY_COMPARE(clipboard->text(), QString("User Name"));
671

672
    // Uuid (top-level field)
673
    setInput("a");
674
    execCmd(clipCmd, {"clip", m_dbFile->fileName(), "/Sample Entry", "0", "-a", "Uuid"});
675
    QTRY_COMPARE(clipboard->text(), QString("{9f4544c2-ab00-c74a-8a1a-6eaf26cf57e9}"));
676

677
    // TOTP
678
    setInput("a");
679
    execCmd(clipCmd, {"clip", m_dbFile->fileName(), "/Sample Entry", "0", "--totp"});
680
    QTRY_VERIFY(isTotp(clipboard->text()));
681
    QCOMPARE(m_stdout->readLine(), QByteArray("Entry's \"totp\" attribute copied to the clipboard!\n"));
682

683
    // Test Unicode
684
    setInput("a");
685
    execCmd(clipCmd, {"clip", m_dbFile2->fileName(), "/General/Unicode", "0", "-a", "username"});
686
    QTRY_COMPARE(clipboard->text(), QString(R"(¯\_(ツ)_/¯)"));
687

688
    // Password with timeout
689
    setInput("a");
690
    // clang-format off
691
    QFuture<void> future = QtConcurrent::run(&clipCmd,
692
                                             static_cast<int(Clip::*)(const QStringList&)>(&DatabaseCommand::execute),
693
                                             QStringList{"clip", m_dbFile->fileName(), "/Sample Entry", "1"});
694
    // clang-format on
695

696
    QTRY_COMPARE(clipboard->text(), QString("Password"));
697
    QTRY_COMPARE_WITH_TIMEOUT(clipboard->text(), QString(""), 3000);
698

699
    future.waitForFinished();
700

701
    // TOTP with timeout
702
    setInput("a");
703
    future = QtConcurrent::run(&clipCmd,
704
                               static_cast<int (Clip::*)(const QStringList&)>(&DatabaseCommand::execute),
705
                               QStringList{"clip", m_dbFile->fileName(), "/Sample Entry", "1", "-t"});
706

707
    QTRY_VERIFY(isTotp(clipboard->text()));
708
    QTRY_COMPARE_WITH_TIMEOUT(clipboard->text(), QString(""), 3000);
709

710
    future.waitForFinished();
711

712
    setInput("a");
713
    execCmd(clipCmd, {"clip", m_dbFile->fileName(), "--totp", "/Sample Entry", "bleuh"});
714
    QVERIFY(m_stderr->readAll().contains("Invalid timeout value bleuh.\n"));
715

716
    setInput("a");
717
    execCmd(clipCmd, {"clip", m_dbFile2->fileName(), "--totp", "/Sample Entry", "0"});
718
    QVERIFY(m_stderr->readAll().contains("Entry with path /Sample Entry has no TOTP set up.\n"));
719

720
    setInput("a");
721
    execCmd(clipCmd, {"clip", m_dbFile->fileName(), "-a", "TESTAttribute1", "/Sample Entry", "0"});
722
    QVERIFY(m_stderr->readAll().contains("ERROR: attribute TESTAttribute1 is ambiguous"));
723

724
    setInput("a");
725
    execCmd(clipCmd, {"clip", m_dbFile2->fileName(), "--attribute", "Username", "--totp", "/Sample Entry", "0"});
726
    QVERIFY(m_stderr->readAll().contains("ERROR: Please specify one of --attribute or --totp, not both.\n"));
727

728
    // Best option
729
    setInput("a");
730
    execCmd(clipCmd, {"clip", m_dbFileMulti->fileName(), "Multi", "0", "-b"});
731
    QByteArray errorChoices = m_stderr->readAll();
732
    QVERIFY(errorChoices.contains("Multi Entry 1"));
733
    QVERIFY(errorChoices.contains("Multi Entry 2"));
734

735
    setInput("a");
736
    execCmd(clipCmd, {"clip", m_dbFileMulti->fileName(), "Entry 2", "0", "-b"});
737
    QTRY_COMPARE(clipboard->text(), QString("Password2"));
738
}
739

740
void TestCli::testCreate()
741
{
742
    DatabaseCreate createCmd;
743
    QVERIFY(!createCmd.name.isEmpty());
744
    QVERIFY(createCmd.getDescriptionLine().contains(createCmd.name));
745

746
    QScopedPointer<QTemporaryDir> testDir(new QTemporaryDir());
747
    QString dbFilename;
748

749
    // Testing password option, password mismatch
750
    dbFilename = testDir->path() + "/testCreate_pw.kdbx";
751
    setInput({"a", "b"});
752
    execCmd(createCmd, {"db-create", dbFilename, "-p"});
753

754
    QCOMPARE(m_stderr->readLine(), QByteArray("Enter password to encrypt database (optional): \n"));
755
    QCOMPARE(m_stderr->readLine(), QByteArray("Repeat password: \n"));
756
    QCOMPARE(m_stderr->readLine(), QByteArray("Error: Passwords do not match.\n"));
757
    QCOMPARE(m_stderr->readLine(), QByteArray("Failed to set database password.\n"));
758

759
    // Testing password option
760
    setInput({"a", "a"});
761
    execCmd(createCmd, {"db-create", dbFilename, "-p"});
762

763
    QCOMPARE(m_stderr->readLine(), QByteArray("Enter password to encrypt database (optional): \n"));
764
    QCOMPARE(m_stderr->readLine(), QByteArray("Repeat password: \n"));
765
    QCOMPARE(m_stdout->readLine(), QByteArray("Successfully created new database.\n"));
766

767
    auto db = readDatabase(dbFilename, "a");
768
    QVERIFY(db);
769

770
    // Testing with empty password (deny it)
771
    dbFilename = testDir->path() + "/testCreate_blankpw.kdbx";
772
    setInput({"", "n"});
773
    execCmd(createCmd, {"db-create", dbFilename, "-p"});
774

775
    QCOMPARE(m_stderr->readLine(), QByteArray("Enter password to encrypt database (optional): \n"));
776
    QVERIFY(m_stderr->readLine().contains("empty password"));
777
    QCOMPARE(m_stderr->readLine(), QByteArray("Failed to set database password.\n"));
778

779
    // Testing with empty password (accept it)
780
    setInput({"", "y"});
781
    execCmd(createCmd, {"db-create", dbFilename, "-p"});
782

783
    QCOMPARE(m_stderr->readLine(), QByteArray("Enter password to encrypt database (optional): \n"));
784
    QVERIFY(m_stderr->readLine().contains("empty password"));
785
    QCOMPARE(m_stdout->readLine(), QByteArray("Successfully created new database.\n"));
786

787
    db = readDatabase(dbFilename, "");
788
    QVERIFY(db);
789

790
    // Should refuse to create the database if it already exists.
791
    execCmd(createCmd, {"db-create", dbFilename, "-p"});
792
    // Output should be empty when there is an error.
793
    QCOMPARE(m_stdout->readAll(), QByteArray());
794
    QString errorMessage = QString("File " + dbFilename + " already exists.\n");
795
    QCOMPARE(m_stderr->readAll(), errorMessage.toUtf8());
796

797
    // Should refuse to create without any key provided.
798
    dbFilename = testDir->path() + "/testCreate_key.kdbx";
799
    execCmd(createCmd, {"db-create", dbFilename});
800
    QCOMPARE(m_stdout->readAll(), QByteArray());
801
    QCOMPARE(m_stderr->readLine(), QByteArray("No key is set. Aborting database creation.\n"));
802

803
    // Testing with keyfile creation
804
    dbFilename = testDir->path() + "/testCreate_key2.kdbx";
805
    QString keyfilePath = testDir->path() + "/keyfile.txt";
806
    setInput({"a", "a"});
807
    execCmd(createCmd, {"db-create", dbFilename, "-p", "-k", keyfilePath});
808

809
    QCOMPARE(m_stderr->readLine(), QByteArray("Enter password to encrypt database (optional): \n"));
810
    QCOMPARE(m_stderr->readLine(), QByteArray("Repeat password: \n"));
811
    QCOMPARE(m_stdout->readLine(), QByteArray("Successfully created new database.\n"));
812

813
    db = readDatabase(dbFilename, "a", keyfilePath);
814
    QVERIFY(db);
815

816
    // Testing with existing keyfile
817
    dbFilename = testDir->path() + "/testCreate_key3.kdbx";
818
    setInput({"a", "a"});
819
    execCmd(createCmd, {"db-create", dbFilename, "-p", "-k", keyfilePath});
820

821
    QCOMPARE(m_stderr->readLine(), QByteArray("Enter password to encrypt database (optional): \n"));
822
    QCOMPARE(m_stderr->readLine(), QByteArray("Repeat password: \n"));
823
    QCOMPARE(m_stdout->readLine(), QByteArray("Successfully created new database.\n"));
824

825
    db = readDatabase(dbFilename, "a", keyfilePath);
826
    QVERIFY(db);
827

828
    // Invalid decryption time (format).
829
    dbFilename = testDir->path() + "/testCreate_time.kdbx";
830
    execCmd(createCmd, {"db-create", dbFilename, "-p", "-t", "NAN"});
831

832
    QCOMPARE(m_stdout->readAll(), QByteArray());
833
    QCOMPARE(m_stderr->readAll(), QByteArray("Invalid decryption time NAN.\n"));
834

835
    // Invalid decryption time (range).
836
    execCmd(createCmd, {"db-create", dbFilename, "-p", "-t", "10"});
837

838
    QCOMPARE(m_stdout->readAll(), QByteArray());
839
    QVERIFY(m_stderr->readAll().contains(QByteArray("Target decryption time must be between")));
840

841
    int encryptionTime = 500;
842
    // Custom encryption time
843
    setInput({"a", "a"});
844
    int epochBefore = QDateTime::currentMSecsSinceEpoch();
845
    execCmd(createCmd, {"db-create", dbFilename, "-p", "-t", QString::number(encryptionTime)});
846
    // Removing 100ms to make sure we account for changes in computation time.
847
    QVERIFY(QDateTime::currentMSecsSinceEpoch() > (epochBefore + encryptionTime - 100));
848

849
    QCOMPARE(m_stderr->readLine(), QByteArray("Enter password to encrypt database (optional): \n"));
850
    QCOMPARE(m_stderr->readLine(), QByteArray("Repeat password: \n"));
851
    QCOMPARE(m_stdout->readLine(), QByteArray("Benchmarking key derivation function for 500ms delay.\n"));
852
    QVERIFY(m_stdout->readLine().contains(QByteArray("rounds for key derivation function.\n")));
853

854
    db = readDatabase(dbFilename, "a");
855
    QVERIFY(db);
856
}
857

858
void TestCli::testDatabaseEdit()
859
{
860
    TemporaryFile firstKeyFile;
861
    firstKeyFile.open();
862
    firstKeyFile.write(QString("keyFilePassword").toLatin1());
863
    firstKeyFile.close();
864

865
    TemporaryFile secondKeyFile;
866
    secondKeyFile.open();
867
    secondKeyFile.write(QString("newKeyFilePassword").toLatin1());
868
    secondKeyFile.close();
869

870
    QScopedPointer<QTemporaryDir> testDir(new QTemporaryDir());
871

872
    DatabaseCreate createCmd;
873
    DatabaseEdit editCmd;
874
    QVERIFY(!editCmd.name.isEmpty());
875
    QVERIFY(editCmd.getDescriptionLine().contains(editCmd.name));
876

877
    QString dbFilename;
878
    dbFilename = testDir->path() + "/testDatabaseEdit.kdbx";
879

880
    // Creating a database for testing
881
    setInput({"a", "a"});
882
    execCmd(createCmd, {"db-create", dbFilename, "-p"});
883
    QCOMPARE(m_stdout->readLine(), QByteArray("Successfully created new database.\n"));
884

885
    // Sanity check.
886
    auto db = readDatabase(dbFilename, "a");
887
    QVERIFY(!db.isNull());
888

889
    setInput("a");
890
    execCmd(editCmd, {"db-edit", dbFilename, "-p", "--unset-password"});
891
    QCOMPARE(m_stdout->readAll(), QByteArray(""));
892
    m_stderr->readLine();
893
    QCOMPARE(m_stderr->readAll(), QByteArray("Cannot use p and unset-password at the same time.\n"));
894

895
    setInput("a");
896
    execCmd(editCmd, {"db-edit", dbFilename, "--set-key-file", "/key/file/path", "--unset-key-file"});
897
    QCOMPARE(m_stdout->readAll(), QByteArray(""));
898
    // Skipping the password prompt.
899
    m_stderr->readLine();
900
    QCOMPARE(m_stderr->readAll(), QByteArray("Cannot use set-key-file and unset-key-file at the same time.\n"));
901

902
    // Sanity check.
903
    db = readDatabase(dbFilename, "a");
904
    QVERIFY(!db.isNull());
905

906
    setInput({"a", "b", "b"});
907
    execCmd(editCmd, {"db-edit", dbFilename, "-p"});
908
    QCOMPARE(m_stdout->readAll(), QByteArray("Successfully edited the database.\n"));
909

910
    // Sanity check
911
    db = readDatabase(dbFilename, "b");
912
    QVERIFY(!db.isNull());
913

914
    setInput("b");
915
    execCmd(editCmd, {"db-edit", dbFilename, "--set-key-file", firstKeyFile.fileName()});
916
    // Skipping the password prompt.
917
    m_stderr->readLine();
918
    QCOMPARE(m_stderr->readAll(), QByteArray(""));
919
    QCOMPARE(m_stdout->readAll(), QByteArray("Successfully edited the database.\n"));
920

921
    // Sanity check
922
    db = readDatabase(dbFilename, "b");
923
    QVERIFY(db.isNull());
924
    db = readDatabase(dbFilename, "b", firstKeyFile.fileName());
925
    QVERIFY(!db.isNull());
926

927
    setInput("b");
928
    execCmd(editCmd,
929
            {"db-edit", dbFilename, "-k", firstKeyFile.fileName(), "--set-key-file", secondKeyFile.fileName()});
930
    QCOMPARE(m_stdout->readAll(), QByteArray("Successfully edited the database.\n"));
931

932
    // Sanity check
933
    db = readDatabase(dbFilename, "b", firstKeyFile.fileName());
934
    QVERIFY(db.isNull());
935
    db = readDatabase(dbFilename, "b", secondKeyFile.fileName());
936
    QVERIFY(!db.isNull());
937

938
    setInput("b");
939
    execCmd(editCmd, {"db-edit", dbFilename, "-k", secondKeyFile.fileName(), "--unset-password"});
940
    // Skipping the password prompt.
941
    m_stderr->readLine();
942
    QCOMPARE(m_stderr->readAll(), QByteArray(""));
943
    QCOMPARE(m_stdout->readAll(), QByteArray("Successfully edited the database.\n"));
944

945
    execCmd(editCmd,
946
            {"db-edit",
947
             dbFilename,
948
             "--no-password",
949
             "-k",
950
             secondKeyFile.fileName(),
951
             "--set-key-file",
952
             firstKeyFile.fileName()});
953
    // Skipping the password prompt.
954
    m_stderr->readLine();
955
    QCOMPARE(m_stderr->readAll(), QByteArray(""));
956
    QCOMPARE(m_stdout->readAll(), QByteArray("Successfully edited the database.\n"));
957

958
    setInput({"b", "b"});
959
    execCmd(editCmd, {"db-edit", dbFilename, "-k", firstKeyFile.fileName(), "--no-password", "--set-password"});
960
    // Skipping over the password setting prompts.
961
    m_stderr->readLine();
962
    m_stderr->readLine();
963
    QCOMPARE(m_stderr->readAll(), QByteArray(""));
964
    QCOMPARE(m_stdout->readAll(), QByteArray("Successfully edited the database.\n"));
965

966
    setInput("b");
967
    execCmd(editCmd, {"db-edit", dbFilename, "-k", firstKeyFile.fileName(), "--unset-key-file"});
968
    // Skipping the password prompt.
969
    m_stderr->readLine();
970
    QCOMPARE(m_stderr->readAll(), QByteArray(""));
971
    QCOMPARE(m_stdout->readAll(), QByteArray("Successfully edited the database.\n"));
972

973
    // Sanity check
974
    db = readDatabase(dbFilename, "b", firstKeyFile.fileName());
975
    QVERIFY(db.isNull());
976
    db = readDatabase(dbFilename, "b");
977
    QVERIFY(!db.isNull());
978

979
    // Trying to remove the key file when there is none set should
980
    // raise an error.
981
    setInput("b");
982
    execCmd(editCmd, {"db-edit", dbFilename, "-p", "--unset-key-file"});
983
    QCOMPARE(m_stdout->readAll(), QByteArray(""));
984
    m_stderr->readLine();
985
    QCOMPARE(m_stderr->readLine(), QByteArray("Cannot remove file key: The database does not have a file key.\n"));
986
    QCOMPARE(m_stderr->readLine(), QByteArray("Could not change the database key.\n"));
987

988
    setInput("b");
989
    execCmd(editCmd, {"db-edit", dbFilename, "--unset-password"});
990
    QCOMPARE(m_stdout->readAll(), QByteArray(""));
991
    // Skipping the password prompt.
992
    m_stderr->readLine();
993
    QCOMPARE(m_stderr->readLine(), QByteArray("Cannot remove all the keys from a database.\n"));
994
}
995

996
void TestCli::testInfo()
997
{
998
    DatabaseInfo infoCmd;
999
    QVERIFY(!infoCmd.name.isEmpty());
1000
    QVERIFY(infoCmd.getDescriptionLine().contains(infoCmd.name));
1001

1002
    setInput("a");
1003
    execCmd(infoCmd, {"db-info", m_dbFile->fileName()});
1004
    m_stderr->readLine(); // Skip password prompt
1005
    QCOMPARE(m_stderr->readAll(), QByteArray());
1006
    QVERIFY(m_stdout->readLine().contains(QByteArray("UUID: ")));
1007
    QCOMPARE(m_stdout->readLine(), QByteArray("Name: \n"));
1008
    QCOMPARE(m_stdout->readLine(), QByteArray("Description: \n"));
1009
    QCOMPARE(m_stdout->readLine(), QByteArray("Cipher: AES 256-bit\n"));
1010
    QCOMPARE(m_stdout->readLine(), QByteArray("KDF: AES (6000 rounds)\n"));
1011
    QCOMPARE(m_stdout->readLine(), QByteArray("Recycle bin is enabled.\n"));
1012
    QVERIFY(m_stdout->readLine().contains(m_dbFile->fileName().toUtf8()));
1013
    QVERIFY(m_stdout->readLine().contains(
1014
        QByteArray("Database created: "))); // date changes often, so just test for the first part
1015
    QVERIFY(m_stdout->readLine().contains(
1016
        QByteArray("Last saved: "))); // date changes often, so just test for the first part
1017
    QCOMPARE(m_stdout->readLine(), QByteArray("Unsaved changes: no\n"));
1018
    QCOMPARE(m_stdout->readLine(), QByteArray("Number of groups: 8\n"));
1019
    QCOMPARE(m_stdout->readLine(), QByteArray("Number of entries: 2\n"));
1020
    QCOMPARE(m_stdout->readLine(), QByteArray("Number of expired entries: 0\n"));
1021
    QCOMPARE(m_stdout->readLine(), QByteArray("Unique passwords: 2\n"));
1022
    QCOMPARE(m_stdout->readLine(), QByteArray("Non-unique passwords: 0\n"));
1023
    QCOMPARE(m_stdout->readLine(), QByteArray("Maximum password reuse: 1\n"));
1024
    QCOMPARE(m_stdout->readLine(), QByteArray("Number of short passwords: 0\n"));
1025
    QCOMPARE(m_stdout->readLine(), QByteArray("Number of weak passwords: 2\n"));
1026
    QCOMPARE(m_stdout->readLine(), QByteArray("Entries excluded from reports: 0\n"));
1027
    QCOMPARE(m_stdout->readLine(), QByteArray("Average password length: 11 characters\n"));
1028

1029
    // Test with quiet option.
1030
    setInput("a");
1031
    execCmd(infoCmd, {"db-info", "-q", m_dbFile->fileName()});
1032
    QCOMPARE(m_stderr->readAll(), QByteArray());
1033
    QVERIFY(m_stdout->readLine().contains(QByteArray("UUID: ")));
1034
    QCOMPARE(m_stdout->readLine(), QByteArray("Name: \n"));
1035
    QCOMPARE(m_stdout->readLine(), QByteArray("Description: \n"));
1036
    QCOMPARE(m_stdout->readLine(), QByteArray("Cipher: AES 256-bit\n"));
1037
    QCOMPARE(m_stdout->readLine(), QByteArray("KDF: AES (6000 rounds)\n"));
1038
    QCOMPARE(m_stdout->readLine(), QByteArray("Recycle bin is enabled.\n"));
1039
}
1040

1041
void TestCli::testDiceware()
1042
{
1043
    Diceware dicewareCmd;
1044
    QVERIFY(!dicewareCmd.name.isEmpty());
1045
    QVERIFY(dicewareCmd.getDescriptionLine().contains(dicewareCmd.name));
1046

1047
    execCmd(dicewareCmd, {"diceware"});
1048
    QString passphrase(m_stdout->readLine());
1049
    QVERIFY(!passphrase.isEmpty());
1050

1051
    execCmd(dicewareCmd, {"diceware", "-W", "2"});
1052
    passphrase = m_stdout->readLine();
1053
    QCOMPARE(passphrase.split(" ").size(), 2);
1054

1055
    execCmd(dicewareCmd, {"diceware", "-W", "10"});
1056
    passphrase = m_stdout->readLine();
1057
    QCOMPARE(passphrase.split(" ").size(), 10);
1058

1059
    // Testing with invalid word count
1060
    execCmd(dicewareCmd, {"diceware", "-W", "-10"});
1061
    QCOMPARE(m_stderr->readLine(), QByteArray("Invalid word count -10\n"));
1062

1063
    // Testing with invalid word count format
1064
    execCmd(dicewareCmd, {"diceware", "-W", "bleuh"});
1065
    QCOMPARE(m_stderr->readLine(), QByteArray("Invalid word count bleuh\n"));
1066

1067
    TemporaryFile wordFile;
1068
    wordFile.open();
1069
    for (int i = 0; i < 4500; ++i) {
1070
        wordFile.write(QString("word" + QString::number(i) + "\n").toLatin1());
1071
    }
1072
    wordFile.close();
1073

1074
    execCmd(dicewareCmd, {"diceware", "-W", "11", "-w", wordFile.fileName()});
1075
    passphrase = m_stdout->readLine();
1076
    const auto words = passphrase.split(" ");
1077
    QCOMPARE(words.size(), 11);
1078
    QRegularExpression regex("^word\\d+$");
1079
    for (const auto& word : words) {
1080
        QVERIFY2(regex.match(word).hasMatch(), qPrintable("Word " + word + " was not on the word list"));
1081
    }
1082

1083
    TemporaryFile smallWordFile;
1084
    smallWordFile.open();
1085
    for (int i = 0; i < 50; ++i) {
1086
        smallWordFile.write(QString("word" + QString::number(i) + "\n").toLatin1());
1087
    }
1088
    smallWordFile.close();
1089

1090
    execCmd(dicewareCmd, {"diceware", "-W", "11", "-w", smallWordFile.fileName()});
1091
    QCOMPARE(m_stderr->readLine(), QByteArray("The word list is too small (< 1000 items)\n"));
1092
}
1093

1094
void TestCli::testEdit()
1095
{
1096
    Edit editCmd;
1097
    QVERIFY(!editCmd.name.isEmpty());
1098
    QVERIFY(editCmd.getDescriptionLine().contains(editCmd.name));
1099

1100
    setInput("a");
1101
    execCmd(editCmd,
1102
            {"edit",
1103
             "-u",
1104
             "newuser",
1105
             "--url",
1106
             "https://otherurl.example.com/",
1107
             "--notes",
1108
             "newnotes",
1109
             "-t",
1110
             "newtitle",
1111
             m_dbFile->fileName(),
1112
             "/Sample Entry"});
1113
    QCOMPARE(m_stdout->readLine(), QByteArray("Successfully edited entry newtitle.\n"));
1114

1115
    auto db = readDatabase();
1116
    auto* entry = db->rootGroup()->findEntryByPath("/newtitle");
1117
    QVERIFY(entry);
1118
    QCOMPARE(entry->username(), QString("newuser"));
1119
    QCOMPARE(entry->url(), QString("https://otherurl.example.com/"));
1120
    QCOMPARE(entry->password(), QString("Password"));
1121
    QCOMPARE(entry->notes(), QString("newnotes"));
1122

1123
    // Quiet option
1124
    setInput("a");
1125
    execCmd(editCmd, {"edit", m_dbFile->fileName(), "-q", "-t", "newertitle", "/newtitle"});
1126
    QCOMPARE(m_stderr->readAll(), QByteArray());
1127
    QCOMPARE(m_stdout->readAll(), QByteArray());
1128

1129
    setInput("a");
1130
    execCmd(editCmd, {"edit", "-g", m_dbFile->fileName(), "/newertitle"});
1131
    db = readDatabase();
1132
    entry = db->rootGroup()->findEntryByPath("/newertitle");
1133
    QVERIFY(entry);
1134
    QCOMPARE(entry->username(), QString("newuser"));
1135
    QCOMPARE(entry->url(), QString("https://otherurl.example.com/"));
1136
    QVERIFY(!entry->password().isEmpty());
1137
    QVERIFY(entry->password() != QString("Password"));
1138

1139
    setInput("a");
1140
    execCmd(editCmd, {"edit", "-g", "-L", "34", "-t", "evennewertitle", m_dbFile->fileName(), "/newertitle"});
1141
    db = readDatabase();
1142
    entry = db->rootGroup()->findEntryByPath("/evennewertitle");
1143
    QVERIFY(entry);
1144
    QCOMPARE(entry->username(), QString("newuser"));
1145
    QCOMPARE(entry->url(), QString("https://otherurl.example.com/"));
1146
    QVERIFY(entry->password() != QString("Password"));
1147
    QCOMPARE(entry->password().size(), 34);
1148
    QRegularExpression defaultPasswordClassesRegex("^[a-zA-Z0-9]+$");
1149
    QVERIFY(defaultPasswordClassesRegex.match(entry->password()).hasMatch());
1150

1151
    setInput("a");
1152
    execCmd(editCmd,
1153
            {"edit",
1154
             "-g",
1155
             "-L",
1156
             "20",
1157
             "--every-group",
1158
             "-s",
1159
             "-n",
1160
             "--upper",
1161
             "-l",
1162
             m_dbFile->fileName(),
1163
             "/evennewertitle"});
1164
    QCOMPARE(m_stdout->readAll(), QByteArray("Successfully edited entry evennewertitle.\n"));
1165

1166
    db = readDatabase();
1167
    entry = db->rootGroup()->findEntryByPath("/evennewertitle");
1168
    QVERIFY(entry);
1169
    QCOMPARE(entry->password().size(), 20);
1170
    QVERIFY(!defaultPasswordClassesRegex.match(entry->password()).hasMatch());
1171

1172
    setInput({"a", "newpassword"});
1173
    execCmd(editCmd, {"edit", "-p", m_dbFile->fileName(), "/evennewertitle"});
1174
    db = readDatabase();
1175
    QVERIFY(db);
1176
    entry = db->rootGroup()->findEntryByPath("/evennewertitle");
1177
    QVERIFY(entry);
1178
    QCOMPARE(entry->password(), QString("newpassword"));
1179

1180
    // with line break in notes
1181
    setInput("a");
1182
    execCmd(editCmd, {"edit", m_dbFile->fileName(), "--notes", "testing\\nline breaks", "/evennewertitle"});
1183
    db = readDatabase();
1184
    entry = db->rootGroup()->findEntryByPath("/evennewertitle");
1185
    QVERIFY(entry);
1186
    QCOMPARE(entry->notes(), QString("testing\nline breaks"));
1187
}
1188

1189
void TestCli::testEstimate_data()
1190
{
1191
    // clang-format off
1192
    QTest::addColumn<QString>("input");
1193
    QTest::addColumn<QStringList>("searchStrings");
1194

1195
    QTest::newRow("Dictionary")
1196
        << "password"
1197
        << QStringList{"Type: Dictionary", "\tpassword"};
1198

1199
    QTest::newRow("Spatial")
1200
        << "sdfg"
1201
        << QStringList{"Type: Spatial", "\tsdfg"};
1202

1203
    QTest::newRow("Spatial(Rep)")
1204
        << "sdfgsdfg"
1205
        << QStringList{"Type: Spatial(Rep)", "\tsdfgsdfg"};
1206

1207
    QTest::newRow("Dictionary / Sequence")
1208
        << "password123"
1209
        << QStringList{"Type: Dictionary", "Type: Sequence", "\tpassword", "\t123"};
1210

1211
    QTest::newRow("Dict+Leet")
1212
        << "p455w0rd"
1213
        << QStringList{"Type: Dict+Leet", "\tp455w0rd"};
1214

1215
    QTest::newRow("Dictionary(Rep)")
1216
        << "hellohello"
1217
        << QStringList{"Type: Dictionary(Rep)", "\thellohello"};
1218

1219
    QTest::newRow("Sequence(Rep) / Dictionary")
1220
        << "456456foobar"
1221
        << QStringList{"Type: Sequence(Rep)", "Type: Dictionary", "\t456456", "\tfoobar"};
1222

1223
    QTest::newRow("Bruteforce(Rep) / Bruteforce")
1224
        << "xzxzy"
1225
        << QStringList{"Type: Bruteforce(Rep)", "Type: Bruteforce", "\txzxz", "\ty"};
1226

1227
    QTest::newRow("Dictionary / Date(Rep)")
1228
        << "pass20182018"
1229
        << QStringList{"Type: Dictionary", "Type: Date(Rep)", "\tpass", "\t20182018"};
1230

1231
    QTest::newRow("Dictionary / Date / Bruteforce")
1232
        << "mypass2018-2"
1233
        << QStringList{"Type: Dictionary", "Type: Date", "Type: Bruteforce", "\tmypass", "\t2018", "\t-2"};
1234

1235
    QTest::newRow("Strong Password")
1236
        << "E*!%.Qw{t.X,&bafw)\"Q!ah$%;U/"
1237
        << QStringList{"Type: Bruteforce", "\tE*"};
1238

1239
    // TODO: detect passphrases and adjust entropy calculation accordingly (issue #2347)
1240
    QTest::newRow("Strong Passphrase")
1241
        << "squint wooing resupply dangle isolation axis headsman"
1242
        << QStringList{"Type: Dictionary", "Type: Bruteforce", "Multi-word extra bits 22.0", "\tsquint", "\t ", "\twooing"};
1243
    // clang-format on
1244
}
1245

1246
void TestCli::testEstimate()
1247
{
1248
    QFETCH(QString, input);
1249
    QFETCH(QStringList, searchStrings);
1250

1251
    // Calculate expected values since zxcvbn output can vary by platform if different wordlists are used
1252
    const auto e = ZxcvbnMatch(input.toUtf8(), nullptr, nullptr);
1253
    auto length = QString::number(input.length());
1254
    auto entropy = QString("%1").arg(e, 0, 'f', 3);
1255
    auto log10 = QString("%1").arg(e * 0.301029996, 0, 'f', 3);
1256

1257
    Estimate estimateCmd;
1258
    QVERIFY(!estimateCmd.name.isEmpty());
1259
    QVERIFY(estimateCmd.getDescriptionLine().contains(estimateCmd.name));
1260

1261
    setInput(input);
1262
    execCmd(estimateCmd, {"estimate", "-a"});
1263
    auto result = QString(m_stdout->readAll());
1264
    QVERIFY(result.contains("Length " + length));
1265
    QVERIFY(result.contains("Entropy " + entropy));
1266
    QVERIFY(result.contains("Log10 " + log10));
1267
    for (const auto& string : asConst(searchStrings)) {
1268
        QVERIFY2(result.contains(string), qPrintable("String " + string + " missing"));
1269
    }
1270
}
1271

1272
void TestCli::testExport()
1273
{
1274
    Export exportCmd;
1275
    QVERIFY(!exportCmd.name.isEmpty());
1276
    QVERIFY(exportCmd.getDescriptionLine().contains(exportCmd.name));
1277

1278
    setInput("a");
1279
    execCmd(exportCmd, {"export", m_dbFile->fileName()});
1280

1281
    TemporaryFile xmlOutput;
1282
    xmlOutput.open(QIODevice::WriteOnly);
1283
    xmlOutput.write(m_stdout->readAll());
1284
    xmlOutput.close();
1285

1286
    QScopedPointer<Database> db(new Database());
1287
    QVERIFY(db->import(xmlOutput.fileName()));
1288

1289
    auto* entry = db->rootGroup()->findEntryByPath("/Sample Entry");
1290
    QVERIFY(entry);
1291
    QCOMPARE(entry->password(), QString("Password"));
1292

1293
    // Quiet option
1294
    QScopedPointer<Database> dbQuiet(new Database());
1295
    setInput("a");
1296
    execCmd(exportCmd, {"export", "-f", "xml", "-q", m_dbFile->fileName()});
1297
    QCOMPARE(m_stderr->readAll(), QByteArray());
1298

1299
    xmlOutput.open(QIODevice::WriteOnly);
1300
    xmlOutput.write(m_stdout->readAll());
1301
    xmlOutput.close();
1302

1303
    QVERIFY(db->import(xmlOutput.fileName()));
1304

1305
    // CSV exporting
1306
    setInput("a");
1307
    execCmd(exportCmd, {"export", "-f", "csv", m_dbFile->fileName()});
1308
    QByteArray csvHeader = m_stdout->readLine();
1309
    QVERIFY(csvHeader.contains(QByteArray("\"Group\",\"Title\",\"Username\",\"Password\",\"URL\",\"Notes\"")));
1310
    QByteArray csvData = m_stdout->readAll();
1311
    QVERIFY(csvData.contains(QByteArray(
1312
        "\"NewDatabase\",\"Sample Entry\",\"User Name\",\"Password\",\"http://www.somesite.com/\",\"Notes\"")));
1313

1314
    // test invalid format
1315
    setInput("a");
1316
    execCmd(exportCmd, {"export", "-f", "yaml", m_dbFile->fileName()});
1317
    m_stderr->readLine(); // Skip password prompt
1318
    QCOMPARE(m_stderr->readLine(), QByteArray("Unsupported format yaml\n"));
1319
}
1320

1321
void TestCli::testGenerate_data()
1322
{
1323
    QTest::addColumn<QStringList>("parameters");
1324
    QTest::addColumn<QString>("pattern");
1325

1326
    QTest::newRow("default") << QStringList{"generate"} << "^[^\r\n]+$";
1327
    QTest::newRow("length") << QStringList{"generate", "-L", "13"} << "^.{13}$";
1328
    QTest::newRow("lowercase") << QStringList{"generate", "-L", "14", "-l"} << "^[a-z]{14}$";
1329
    QTest::newRow("uppercase") << QStringList{"generate", "-L", "15", "--upper"} << "^[A-Z]{15}$";
1330
    QTest::newRow("numbers") << QStringList{"generate", "-L", "16", "-n"} << "^[0-9]{16}$";
1331
    QTest::newRow("special") << QStringList{"generate", "-L", "200", "-s"}
1332
                             << R"(^[\(\)\[\]\{\}\.\-*|\\,:;"'\/\_!+-<=>?#$%&^`@~]{200}$)";
1333
    QTest::newRow("special (exclude)") << QStringList{"generate", "-L", "200", "-s", "-x", "+.?@&"}
1334
                                       << R"(^[\(\)\[\]\{\}\.\-*|\\,:;"'\/\_!-<=>#$%^`~]{200}$)";
1335
    QTest::newRow("extended") << QStringList{"generate", "-L", "50", "-e"}
1336
                              << R"(^[^a-zA-Z0-9\(\)\[\]\{\}\.\-\*\|\\,:;"'\/\_!+-<=>?#$%&^`@~]{50}$)";
1337
    QTest::newRow("numbers + lowercase + uppercase")
1338
        << QStringList{"generate", "-L", "16", "-n", "--upper", "-l"} << "^[0-9a-zA-Z]{16}$";
1339
    QTest::newRow("numbers + lowercase + uppercase (exclude)")
1340
        << QStringList{"generate", "-L", "500", "-n", "-U", "-l", "-x", "abcdefg0123@"} << "^[^abcdefg0123@]{500}$";
1341
    QTest::newRow("numbers + lowercase + uppercase (exclude similar)")
1342
        << QStringList{"generate", "-L", "200", "-n", "-U", "-l", "--exclude-similar"} << "^[^l1IO0]{200}$";
1343
    QTest::newRow("uppercase + lowercase (every)")
1344
        << QStringList{"generate", "-L", "2", "--upper", "-l", "--every-group"} << "^[a-z][A-Z]|[A-Z][a-z]$";
1345
    QTest::newRow("numbers + lowercase (every)")
1346
        << QStringList{"generate", "-L", "2", "-n", "-l", "--every-group"} << "^[a-z][0-9]|[0-9][a-z]$";
1347
    QTest::newRow("custom character set")
1348
        << QStringList{"generate", "-L", "200", "-n", "-c", "abc"} << "^[abc0-9]{200}$";
1349
    QTest::newRow("custom character set without extra options uses only custom chars")
1350
        << QStringList{"generate", "-L", "200", "-c", "a"} << "^a{200}$";
1351
}
1352

1353
void TestCli::testGenerate()
1354
{
1355
    QFETCH(QStringList, parameters);
1356
    QFETCH(QString, pattern);
1357

1358
    Generate generateCmd;
1359
    QVERIFY(!generateCmd.name.isEmpty());
1360
    QVERIFY(generateCmd.getDescriptionLine().contains(generateCmd.name));
1361

1362
    for (int i = 0; i < 10; ++i) {
1363
        execCmd(generateCmd, parameters);
1364
        QRegularExpression regex(pattern);
1365
#ifdef Q_OS_UNIX
1366
        QString password = QString::fromUtf8(m_stdout->readLine());
1367
#else
1368
        QString password = QString::fromLatin1(m_stdout->readLine());
1369
#endif
1370

1371
        QVERIFY2(regex.match(password).hasMatch(),
1372
                 qPrintable("Password " + password + " does not match pattern " + pattern));
1373
        QCOMPARE(m_stderr->readAll(), QByteArray());
1374
    }
1375

1376
    // Testing with invalid password length
1377
    execCmd(generateCmd, {"generate", "-L", "-10"});
1378
    QCOMPARE(m_stderr->readLine(), QByteArray("Invalid password length -10\n"));
1379

1380
    execCmd(generateCmd, {"generate", "-L", "0"});
1381
    QCOMPARE(m_stderr->readLine(), QByteArray("Invalid password length 0\n"));
1382

1383
    // Testing with invalid word count format
1384
    execCmd(generateCmd, {"generate", "-L", "bleuh"});
1385
    QCOMPARE(m_stderr->readLine(), QByteArray("Invalid password length bleuh\n"));
1386
}
1387

1388
void TestCli::testImport()
1389
{
1390
    Import importCmd;
1391
    QVERIFY(!importCmd.name.isEmpty());
1392
    QVERIFY(importCmd.getDescriptionLine().contains(importCmd.name));
1393

1394
    QScopedPointer<QTemporaryDir> testDir(new QTemporaryDir());
1395
    QString databaseFilename = testDir->path() + "/testImport1.kdbx";
1396

1397
    setInput({"a", "a"});
1398
    execCmd(importCmd, {"import", m_xmlFile->fileName(), databaseFilename, "-p"});
1399

1400
    QCOMPARE(m_stderr->readLine(), QByteArray("Enter password to encrypt database (optional): \n"));
1401
    QCOMPARE(m_stderr->readLine(), QByteArray("Repeat password: \n"));
1402
    QCOMPARE(m_stdout->readLine(), QByteArray("Successfully imported database.\n"));
1403

1404
    auto db = readDatabase(databaseFilename, "a");
1405
    QVERIFY(db);
1406
    auto* entry = db->rootGroup()->findEntryByPath("/Sample Entry 1");
1407
    QVERIFY(entry);
1408
    QCOMPARE(entry->username(), QString("User Name"));
1409

1410
    // Should refuse to create the database if it already exists.
1411
    execCmd(importCmd, {"import", m_xmlFile->fileName(), databaseFilename});
1412
    // Output should be empty when there is an error.
1413
    QCOMPARE(m_stdout->readAll(), QByteArray());
1414
    QString errorMessage = QString("File " + databaseFilename + " already exists.\n");
1415
    QCOMPARE(m_stderr->readAll(), errorMessage.toUtf8());
1416

1417
    // Testing import with non-existing keyfile
1418
    databaseFilename = testDir->path() + "/testImport2.kdbx";
1419
    QString keyfilePath = testDir->path() + "/keyfile.txt";
1420
    setInput({"a", "a"});
1421
    execCmd(importCmd, {"import", "-p", "-k", keyfilePath, m_xmlFile->fileName(), databaseFilename});
1422

1423
    QCOMPARE(m_stderr->readLine(), QByteArray("Enter password to encrypt database (optional): \n"));
1424
    QCOMPARE(m_stderr->readLine(), QByteArray("Repeat password: \n"));
1425
    QCOMPARE(m_stdout->readLine(), QByteArray("Successfully imported database.\n"));
1426

1427
    db = readDatabase(databaseFilename, "a", keyfilePath);
1428
    QVERIFY(db);
1429

1430
    // Testing import with existing keyfile
1431
    databaseFilename = testDir->path() + "/testImport3.kdbx";
1432
    setInput({"a", "a"});
1433
    execCmd(importCmd, {"import", "-p", "-k", keyfilePath, m_xmlFile->fileName(), databaseFilename});
1434

1435
    QCOMPARE(m_stderr->readLine(), QByteArray("Enter password to encrypt database (optional): \n"));
1436
    QCOMPARE(m_stderr->readLine(), QByteArray("Repeat password: \n"));
1437
    QCOMPARE(m_stdout->readLine(), QByteArray("Successfully imported database.\n"));
1438

1439
    db = readDatabase(databaseFilename, "a", keyfilePath);
1440
    QVERIFY(db);
1441

1442
    // Invalid decryption time (format).
1443
    databaseFilename = testDir->path() + "/testCreate_time.kdbx";
1444
    execCmd(importCmd, {"import", "-p", "-t", "NAN", m_xmlFile->fileName(), databaseFilename});
1445

1446
    QCOMPARE(m_stdout->readAll(), QByteArray());
1447
    QCOMPARE(m_stderr->readAll(), QByteArray("Invalid decryption time NAN.\n"));
1448

1449
    // Invalid decryption time (range).
1450
    execCmd(importCmd, {"import", "-p", "-t", "10", m_xmlFile->fileName(), databaseFilename});
1451

1452
    QCOMPARE(m_stdout->readAll(), QByteArray());
1453
    QVERIFY(m_stderr->readAll().contains(QByteArray("Target decryption time must be between")));
1454

1455
    int encryptionTime = 500;
1456
    // Custom encryption time
1457
    setInput({"a", "a"});
1458
    int epochBefore = QDateTime::currentMSecsSinceEpoch();
1459
    execCmd(importCmd,
1460
            {"import", "-p", "-t", QString::number(encryptionTime), m_xmlFile->fileName(), databaseFilename});
1461
    // Removing 100ms to make sure we account for changes in computation time.
1462
    QVERIFY(QDateTime::currentMSecsSinceEpoch() > (epochBefore + encryptionTime - 100));
1463

1464
    QCOMPARE(m_stderr->readLine(), QByteArray("Enter password to encrypt database (optional): \n"));
1465
    QCOMPARE(m_stderr->readLine(), QByteArray("Repeat password: \n"));
1466
    QCOMPARE(m_stdout->readLine(), QByteArray("Benchmarking key derivation function for 500ms delay.\n"));
1467
    QVERIFY(m_stdout->readLine().contains(QByteArray("rounds for key derivation function.\n")));
1468

1469
    db = readDatabase(databaseFilename, "a");
1470
    QVERIFY(db);
1471

1472
    // Quiet option
1473
    QScopedPointer<QTemporaryDir> testDirQuiet(new QTemporaryDir());
1474
    QString databaseFilenameQuiet = testDirQuiet->path() + "/testImport2.kdbx";
1475

1476
    setInput({"a", "a"});
1477
    execCmd(importCmd, {"import", "-p", "-q", m_xmlFile->fileName(), databaseFilenameQuiet});
1478

1479
    QCOMPARE(m_stderr->readLine(), QByteArray("Enter password to encrypt database (optional): \n"));
1480
    QCOMPARE(m_stderr->readLine(), QByteArray("Repeat password: \n"));
1481
    QCOMPARE(m_stdout->readLine(), QByteArray());
1482

1483
    db = readDatabase(databaseFilenameQuiet, "a");
1484
    QVERIFY(db);
1485
}
1486

1487
void TestCli::testKeyFileOption()
1488
{
1489
    List listCmd;
1490

1491
    QString keyFilePath(QString(KEEPASSX_TEST_DATA_DIR).append("/KeyFileProtected.key"));
1492
    setInput("a");
1493
    execCmd(listCmd, {"ls", "-k", keyFilePath, m_keyFileProtectedDbFile->fileName()});
1494
    m_stderr->readLine(); // Skip password prompt
1495
    QCOMPARE(m_stderr->readAll(), QByteArray());
1496
    QCOMPARE(m_stdout->readAll(),
1497
             QByteArray("entry1\n"
1498
                        "entry2\n"));
1499

1500
    // Should raise an error with no key file.
1501
    setInput("a");
1502
    execCmd(listCmd, {"ls", m_keyFileProtectedDbFile->fileName()});
1503
    QCOMPARE(m_stdout->readAll(), QByteArray());
1504
    QVERIFY(m_stderr->readAll().contains("Invalid credentials were provided"));
1505

1506
    // Should raise an error if key file path is invalid.
1507
    setInput("a");
1508
    execCmd(listCmd, {"ls", "-k", "invalidpath", m_keyFileProtectedDbFile->fileName()});
1509
    m_stderr->readLine(); // skip password prompt
1510
    QCOMPARE(m_stdout->readAll(), QByteArray());
1511
    QCOMPARE(m_stderr->readAll().split(':').at(0), QByteArray("Failed to load key file invalidpath"));
1512
}
1513

1514
void TestCli::testNoPasswordOption()
1515
{
1516
    List listCmd;
1517

1518
    QString keyFilePath(QString(KEEPASSX_TEST_DATA_DIR).append("/KeyFileProtectedNoPassword.key"));
1519
    execCmd(listCmd, {"ls", "-k", keyFilePath, "--no-password", m_keyFileProtectedNoPasswordDbFile->fileName()});
1520
    // Expecting no password prompt
1521
    QCOMPARE(m_stderr->readAll(), QByteArray());
1522
    QCOMPARE(m_stdout->readAll(),
1523
             QByteArray("entry1\n"
1524
                        "entry2\n"));
1525

1526
    // Should raise an error with no key file.
1527
    execCmd(listCmd, {"ls", "--no-password", m_keyFileProtectedNoPasswordDbFile->fileName()});
1528
    QCOMPARE(m_stdout->readAll(), QByteArray());
1529
    QVERIFY(m_stderr->readAll().contains("Invalid credentials were provided"));
1530
}
1531

1532
void TestCli::testList()
1533
{
1534
    List listCmd;
1535
    QVERIFY(!listCmd.name.isEmpty());
1536
    QVERIFY(listCmd.getDescriptionLine().contains(listCmd.name));
1537

1538
    setInput("a");
1539
    execCmd(listCmd, {"ls", m_dbFile->fileName()});
1540
    m_stderr->readLine(); // Skip password prompt
1541
    QCOMPARE(m_stderr->readAll(), QByteArray());
1542
    QCOMPARE(m_stdout->readAll(),
1543
             QByteArray("Sample Entry\n"
1544
                        "General/\n"
1545
                        "Windows/\n"
1546
                        "Network/\n"
1547
                        "Internet/\n"
1548
                        "eMail/\n"
1549
                        "Homebanking/\n"));
1550

1551
    // Quiet option
1552
    setInput("a");
1553
    execCmd(listCmd, {"ls", "-q", m_dbFile->fileName()});
1554
    QCOMPARE(m_stderr->readAll(), QByteArray());
1555
    QCOMPARE(m_stdout->readAll(),
1556
             QByteArray("Sample Entry\n"
1557
                        "General/\n"
1558
                        "Windows/\n"
1559
                        "Network/\n"
1560
                        "Internet/\n"
1561
                        "eMail/\n"
1562
                        "Homebanking/\n"));
1563

1564
    setInput("a");
1565
    execCmd(listCmd, {"ls", "-R", m_dbFile->fileName()});
1566
    QCOMPARE(m_stdout->readAll(),
1567
             QByteArray("Sample Entry\n"
1568
                        "General/\n"
1569
                        "  [empty]\n"
1570
                        "Windows/\n"
1571
                        "  [empty]\n"
1572
                        "Network/\n"
1573
                        "  [empty]\n"
1574
                        "Internet/\n"
1575
                        "  [empty]\n"
1576
                        "eMail/\n"
1577
                        "  [empty]\n"
1578
                        "Homebanking/\n"
1579
                        "  Subgroup/\n"
1580
                        "    Subgroup Entry\n"));
1581

1582
    setInput("a");
1583
    execCmd(listCmd, {"ls", "-R", "-f", m_dbFile->fileName()});
1584
    QCOMPARE(m_stdout->readAll(),
1585
             QByteArray("Sample Entry\n"
1586
                        "General/\n"
1587
                        "General/[empty]\n"
1588
                        "Windows/\n"
1589
                        "Windows/[empty]\n"
1590
                        "Network/\n"
1591
                        "Network/[empty]\n"
1592
                        "Internet/\n"
1593
                        "Internet/[empty]\n"
1594
                        "eMail/\n"
1595
                        "eMail/[empty]\n"
1596
                        "Homebanking/\n"
1597
                        "Homebanking/Subgroup/\n"
1598
                        "Homebanking/Subgroup/Subgroup Entry\n"));
1599

1600
    setInput("a");
1601
    execCmd(listCmd, {"ls", "-R", "-f", m_dbFile->fileName(), "/Homebanking"});
1602
    QCOMPARE(m_stdout->readAll(),
1603
             QByteArray("Subgroup/\n"
1604
                        "Subgroup/Subgroup Entry\n"));
1605

1606
    setInput("a");
1607
    execCmd(listCmd, {"ls", m_dbFile->fileName(), "/General/"});
1608
    QCOMPARE(m_stdout->readAll(), QByteArray("[empty]\n"));
1609

1610
    setInput("a");
1611
    execCmd(listCmd, {"ls", m_dbFile->fileName(), "/DoesNotExist/"});
1612
    m_stderr->readLine(); // skip password prompt
1613
    QCOMPARE(m_stderr->readAll(), QByteArray("Cannot find group /DoesNotExist/.\n"));
1614
    QCOMPARE(m_stdout->readAll(), QByteArray());
1615
}
1616

1617
void TestCli::testMerge()
1618
{
1619
    Merge mergeCmd;
1620
    QVERIFY(!mergeCmd.name.isEmpty());
1621
    QVERIFY(mergeCmd.getDescriptionLine().contains(mergeCmd.name));
1622

1623
    // load test database and save copies
1624
    auto db = readDatabase();
1625
    QVERIFY(db);
1626
    TemporaryFile targetFile1;
1627
    targetFile1.open();
1628
    targetFile1.close();
1629

1630
    TemporaryFile targetFile2;
1631
    targetFile2.open();
1632
    targetFile2.close();
1633

1634
    TemporaryFile targetFile3;
1635
    targetFile3.open();
1636
    targetFile3.close();
1637

1638
    db->saveAs(targetFile1.fileName());
1639
    db->saveAs(targetFile2.fileName());
1640

1641
    // save another copy with a different password
1642
    auto oldKey = db->key();
1643
    auto key = QSharedPointer<CompositeKey>::create();
1644
    key->addKey(QSharedPointer<PasswordKey>::create("b"));
1645
    db->setKey(key);
1646
    db->saveAs(targetFile3.fileName());
1647

1648
    // Restore the original password
1649
    db->setKey(oldKey);
1650

1651
    // then add a new entry to the in-memory database and save another copy
1652
    auto* entry = new Entry();
1653
    entry->setUuid(QUuid::createUuid());
1654
    entry->setTitle("Some Website");
1655
    entry->setPassword("secretsecretsecret");
1656
    auto* group = db->rootGroup()->findGroupByPath("/Internet/");
1657
    QVERIFY(group);
1658
    group->addEntry(entry);
1659

1660
    TemporaryFile sourceFile;
1661
    sourceFile.open();
1662
    sourceFile.close();
1663
    db->saveAs(sourceFile.fileName());
1664

1665
    setInput("a");
1666
    execCmd(mergeCmd, {"merge", "-s", targetFile1.fileName(), sourceFile.fileName()});
1667
    m_stderr->readLine(); // Skip password prompt
1668
    QCOMPARE(m_stderr->readAll(), QByteArray());
1669
    QList<QByteArray> outLines1 = m_stdout->readAll().split('\n');
1670
    QVERIFY(outLines1.at(0).contains("Overwriting Internet"));
1671
    QVERIFY(outLines1.at(1).contains("Creating missing Some Website"));
1672
    QCOMPARE(outLines1.at(2),
1673
             QString("Successfully merged %1 into %2.").arg(sourceFile.fileName(), targetFile1.fileName()).toUtf8());
1674

1675
    auto mergedDb = QSharedPointer<Database>::create();
1676
    QVERIFY(mergedDb->open(targetFile1.fileName(), oldKey));
1677

1678
    auto* entry1 = mergedDb->rootGroup()->findEntryByPath("/Internet/Some Website");
1679
    QVERIFY(entry1);
1680
    QCOMPARE(entry1->title(), QString("Some Website"));
1681
    QCOMPARE(entry1->password(), QString("secretsecretsecret"));
1682

1683
    // the dry run option should not modify the target database.
1684
    setInput("a");
1685
    execCmd(mergeCmd, {"merge", "--dry-run", "-s", targetFile2.fileName(), sourceFile.fileName()});
1686
    QList<QByteArray> outLines2 = m_stdout->readAll().split('\n');
1687
    QVERIFY(outLines2.at(0).contains("Overwriting Internet"));
1688
    QVERIFY(outLines2.at(1).contains("Creating missing Some Website"));
1689
    QCOMPARE(outLines2.at(2), QByteArray("Database was not modified by merge operation."));
1690

1691
    mergedDb = QSharedPointer<Database>::create();
1692
    QVERIFY(mergedDb->open(targetFile2.fileName(), oldKey));
1693
    entry1 = mergedDb->rootGroup()->findEntryByPath("/Internet/Some Website");
1694
    QVERIFY(!entry1);
1695

1696
    // the dry run option can be used with the quiet option
1697
    setInput("a");
1698
    execCmd(mergeCmd, {"merge", "--dry-run", "-s", "-q", targetFile2.fileName(), sourceFile.fileName()});
1699
    QCOMPARE(m_stderr->readAll(), QByteArray());
1700
    QCOMPARE(m_stdout->readAll(), QByteArray());
1701

1702
    mergedDb = QSharedPointer<Database>::create();
1703
    QVERIFY(mergedDb->open(targetFile2.fileName(), oldKey));
1704
    entry1 = mergedDb->rootGroup()->findEntryByPath("/Internet/Some Website");
1705
    QVERIFY(!entry1);
1706

1707
    // try again with different passwords for both files
1708
    setInput({"b", "a"});
1709
    execCmd(mergeCmd, {"merge", targetFile3.fileName(), sourceFile.fileName()});
1710
    QList<QByteArray> outLines3 = m_stdout->readAll().split('\n');
1711
    QCOMPARE(outLines3.at(2),
1712
             QString("Successfully merged %1 into %2.").arg(sourceFile.fileName(), targetFile3.fileName()).toUtf8());
1713

1714
    mergedDb = QSharedPointer<Database>::create();
1715
    QVERIFY(mergedDb->open(targetFile3.fileName(), key));
1716

1717
    entry1 = mergedDb->rootGroup()->findEntryByPath("/Internet/Some Website");
1718
    QVERIFY(entry1);
1719
    QCOMPARE(entry1->title(), QString("Some Website"));
1720
    QCOMPARE(entry1->password(), QString("secretsecretsecret"));
1721

1722
    // making sure that the message is different if the database was not
1723
    // modified by the merge operation.
1724
    setInput("a");
1725
    execCmd(mergeCmd, {"merge", "-s", sourceFile.fileName(), sourceFile.fileName()});
1726
    QCOMPARE(m_stdout->readAll(), QByteArray("Database was not modified by merge operation.\n"));
1727

1728
    // Quiet option
1729
    setInput("a");
1730
    execCmd(mergeCmd, {"merge", "-q", "-s", sourceFile.fileName(), sourceFile.fileName()});
1731
    QCOMPARE(m_stderr->readAll(), QByteArray());
1732
    QCOMPARE(m_stdout->readAll(), QByteArray());
1733

1734
    // Quiet option without the -s option
1735
    setInput({"a", "a"});
1736
    execCmd(mergeCmd, {"merge", "-q", sourceFile.fileName(), sourceFile.fileName()});
1737
    QCOMPARE(m_stderr->readAll(), QByteArray());
1738
    QCOMPARE(m_stdout->readAll(), QByteArray());
1739
}
1740

1741
void TestCli::testMergeWithKeys()
1742
{
1743
    DatabaseCreate createCmd;
1744
    QVERIFY(!createCmd.name.isEmpty());
1745
    QVERIFY(createCmd.getDescriptionLine().contains(createCmd.name));
1746

1747
    Merge mergeCmd;
1748
    QVERIFY(!mergeCmd.name.isEmpty());
1749
    QVERIFY(mergeCmd.getDescriptionLine().contains(mergeCmd.name));
1750

1751
    QScopedPointer<QTemporaryDir> testDir(new QTemporaryDir());
1752

1753
    QString sourceDatabaseFilename = testDir->path() + "/testSourceDatabase.kdbx";
1754
    QString sourceKeyfilePath = testDir->path() + "/testSourceKeyfile.txt";
1755

1756
    QString targetDatabaseFilename = testDir->path() + "/testTargetDatabase.kdbx";
1757
    QString targetKeyfilePath = testDir->path() + "/testTargetKeyfile.txt";
1758

1759
    setInput({"a", "a"});
1760
    execCmd(createCmd, {"db-create", sourceDatabaseFilename, "-p", "-k", sourceKeyfilePath});
1761

1762
    setInput({"b", "b"});
1763
    execCmd(createCmd, {"db-create", targetDatabaseFilename, "-p", "-k", targetKeyfilePath});
1764

1765
    auto sourceDatabase = readDatabase(sourceDatabaseFilename, "a", sourceKeyfilePath);
1766
    QVERIFY(sourceDatabase);
1767

1768
    auto targetDatabase = readDatabase(targetDatabaseFilename, "b", targetKeyfilePath);
1769
    QVERIFY(targetDatabase);
1770

1771
    auto* rootGroup = new Group();
1772
    rootGroup->setName("root");
1773
    rootGroup->setUuid(QUuid::createUuid());
1774
    auto* group = new Group();
1775
    group->setUuid(QUuid::createUuid());
1776
    group->setParent(rootGroup);
1777
    group->setName("Internet");
1778

1779
    auto* entry = new Entry();
1780
    entry->setUuid(QUuid::createUuid());
1781
    entry->setTitle("Some Website");
1782
    entry->setPassword("secretsecretsecret");
1783
    group->addEntry(entry);
1784

1785
    auto oldGroup = sourceDatabase->setRootGroup(rootGroup);
1786
    delete oldGroup;
1787

1788
    auto* otherRootGroup = new Group();
1789
    otherRootGroup->setName("root");
1790
    otherRootGroup->setUuid(QUuid::createUuid());
1791
    auto* otherGroup = new Group();
1792
    otherGroup->setUuid(QUuid::createUuid());
1793
    otherGroup->setParent(otherRootGroup);
1794
    otherGroup->setName("Internet");
1795

1796
    auto* otherEntry = new Entry();
1797
    otherEntry->setUuid(QUuid::createUuid());
1798
    otherEntry->setTitle("Some Website 2");
1799
    otherEntry->setPassword("secretsecretsecret 2");
1800
    otherGroup->addEntry(otherEntry);
1801

1802
    oldGroup = targetDatabase->setRootGroup(otherRootGroup);
1803
    delete oldGroup;
1804

1805
    sourceDatabase->saveAs(sourceDatabaseFilename);
1806
    targetDatabase->saveAs(targetDatabaseFilename);
1807

1808
    setInput({"b", "a"});
1809
    execCmd(mergeCmd,
1810
            {"merge",
1811
             "-k",
1812
             targetKeyfilePath,
1813
             "--key-file-from",
1814
             sourceKeyfilePath,
1815
             targetDatabaseFilename,
1816
             sourceDatabaseFilename});
1817

1818
    QList<QByteArray> lines = m_stdout->readAll().split('\n');
1819
    QVERIFY(lines.contains(
1820
        QString("Successfully merged %1 into %2.").arg(sourceDatabaseFilename, targetDatabaseFilename).toUtf8()));
1821
}
1822

1823
void TestCli::testMove()
1824
{
1825
    Move moveCmd;
1826
    QVERIFY(!moveCmd.name.isEmpty());
1827
    QVERIFY(moveCmd.getDescriptionLine().contains(moveCmd.name));
1828

1829
    setInput("a");
1830
    execCmd(moveCmd, {"mv", m_dbFile->fileName(), "invalid_entry_path", "invalid_group_path"});
1831
    m_stderr->readLine(); // skip password prompt
1832
    QCOMPARE(m_stderr->readLine(), QByteArray("Could not find entry with path invalid_entry_path.\n"));
1833
    QCOMPARE(m_stdout->readLine(), QByteArray());
1834

1835
    setInput("a");
1836
    execCmd(moveCmd, {"mv", m_dbFile->fileName(), "Sample Entry", "invalid_group_path"});
1837
    m_stderr->readLine(); // skip password prompt
1838
    QCOMPARE(m_stderr->readLine(), QByteArray("Could not find group with path invalid_group_path.\n"));
1839
    QCOMPARE(m_stdout->readLine(), QByteArray());
1840

1841
    setInput("a");
1842
    execCmd(moveCmd, {"mv", m_dbFile->fileName(), "Sample Entry", "General/"});
1843
    m_stderr->readLine(); // skip password prompt
1844
    QCOMPARE(m_stderr->readLine(), QByteArray());
1845
    QCOMPARE(m_stdout->readLine(), QByteArray("Successfully moved entry Sample Entry to group General/.\n"));
1846

1847
    auto db = readDatabase();
1848
    auto* entry = db->rootGroup()->findEntryByPath("General/Sample Entry");
1849
    QVERIFY(entry);
1850

1851
    // Test that not modified if the same group is destination.
1852
    setInput("a");
1853
    execCmd(moveCmd, {"mv", m_dbFile->fileName(), "General/Sample Entry", "General/"});
1854
    m_stderr->readLine(); // skip password prompt
1855
    QCOMPARE(m_stderr->readLine(), QByteArray("Entry is already in group General/.\n"));
1856
    QCOMPARE(m_stdout->readLine(), QByteArray());
1857

1858
    // sanity check
1859
    db = readDatabase();
1860
    entry = db->rootGroup()->findEntryByPath("General/Sample Entry");
1861
    QVERIFY(entry);
1862
}
1863

1864
void TestCli::testRemove()
1865
{
1866
    Remove removeCmd;
1867
    QVERIFY(!removeCmd.name.isEmpty());
1868
    QVERIFY(removeCmd.getDescriptionLine().contains(removeCmd.name));
1869

1870
    // load test database and save a copy with disabled recycle bin
1871
    auto db = readDatabase();
1872
    QVERIFY(db);
1873
    TemporaryFile fileCopy;
1874
    fileCopy.open();
1875
    fileCopy.close();
1876

1877
    db->metadata()->setRecycleBinEnabled(false);
1878
    db->saveAs(fileCopy.fileName());
1879

1880
    // delete entry and verify
1881
    setInput("a");
1882
    execCmd(removeCmd, {"rm", m_dbFile->fileName(), "/Sample Entry"});
1883
    m_stderr->readLine(); // skip password prompt
1884
    QCOMPARE(m_stderr->readAll(), QByteArray());
1885
    QCOMPARE(m_stdout->readAll(), QByteArray("Successfully recycled entry Sample Entry.\n"));
1886

1887
    auto readBackDb = readDatabase();
1888
    QVERIFY(readBackDb);
1889
    QVERIFY(!readBackDb->rootGroup()->findEntryByPath("/Sample Entry"));
1890
    QVERIFY(readBackDb->rootGroup()->findEntryByPath(QString("/%1/Sample Entry").arg(Group::tr("Recycle Bin"))));
1891

1892
    // try again, this time without recycle bin
1893
    setInput("a");
1894
    execCmd(removeCmd, {"rm", fileCopy.fileName(), "/Sample Entry"});
1895
    m_stderr->readLine(); // skip password prompt
1896
    QCOMPARE(m_stdout->readAll(), QByteArray("Successfully deleted entry Sample Entry.\n"));
1897

1898
    readBackDb = readDatabase(fileCopy.fileName(), "a");
1899
    QVERIFY(readBackDb);
1900
    QVERIFY(!readBackDb->rootGroup()->findEntryByPath("/Sample Entry"));
1901
    QVERIFY(!readBackDb->rootGroup()->findEntryByPath(QString("/%1/Sample Entry").arg(Group::tr("Recycle Bin"))));
1902

1903
    // finally, try deleting a non-existent entry
1904
    setInput("a");
1905
    execCmd(removeCmd, {"rm", fileCopy.fileName(), "/Sample Entry"});
1906
    m_stderr->readLine(); // skip password prompt
1907
    QCOMPARE(m_stderr->readAll(), QByteArray("Entry /Sample Entry not found.\n"));
1908
    QCOMPARE(m_stdout->readAll(), QByteArray());
1909

1910
    // try deleting a directory, should fail
1911
    setInput("a");
1912
    execCmd(removeCmd, {"rm", fileCopy.fileName(), "/General"});
1913
    m_stderr->readLine(); // skip password prompt
1914
    QCOMPARE(m_stderr->readAll(), QByteArray("Entry /General not found.\n"));
1915
    QCOMPARE(m_stdout->readAll(), QByteArray());
1916
}
1917

1918
void TestCli::testRemoveGroup()
1919
{
1920
    RemoveGroup removeGroupCmd;
1921
    QVERIFY(!removeGroupCmd.name.isEmpty());
1922
    QVERIFY(removeGroupCmd.getDescriptionLine().contains(removeGroupCmd.name));
1923

1924
    // try deleting a directory, should recycle it first.
1925
    setInput("a");
1926
    execCmd(removeGroupCmd, {"rmdir", m_dbFile->fileName(), "/General"});
1927
    m_stderr->readLine(); // skip password prompt
1928
    QCOMPARE(m_stderr->readAll(), QByteArray());
1929
    QCOMPARE(m_stdout->readAll(), QByteArray("Successfully recycled group /General.\n"));
1930

1931
    auto db = readDatabase();
1932
    auto* group = db->rootGroup()->findGroupByPath("General");
1933
    QVERIFY(!group);
1934

1935
    // try deleting a directory again, should delete it permanently.
1936
    setInput("a");
1937
    execCmd(removeGroupCmd, {"rmdir", m_dbFile->fileName(), "Recycle Bin/General"});
1938
    m_stderr->readLine(); // skip password prompt
1939
    QCOMPARE(m_stderr->readAll(), QByteArray());
1940
    QCOMPARE(m_stdout->readAll(), QByteArray("Successfully deleted group Recycle Bin/General.\n"));
1941

1942
    db = readDatabase();
1943
    group = db->rootGroup()->findGroupByPath("Recycle Bin/General");
1944
    QVERIFY(!group);
1945

1946
    // try deleting an invalid group, should fail.
1947
    setInput("a");
1948
    execCmd(removeGroupCmd, {"rmdir", m_dbFile->fileName(), "invalid"});
1949
    m_stderr->readLine(); // skip password prompt
1950
    QCOMPARE(m_stderr->readAll(), QByteArray("Group invalid not found.\n"));
1951
    QCOMPARE(m_stdout->readAll(), QByteArray());
1952

1953
    // Should fail to remove the root group.
1954
    setInput("a");
1955
    execCmd(removeGroupCmd, {"rmdir", m_dbFile->fileName(), "/"});
1956
    m_stderr->readLine(); // skip password prompt
1957
    QCOMPARE(m_stderr->readAll(), QByteArray("Cannot remove root group from database.\n"));
1958
    QCOMPARE(m_stdout->readAll(), QByteArray());
1959
}
1960

1961
void TestCli::testRemoveQuiet()
1962
{
1963
    Remove removeCmd;
1964
    QVERIFY(!removeCmd.name.isEmpty());
1965
    QVERIFY(removeCmd.getDescriptionLine().contains(removeCmd.name));
1966

1967
    // delete entry and verify
1968
    setInput("a");
1969
    execCmd(removeCmd, {"rm", "-q", m_dbFile->fileName(), "/Sample Entry"});
1970
    QCOMPARE(m_stderr->readAll(), QByteArray());
1971
    QCOMPARE(m_stdout->readAll(), QByteArray());
1972

1973
    auto db = readDatabase();
1974
    QVERIFY(db);
1975

1976
    QVERIFY(!db->rootGroup()->findEntryByPath("/Sample Entry"));
1977
    QVERIFY(db->rootGroup()->findEntryByPath(QString("/%1/Sample Entry").arg(Group::tr("Recycle Bin"))));
1978

1979
    // remove the entry completely
1980
    setInput("a");
1981
    execCmd(removeCmd, {"rm", "-q", m_dbFile->fileName(), QString("/%1/Sample Entry").arg(Group::tr("Recycle Bin"))});
1982
    QCOMPARE(m_stderr->readAll(), QByteArray());
1983
    QCOMPARE(m_stdout->readAll(), QByteArray());
1984

1985
    db = readDatabase();
1986
    QVERIFY(!db->rootGroup()->findEntryByPath("/Sample Entry"));
1987
    QVERIFY(!db->rootGroup()->findEntryByPath(QString("/%1/Sample Entry").arg(Group::tr("Recycle Bin"))));
1988
}
1989

1990
void TestCli::testSearch()
1991
{
1992
    Search searchCmd;
1993
    QVERIFY(!searchCmd.name.isEmpty());
1994
    QVERIFY(searchCmd.getDescriptionLine().contains(searchCmd.name));
1995

1996
    setInput("a");
1997
    execCmd(searchCmd, {"search", m_dbFile->fileName(), "Sample"});
1998
    m_stderr->readLine(); // Skip password prompt
1999
    QCOMPARE(m_stderr->readAll(), QByteArray());
2000
    QCOMPARE(m_stdout->readAll(), QByteArray("/Sample Entry\n"));
2001

2002
    // Quiet option
2003
    setInput("a");
2004
    execCmd(searchCmd, {"search", m_dbFile->fileName(), "-q", "Sample"});
2005
    QCOMPARE(m_stderr->readAll(), QByteArray());
2006
    QCOMPARE(m_stdout->readAll(), QByteArray("/Sample Entry\n"));
2007

2008
    setInput("a");
2009
    execCmd(searchCmd, {"search", m_dbFile->fileName(), "Does Not Exist"});
2010
    m_stderr->readLine(); // skip password prompt
2011
    QCOMPARE(m_stderr->readAll(), QByteArray("No results for that search term.\n"));
2012
    QCOMPARE(m_stdout->readAll(), QByteArray());
2013

2014
    // write a modified database
2015
    auto db = readDatabase();
2016
    QVERIFY(db);
2017
    auto* group = db->rootGroup()->findGroupByPath("/General/");
2018
    QVERIFY(group);
2019
    auto* entry = new Entry();
2020
    entry->setUuid(QUuid::createUuid());
2021
    entry->setTitle("New Entry");
2022
    group->addEntry(entry);
2023

2024
    TemporaryFile tmpFile;
2025
    tmpFile.open();
2026
    tmpFile.close();
2027
    db->saveAs(tmpFile.fileName());
2028

2029
    setInput("a");
2030
    execCmd(searchCmd, {"search", tmpFile.fileName(), "title:New"});
2031
    QCOMPARE(m_stdout->readAll(), QByteArray("/General/New Entry\n"));
2032

2033
    setInput("a");
2034
    execCmd(searchCmd, {"search", tmpFile.fileName(), "title:Entry"});
2035
    QCOMPARE(m_stdout->readAll(),
2036
             QByteArray("/Sample Entry\n/General/New Entry\n/Homebanking/Subgroup/Subgroup Entry\n"));
2037

2038
    setInput("a");
2039
    execCmd(searchCmd, {"search", tmpFile.fileName(), "group:General"});
2040
    QCOMPARE(m_stdout->readAll(), QByteArray("/General/New Entry\n"));
2041

2042
    setInput("a");
2043
    execCmd(searchCmd, {"search", tmpFile.fileName(), "group:NewDatabase"});
2044
    QCOMPARE(m_stdout->readAll(), QByteArray("/Sample Entry\n"));
2045

2046
    setInput("a");
2047
    execCmd(searchCmd, {"search", tmpFile.fileName(), "group:/NewDatabase"});
2048
    QCOMPARE(m_stdout->readAll(),
2049
             QByteArray("/Sample Entry\n/General/New Entry\n/Homebanking/Subgroup/Subgroup Entry\n"));
2050

2051
    setInput("a");
2052
    execCmd(searchCmd, {"search", tmpFile.fileName(), "url:bank"});
2053
    QCOMPARE(m_stdout->readAll(), QByteArray("/Homebanking/Subgroup/Subgroup Entry\n"));
2054

2055
    setInput("a");
2056
    execCmd(searchCmd, {"search", tmpFile.fileName(), "u:User Name"});
2057
    QCOMPARE(m_stdout->readAll(), QByteArray("/Sample Entry\n/Homebanking/Subgroup/Subgroup Entry\n"));
2058
}
2059

2060
void TestCli::testShow()
2061
{
2062
    Show showCmd;
2063
    QVERIFY(!showCmd.name.isEmpty());
2064
    QVERIFY(showCmd.getDescriptionLine().contains(showCmd.name));
2065

2066
    setInput("a");
2067
    execCmd(showCmd, {"show", m_dbFile->fileName(), "/Sample Entry"});
2068
    m_stderr->readLine(); // Skip password prompt
2069
    QCOMPARE(m_stderr->readAll(), QByteArray());
2070
    QCOMPARE(m_stdout->readAll(),
2071
             QByteArray("Title: Sample Entry\n"
2072
                        "UserName: User Name\n"
2073
                        "Password: PROTECTED\n"
2074
                        "URL: http://www.somesite.com/\n"
2075
                        "Notes: Notes\n"
2076
                        "Uuid: {9f4544c2-ab00-c74a-8a1a-6eaf26cf57e9}\n"
2077
                        "Tags: \n"));
2078

2079
    setInput("a");
2080
    execCmd(showCmd, {"show", "-s", m_dbFile->fileName(), "/Sample Entry"});
2081
    QCOMPARE(m_stdout->readAll(),
2082
             QByteArray("Title: Sample Entry\n"
2083
                        "UserName: User Name\n"
2084
                        "Password: Password\n"
2085
                        "URL: http://www.somesite.com/\n"
2086
                        "Notes: Notes\n"
2087
                        "Uuid: {9f4544c2-ab00-c74a-8a1a-6eaf26cf57e9}\n"
2088
                        "Tags: \n"));
2089

2090
    setInput("a");
2091
    execCmd(showCmd, {"show", m_dbFile->fileName(), "-q", "/Sample Entry"});
2092
    QCOMPARE(m_stderr->readAll(), QByteArray());
2093
    QCOMPARE(m_stdout->readAll(),
2094
             QByteArray("Title: Sample Entry\n"
2095
                        "UserName: User Name\n"
2096
                        "Password: PROTECTED\n"
2097
                        "URL: http://www.somesite.com/\n"
2098
                        "Notes: Notes\n"
2099
                        "Uuid: {9f4544c2-ab00-c74a-8a1a-6eaf26cf57e9}\n"
2100
                        "Tags: \n"));
2101

2102
    setInput("a");
2103
    execCmd(showCmd, {"show", m_dbFile->fileName(), "--show-attachments", "/Sample Entry"});
2104
    m_stderr->readLine(); // Skip password prompt
2105
    QCOMPARE(m_stderr->readAll(), QByteArray());
2106
    QCOMPARE(m_stdout->readAll(),
2107
             QByteArray("Title: Sample Entry\n"
2108
                        "UserName: User Name\n"
2109
                        "Password: PROTECTED\n"
2110
                        "URL: http://www.somesite.com/\n"
2111
                        "Notes: Notes\n"
2112
                        "Uuid: {9f4544c2-ab00-c74a-8a1a-6eaf26cf57e9}\n"
2113
                        "Tags: \n"
2114
                        "\n"
2115
                        "Attachments:\n"
2116
                        "  Sample attachment.txt (15 B)\n"));
2117

2118
    setInput("a");
2119
    execCmd(showCmd, {"show", m_dbFile->fileName(), "--show-attachments", "/Homebanking/Subgroup/Subgroup Entry"});
2120
    m_stderr->readLine(); // Skip password prompt
2121
    QCOMPARE(m_stderr->readAll(), QByteArray());
2122
    QCOMPARE(m_stdout->readAll(),
2123
             QByteArray("Title: Subgroup Entry\n"
2124
                        "UserName: Bank User Name\n"
2125
                        "Password: PROTECTED\n"
2126
                        "URL: https://www.bank.com\n"
2127
                        "Notes: Important note\n"
2128
                        "Uuid: {20b183fd-6878-4506-a50b-06d30792aa10}\n"
2129
                        "Tags: \n"
2130
                        "\n"
2131
                        "No attachments present.\n"));
2132

2133
    setInput("a");
2134
    execCmd(showCmd, {"show", "-a", "Title", m_dbFile->fileName(), "/Sample Entry"});
2135
    QCOMPARE(m_stdout->readAll(), QByteArray("Sample Entry\n"));
2136

2137
    setInput("a");
2138
    execCmd(showCmd, {"show", "-a", "Password", m_dbFile->fileName(), "/Sample Entry"});
2139
    QCOMPARE(m_stdout->readAll(), QByteArray("Password\n"));
2140

2141
    setInput("a");
2142
    execCmd(showCmd, {"show", "-a", "Uuid", m_dbFile->fileName(), "/Sample Entry"});
2143
    QCOMPARE(m_stdout->readAll(), QByteArray("{9f4544c2-ab00-c74a-8a1a-6eaf26cf57e9}\n"));
2144

2145
    setInput("a");
2146
    execCmd(showCmd, {"show", "-a", "Title", "-a", "URL", m_dbFile->fileName(), "/Sample Entry"});
2147
    QCOMPARE(m_stdout->readAll(),
2148
             QByteArray("Sample Entry\n"
2149
                        "http://www.somesite.com/\n"));
2150

2151
    // Test case insensitivity
2152
    setInput("a");
2153
    execCmd(showCmd, {"show", "-a", "TITLE", "-a", "URL", m_dbFile->fileName(), "/Sample Entry"});
2154
    QCOMPARE(m_stdout->readAll(),
2155
             QByteArray("Sample Entry\n"
2156
                        "http://www.somesite.com/\n"));
2157

2158
    setInput("a");
2159
    execCmd(showCmd, {"show", "-a", "DoesNotExist", m_dbFile->fileName(), "/Sample Entry"});
2160
    QCOMPARE(m_stdout->readAll(), QByteArray());
2161
    QVERIFY(m_stderr->readAll().contains("ERROR: unknown attribute DoesNotExist.\n"));
2162

2163
    setInput("a");
2164
    execCmd(showCmd, {"show", "-t", m_dbFile->fileName(), "/Sample Entry"});
2165
    QVERIFY(isTotp(m_stdout->readAll()));
2166

2167
    setInput("a");
2168
    execCmd(showCmd, {"show", "-a", "Title", m_dbFile->fileName(), "--totp", "/Sample Entry"});
2169
    QCOMPARE(m_stdout->readLine(), QByteArray("Sample Entry\n"));
2170
    QVERIFY(isTotp(m_stdout->readAll()));
2171

2172
    setInput("a");
2173
    execCmd(showCmd, {"show", m_dbFile2->fileName(), "--totp", "/Sample Entry"});
2174
    QCOMPARE(m_stdout->readAll(), QByteArray());
2175
    QVERIFY(m_stderr->readAll().contains("Entry with path /Sample Entry has no TOTP set up.\n"));
2176

2177
    // Show with ambiguous attributes
2178
    setInput("a");
2179
    execCmd(showCmd, {"show", m_dbFile->fileName(), "-a", "Testattribute1", "/Sample Entry"});
2180
    QCOMPARE(m_stdout->readAll(), QByteArray());
2181
    QVERIFY(m_stderr->readAll().contains("ERROR: attribute Testattribute1 is ambiguous"));
2182

2183
    setInput("a");
2184
    execCmd(showCmd, {"show", "--all", m_dbFile->fileName(), "/Sample Entry"});
2185
    QCOMPARE(m_stdout->readAll(),
2186
             QByteArray("Title: Sample Entry\n"
2187
                        "UserName: User Name\n"
2188
                        "Password: PROTECTED\n"
2189
                        "URL: http://www.somesite.com/\n"
2190
                        "Notes: Notes\n"
2191
                        "Uuid: {9f4544c2-ab00-c74a-8a1a-6eaf26cf57e9}\n"
2192
                        "Tags: \n"
2193
                        "TOTP Seed: PROTECTED\n"
2194
                        "TOTP Settings: 30;6\n"
2195
                        "TestAttribute1: b\n"
2196
                        "testattribute1: a\n"));
2197
}
2198

2199
void TestCli::testInvalidDbFiles()
2200
{
2201
    Show showCmd;
2202
    QString nonExistentDbPath("/foo/bar/baz");
2203
    QString directoryName("/");
2204

2205
    execCmd(showCmd, {"show", nonExistentDbPath, "/Sample Entry"});
2206
    QCOMPARE(QString(m_stderr->readAll()),
2207
             QObject::tr("Failed to open database file %1: not found").arg(nonExistentDbPath) + "\n");
2208
    QCOMPARE(m_stdout->readAll(), QByteArray());
2209

2210
    execCmd(showCmd, {"show", directoryName, "whatever"});
2211
    QCOMPARE(QString(m_stderr->readAll()),
2212
             QObject::tr("Failed to open database file %1: not a plain file").arg(directoryName) + "\n");
2213

2214
    // Create a write-only file and try to open it.
2215
    // QFileInfo.isReadable returns 'true' on Windows, even after the call to
2216
    // setPermissions(WriteOwner) and with NTFS permissions enabled, so this
2217
    // check doesn't work.
2218
#if !defined(Q_OS_WIN)
2219
    QTemporaryFile tempFile;
2220
    QVERIFY(tempFile.open());
2221
    QString path = QFileInfo(tempFile).absoluteFilePath();
2222
    QVERIFY(tempFile.setPermissions(QFileDevice::WriteOwner));
2223
    execCmd(showCmd, {"show", path, "some entry"});
2224
    QCOMPARE(QString(m_stderr->readAll()),
2225
             QObject::tr("Failed to open database file %1: not readable").arg(path) + "\n");
2226
#endif // Q_OS_WIN
2227
}
2228

2229
/**
2230
 * Secret key for the YubiKey slot used by the unit test is
2231
 * 1c e3 0f d7 8d 20 dc fa 40 b5 0c 18 77 9a fb 0f 02 28 8d b7
2232
 * This secret can be on either slot but must be passive.
2233
 */
2234
void TestCli::testYubiKeyOption()
2235
{
2236
    if (!YubiKey::instance()->isInitialized()) {
2237
        QSKIP("Unable to initialize YubiKey interface.");
2238
    }
2239

2240
    YubiKey::instance()->findValidKeys();
2241

2242
    const auto keys = YubiKey::instance()->foundKeys().keys();
2243
    if (keys.isEmpty()) {
2244
        QSKIP("No YubiKey devices were detected.");
2245
    }
2246

2247
    bool wouldBlock = false;
2248
    QByteArray challenge("CLITest");
2249
    Botan::secure_vector<char> response;
2250
    QByteArray expected("\xA2\x3B\x94\x00\xBE\x47\x9A\x30\xA9\xEB\x50\x9B\x85\x56\x5B\x6B\x30\x25\xB4\x8E", 20);
2251

2252
    // Find a key that as configured for this test
2253
    YubiKeySlot pKey(0, 0);
2254
    for (auto key : keys) {
2255
        if (YubiKey::instance()->testChallenge(key, &wouldBlock) && !wouldBlock) {
2256
            YubiKey::instance()->challenge(key, challenge, response);
2257
            if (std::memcmp(response.data(), expected.data(), expected.size()) == 0) {
2258
                pKey = key;
2259
                break;
2260
            }
2261
            Tools::wait(100);
2262
        }
2263
    }
2264

2265
    if (pKey.first == 0 && pKey.second == 0) {
2266
        QSKIP("No YubiKey is properly configured to perform this test.");
2267
    }
2268

2269
    List listCmd;
2270
    Add addCmd;
2271

2272
    setInput("a");
2273
    execCmd(listCmd,
2274
            {"ls",
2275
             "-y",
2276
             QString("%1:%2").arg(QString::number(pKey.second), QString::number(pKey.first)),
2277
             m_yubiKeyProtectedDbFile->fileName()});
2278
    m_stderr->readLine(); // skip password prompt
2279
    QCOMPARE(m_stderr->readAll(), QByteArray());
2280
    QCOMPARE(m_stdout->readAll(),
2281
             QByteArray("entry1\n"
2282
                        "entry2\n"));
2283

2284
    // Should raise an error with no yubikey slot.
2285
    setInput("a");
2286
    execCmd(listCmd, {"ls", m_yubiKeyProtectedDbFile->fileName()});
2287
    m_stderr->readLine(); // skip password prompt
2288
    QCOMPARE(m_stderr->readLine(),
2289
             QByteArray("Error while reading the database: Invalid credentials were provided, please try again.\n"));
2290
    QCOMPARE(m_stderr->readLine(),
2291
             QByteArray("If this reoccurs, then your database file may be corrupt. (HMAC mismatch)\n"));
2292
    QCOMPARE(m_stdout->readAll(), QByteArray());
2293

2294
    // Should raise an error if yubikey slot is not a string
2295
    setInput("a");
2296
    execCmd(listCmd, {"ls", "-y", "invalidslot", m_yubiKeyProtectedDbFile->fileName()});
2297
    m_stderr->readLine(); // skip password prompt
2298
    QCOMPARE(m_stderr->readAll().split(':').at(0), QByteArray("Invalid YubiKey slot invalidslot\n"));
2299
    QCOMPARE(m_stdout->readAll(), QByteArray());
2300

2301
    // Should raise an error if yubikey slot is invalid.
2302
    setInput("a");
2303
    execCmd(listCmd, {"ls", "-y", "3", m_yubiKeyProtectedDbFile->fileName()});
2304
    m_stderr->readLine(); // skip password prompt
2305
    QCOMPARE(m_stderr->readAll().split(':').at(0), QByteArray("Invalid YubiKey slot 3\n"));
2306
    QCOMPARE(m_stdout->readAll(), QByteArray());
2307
}
2308

2309
void TestCli::testNonAscii()
2310
{
2311
    QProcess process;
2312
    process.setProcessChannelMode(QProcess::MergedChannels);
2313
    process.start(
2314
        KEEPASSX_CLI_PATH,
2315
        QStringList(
2316
            {"show", "-a", "password", m_nonAsciiDbFile->fileName(), QString::fromUtf8("\xe7\xa7\x98\xe5\xaf\x86")}));
2317
    process.waitForStarted();
2318
    QCOMPARE(process.state(), QProcess::ProcessState::Running);
2319

2320
    // Write password.
2321
    process.write("\xce\x94\xc3\xb6\xd8\xb6\n");
2322
    process.closeWriteChannel();
2323

2324
    process.waitForFinished();
2325

2326
    process.readLine(); // skip password prompt
2327
    QByteArray password = process.readLine();
2328
    QCOMPARE(QString::fromUtf8(password).trimmed(),
2329
             QString::fromUtf8("\xf0\x9f\x9a\x97\xf0\x9f\x90\x8e\xf0\x9f\x94\x8b\xf0\x9f\x93\x8e"));
2330
}
2331

2332
void TestCli::testCommandParsing_data()
2333
{
2334
    QTest::addColumn<QString>("input");
2335
    QTest::addColumn<QStringList>("expectedOutput");
2336

2337
    QTest::newRow("basic") << "hello world" << QStringList({"hello", "world"});
2338
    QTest::newRow("basic escaping") << "hello\\ world" << QStringList({"hello world"});
2339
    QTest::newRow("quoted string") << "\"hello world\"" << QStringList({"hello world"});
2340
    QTest::newRow("multiple params") << "show Passwords/Internet" << QStringList({"show", "Passwords/Internet"});
2341
    QTest::newRow("quoted string inside param")
2342
        << R"(ls foo\ bar\ baz"quoted")" << QStringList({"ls", "foo bar baz\"quoted\""});
2343
    QTest::newRow("multiple whitespace") << "hello    world" << QStringList({"hello", "world"});
2344
    QTest::newRow("single slash char") << "\\" << QStringList({"\\"});
2345
    QTest::newRow("double backslash entry name") << "show foo\\\\\\\\bar" << QStringList({"show", "foo\\\\bar"});
2346
}
2347

2348
void TestCli::testCommandParsing()
2349
{
2350
    QFETCH(QString, input);
2351
    QFETCH(QStringList, expectedOutput);
2352

2353
    QStringList result = Utils::splitCommandString(input);
2354
    QCOMPARE(result.size(), expectedOutput.size());
2355
    for (int i = 0; i < expectedOutput.size(); ++i) {
2356
        QCOMPARE(result[i], expectedOutput[i]);
2357
    }
2358
}
2359

2360
void TestCli::testOpen()
2361
{
2362
    Open openCmd;
2363

2364
    setInput("a");
2365
    execCmd(openCmd, {"open", m_dbFile->fileName()});
2366
    QVERIFY(openCmd.currentDatabase);
2367

2368
    List listCmd;
2369
    // Set a current database, simulating interactive mode.
2370
    listCmd.currentDatabase = openCmd.currentDatabase;
2371
    execCmd(listCmd, {"ls"});
2372
    QByteArray expectedOutput("Sample Entry\n"
2373
                              "General/\n"
2374
                              "Windows/\n"
2375
                              "Network/\n"
2376
                              "Internet/\n"
2377
                              "eMail/\n"
2378
                              "Homebanking/\n");
2379
    QByteArray actualOutput = m_stdout->readAll();
2380
    actualOutput.truncate(expectedOutput.length());
2381
    QCOMPARE(actualOutput, expectedOutput);
2382
}
2383

2384
void TestCli::testHelp()
2385
{
2386
    Help helpCmd;
2387
    Commands::setupCommands(false);
2388

2389
    execCmd(helpCmd, {"help"});
2390
    QVERIFY(m_stdout->readAll().contains("Available commands"));
2391

2392
    List listCmd;
2393
    execCmd(helpCmd, {"help", "ls"});
2394
    QVERIFY(m_stdout->readAll().contains(listCmd.description.toLatin1()));
2395
}
2396

Использование cookies

Мы используем файлы cookie в соответствии с Политикой конфиденциальности и Политикой использования cookies.

Нажимая кнопку «Принимаю», Вы даете АО «СберТех» согласие на обработку Ваших персональных данных в целях совершенствования нашего веб-сайта и Сервиса GitVerse, а также повышения удобства их использования.

Запретить использование cookies Вы можете самостоятельно в настройках Вашего браузера.