QtPass 1.6.0
Multi-platform GUI for pass, the standard unix password manager.
Loading...
Searching...
No Matches
tst_util.cpp
Go to the documentation of this file.
1// SPDX-FileCopyrightText: 2016 Anne Jan Brouwer
2// SPDX-License-Identifier: GPL-3.0-or-later
3#include <QCoreApplication>
4#include <QDir>
5#include <QFile>
6#include <QFileInfo>
7#include <QList>
8#include <QProcessEnvironment>
9#include <QSignalSpy>
10#include <QTemporaryDir>
11#include <QUuid>
12#include <QtTest>
13
14#include "../../../src/enums.h"
17#include "../../../src/pass.h"
20#include "../../../src/qtpass.h"
24#include "../../../src/util.h"
25
26using GrepResults = QList<QPair<QString, QStringList>>;
27Q_DECLARE_METATYPE(GrepResults)
28
29
32class tst_util : public QObject {
33 Q_OBJECT
34
35public:
37 ~tst_util() override;
38
39public Q_SLOTS:
40 void init();
41 void cleanup();
42
43private:
44 struct PassStoreGuard {
45 QString original;
46 explicit PassStoreGuard(const QString &orig) : original(orig) {}
47 ~PassStoreGuard() { QtPassSettings::setPassStore(original); }
48 };
49
50 // Thin subclass that exposes protected members needed by env tests.
51 // NOTE: environment() calls updateEnv(), which adds PASSWORD_STORE_* entries
52 // to the internal env list and forwards it to exec via setEnvironment().
53 // Because Pass::setEnvVar removes all matching entries before appending,
54 // repeated calls to environment() are idempotent for PASSWORD_STORE_* vars.
55 // Tests that call callSetEnvVar() directly work on the same env list, so
56 // any subsequent environment() call will re-run updateEnv() and may
57 // overwrite those entries — use callSetEnvVar() and environment() in the
58 // same test only when the keys don't overlap with PASSWORD_STORE_*.
59 class TestPass : public ImitatePass {
60 public:
61 void callSetEnvVar(const QString &key, const QString &value) {
62 setEnvVar(key, value);
63 }
64 QStringList environment() {
65 updateEnv();
66 return exec.environment();
67 }
68 void callFinished(int id, int exitCode, const QString &out,
69 const QString &err) {
70 finished(id, exitCode, out, err);
71 }
72 void callPassFinished(int id, int exitCode, const QString &out,
73 const QString &err) {
74 Pass::finished(id, exitCode, out, err);
75 }
76 };
77
78 template <typename T, void (*Setter)(const T &)> struct SettingGuard {
79 T original;
80 SettingGuard(T orig, const T &newVal) : original(std::move(orig)) {
81 Setter(newVal);
82 }
83 ~SettingGuard() { Setter(original); }
84 SettingGuard(const SettingGuard &) = delete;
85 SettingGuard &operator=(const SettingGuard &) = delete;
86 };
87
88private Q_SLOTS:
89 void cleanupTestCase();
90 void normalizeFolderPath();
91 void normalizeFolderPathEdgeCases();
92 void fileContent();
93 void fileContentEdgeCases();
94 void namedValuesTakeValue();
95 void namedValuesEdgeCases();
96 void totpHiddenFromDisplay();
97 void testAwsUrl();
98 void regexPatterns();
99 void regexPatternEdgeCases();
100 void endsWithGpgEdgeCases();
101 void userInfoValidity();
102 void userInfoValidityEdgeCases();
103 void passwordConfigurationCharacters();
104 void simpleTransactionBasic();
105 void simpleTransactionNested();
106 void createGpgIdFile();
107 void createGpgIdFileEmptyKeys();
108 void generateRandomPassword();
109 void boundedRandom();
110 void findBinaryInPath();
111 void findPasswordStore();
112 void configIsValid();
113 void getDirBasic();
114 void getDirWithIndex();
115 void findBinaryInPathNotFound();
116 void findPasswordStoreEnvVar();
117 void normalizeFolderPathMultipleCalls();
118 void userInfoFullyValid();
119 void userInfoMarginallyValid();
120 void userInfoIsValid();
121 void userInfoCreatedAndExpiry();
122 void qProgressIndicatorBasic();
123 void qProgressIndicatorStartStop();
124 void namedValueBasic();
125 void namedValueMultiple();
126 void buildClipboardMimeDataLinux();
127 void buildClipboardMimeDataWindows();
128 void buildClipboardMimeDataMac();
129 void utilRegexEnsuresGpg();
130 void utilRegexProtocol();
131 void utilRegexNewLines();
132 void reencryptPathNormalization();
133 void reencryptPathAbsolutePath();
134 void buildClipboardMimeDataDword();
135 void imitatePassResolveMoveDestination();
136 void imitatePassResolveMoveDestinationForce();
137 void imitatePassResolveMoveDestinationDestExistsNoForce();
138 void imitatePassResolveMoveDestinationDir();
139 void imitatePassResolveMoveDestinationNonExistent();
140 void imitatePassRemoveDir();
141 void getRecipientListBasic();
142 void getRecipientListEmpty();
143 void getRecipientListWithComments();
144 void getRecipientListInvalidKeyId();
145 void isValidKeyIdBasic();
146 void isValidKeyIdWith0xPrefix();
147 void isValidKeyIdWithEmail();
148 void isValidKeyIdInvalid();
149 void getRecipientStringCount();
150 void getGpgIdPathBasic();
151 void getGpgIdPathSubfolder();
152 void getGpgIdPathNotFound();
153 void findBinaryInPathReturnedPathIsAbsolute();
154 void findBinaryInPathReturnedPathIsExecutable();
155 void findBinaryInPathMultipleKnownBinaries();
156 void findBinaryInPathConsistency();
157 void findBinaryInPathResultContainsBinaryName();
158 void findBinaryInPathTempExecutableInTempDir();
159 void findBinaryInPathWithConstQStringRef();
160 void findBinaryInPathEmptyString();
161 void findBinaryInPathStringLiteral();
162 void setEnvVarAdds();
163 void setEnvVarUpdates();
164 void setEnvVarRemoves();
165 void setEnvVarNoopOnMissingRemove();
166 void updateEnvSetsExpectedVars();
167 void updateEnvEmptyCustomCharsetFallsBackToAllChars();
168 void updateEnvWslenvContainsRequiredVars();
169 void gpgErrorMessageKeyExpiredStatusToken();
170 void gpgErrorMessageKeyRevokedStatusToken();
171 void gpgErrorMessageNoPubkeyStatusToken();
172 void gpgErrorMessageInvRecpStatusToken();
173 void gpgErrorMessageFailureStatusToken();
174 void gpgErrorMessageKeyExpiredFallback();
175 void gpgErrorMessageRevokedFallback();
176 void gpgErrorMessageNoPubkeyFallback();
177 void gpgErrorMessageEncryptionFailedFallback();
178 void gpgErrorMessageUnknownReturnsEmpty();
179 void gpgErrorMessageStatusTokenTakesPriorityOverFallback();
180 // parseGrepOutput
181 void parseGrepOutputEmpty();
182 void parseGrepOutputSingleEntry();
183 void parseGrepOutputMultipleEntries();
184 void parseGrepOutputAnsiStripped();
185 void parseGrepOutputHeaderColonStripped();
186 void parseGrepOutputCrlfHandled();
187 void parseGrepOutputOrphanMatchesIgnored();
188 void parseGrepOutputEmptyMatchLinesIgnored();
189 void parseGrepOutputLastEntryIncluded();
190 void parseGrepOutputEmbeddedBlueNotHeader();
191 void parseGrepOutputPlainTextHeaders();
192 // Pass::finished PASS_GREP exit-code handling
193 void passFinishedGrepNoMatchEmitsEmpty();
194 void passFinishedGrepErrorEmitsProcessError();
195 void passFinishedGrepSuccessEmitsResults();
196 // ImitatePass::Grep / helpers
197 void grepMatchFileFailedDecryptReturnsEmpty();
198 void grepScanStoreEmptyDirReturnsEmpty();
199 void grepImitatePassEmptyStoreEmitsEmpty();
200 void grepImitatePassInvalidRegexEmitsEmpty();
201};
202
206tst_util::tst_util() = default;
207
211tst_util::~tst_util() = default;
212
217 qRegisterMetaType<GrepResults>("GrepResults");
218 // Qt5 QSignalSpy looks up by the normalized signal type string, not the alias
219 qRegisterMetaType<GrepResults>("QList<QPair<QString,QStringList>>");
220}
221
226 // Intentionally left empty: no per-test cleanup required.
227}
228
232void tst_util::cleanupTestCase() {
233 // No test case cleanup required; function intentionally left empty.
234}
235
240void tst_util::normalizeFolderPath() {
241 QString result;
242 QString sep = QDir::separator();
243
244 // Forward slash path
245 result = Util::normalizeFolderPath("test");
246 QVERIFY(result.endsWith(sep));
247 result = Util::normalizeFolderPath("test/");
248 QVERIFY(result.endsWith(sep));
249 // Verify exact normalized path content
250 result = Util::normalizeFolderPath("test");
251 QVERIFY2(result == "test" + sep,
252 qPrintable(QString("Expected 'test%1', got '%2'").arg(sep, result)));
253 result = Util::normalizeFolderPath("test/");
254 QVERIFY2(result == "test" + sep,
255 qPrintable(QString("Expected 'test%1', got '%2'").arg(sep, result)));
256
257 // Windows-style backslash path (only on Windows)
258 if (QDir::separator() == '\\') {
259 result = Util::normalizeFolderPath("test\\subdir");
260 QVERIFY(result.endsWith("\\"));
261 QVERIFY(result.contains("test"));
262 QVERIFY(result.contains("subdir"));
263 // Verify exact normalized path content
264 QVERIFY2(
265 result == "test\\subdir\\",
266 qPrintable(
267 QString("Expected 'test\\\\subdir\\\\', got '%1'").arg(result)));
268 }
269
270 // Mixed separators test
271 result = Util::normalizeFolderPath("test/subdir\\folder");
272 QVERIFY(result.endsWith(sep));
273 QVERIFY(result.contains("test"));
274 QVERIFY(result.contains("subdir"));
275 QVERIFY(result.contains("folder"));
276}
277
278void tst_util::fileContent() {
279 NamedValue key = {"key", "val"};
280 NamedValue key2 = {"key2", "val2"};
281 QString password = "password";
282
283 FileContent fc = FileContent::parse("password\n", {}, false);
284 QCOMPARE(fc.getPassword(), password);
285 QCOMPARE(fc.getNamedValues(), {});
286 QCOMPARE(fc.getRemainingData(), QString());
287
288 fc = FileContent::parse("password", {}, false);
289 QCOMPARE(fc.getPassword(), password);
290 QCOMPARE(fc.getNamedValues(), {});
291 QCOMPARE(fc.getRemainingData(), QString());
292
293 fc = FileContent::parse("password\nfoobar\n", {}, false);
294 QCOMPARE(fc.getPassword(), password);
295 QCOMPARE(fc.getNamedValues(), {});
296 QCOMPARE(fc.getRemainingData(), QString("foobar\n"));
297
298 fc = FileContent::parse("password\nkey: val\nkey2: val2", {"key2"}, false);
299 QCOMPARE(fc.getPassword(), password);
300 QCOMPARE(fc.getNamedValues(), NamedValues({key2}));
301 QCOMPARE(fc.getRemainingData(), QString("key: val"));
302
303 fc = FileContent::parse("password\nkey: val\nkey2: val2", {"key2"}, true);
304 QCOMPARE(fc.getPassword(), password);
305 QCOMPARE(fc.getNamedValues(), NamedValues({key, key2}));
306 QCOMPARE(fc.getRemainingData(), QString());
307}
308
309void tst_util::namedValuesTakeValue() {
310 NamedValues nv = {{"key1", "value1"}, {"key2", "value2"}, {"key3", "value3"}};
311
312 QString val = nv.takeValue("key2");
313 QCOMPARE(val, QString("value2"));
314 QCOMPARE(nv.length(), 2);
315 QVERIFY(!nv.contains({"key2", "value2"}));
316
317 val = nv.takeValue("nonexistent");
318 QVERIFY(val.isEmpty());
319
320 val = nv.takeValue("key1");
321 QCOMPARE(val, QString("value1"));
322 val = nv.takeValue("key3");
323 QCOMPARE(val, QString("value3"));
324 QVERIFY(nv.isEmpty());
325}
326
327void tst_util::totpHiddenFromDisplay() {
328 FileContent fc = FileContent::parse(
329 "password\notpauth://totp/Test?secret=JBSWY3DPEHPK3PXP\nkey: value\n", {},
330 false);
331
332 QString remaining = fc.getRemainingData();
333 QVERIFY(remaining.contains("otpauth://"));
334 QVERIFY(remaining.contains("key: value"));
335
336 QString display = fc.getRemainingDataForDisplay();
337 QVERIFY(!display.contains("otpauth"));
338 QVERIFY(display.contains("key: value"));
339
341 "password\nOTPAUTH://TOTP/Test?secret=JBSWY3DPEHPK3PXP\n", {}, false);
342 QVERIFY(fc.getRemainingDataForDisplay().isEmpty());
343}
344
345void tst_util::testAwsUrl() {
346 QRegularExpression proto = Util::protocolRegex();
347
348 QRegularExpressionMatch match1 =
349 proto.match("https://rh-dev.signin.aws.amazon.com/console");
350 QVERIFY2(match1.hasMatch(), "Should match AWS console URL");
351 QString captured1 = match1.captured(1);
352 QVERIFY2(captured1.contains("amazon.com"), "Should include full URL");
353
354 QRegularExpressionMatch match2 = proto.match("https://test-example.com/path");
355 QVERIFY2(match2.hasMatch(), "Should match URL with dash");
356 QString captured2 = match2.captured(1);
357 QVERIFY2(captured2.contains("test-example.com"),
358 "Should include full domain");
359}
360
361void tst_util::regexPatterns() {
362 QRegularExpression gpg = Util::endsWithGpg();
363 QVERIFY(gpg.match("test.gpg").hasMatch());
364 QVERIFY(gpg.match("folder/test.gpg").hasMatch());
365 QVERIFY(!gpg.match("test.gpg~").hasMatch());
366 QVERIFY(!gpg.match("test.gpg.bak").hasMatch());
367
368 QRegularExpression proto = Util::protocolRegex();
369 QVERIFY(proto.match("https://example.com").hasMatch());
370 QVERIFY(proto.match("ssh://user@host/path").hasMatch());
371 QVERIFY(proto.match("ftp://server/file").hasMatch());
372 QVERIFY(proto.match("webdav://localhost/share").hasMatch());
373 QVERIFY(!proto.match("not a url").hasMatch());
374 QRegularExpressionMatch urlWithTrailingTextMatch =
375 proto.match("https://example.com/ is the address");
376 QVERIFY(urlWithTrailingTextMatch.hasMatch());
377 QString captured = urlWithTrailingTextMatch.captured(1);
378 QVERIFY2(!captured.contains(" "), "URL should not include space");
379 QVERIFY2(!captured.contains("<"), "URL should not include <");
380 QVERIFY2(captured == "https://example.com/", "URL should stop at space");
381
382 QRegularExpressionMatch urlWithFragmentMatch =
383 proto.match("Link: https://test.org/path?q=1#frag");
384 QVERIFY(urlWithFragmentMatch.hasMatch());
385 captured = urlWithFragmentMatch.captured(1);
386 QVERIFY2(captured.contains("?"), "URL should include query params");
387 QVERIFY2(captured.contains("#"), "URL should include fragment");
388 QVERIFY2(!captured.contains(" now"), "URL should not include trailing text");
389
390 QRegularExpression nl = Util::newLinesRegex();
391 QVERIFY(nl.match("\n").hasMatch());
392 QVERIFY(nl.match("\r").hasMatch());
393 QVERIFY(nl.match("\r\n").hasMatch());
394}
395
396void tst_util::normalizeFolderPathEdgeCases() {
397 QString result = Util::normalizeFolderPath("");
398 QVERIFY(result.endsWith(QDir::separator()));
399 QVERIFY2(result == QDir::separator() || result.endsWith(QDir::separator()),
400 "Empty path should become separator");
401
402 result = Util::normalizeFolderPath(QDir::separator());
403 QVERIFY(result.endsWith(QDir::separator()));
404
405 result = Util::normalizeFolderPath("path/to/dir/");
406 QVERIFY(result.endsWith(QDir::separator()));
407
408 QString nativeResult = Util::normalizeFolderPath("path/to/dir");
409 QVERIFY(nativeResult.endsWith(QDir::separator()));
410}
411
412void tst_util::fileContentEdgeCases() {
413 FileContent fc = FileContent::parse("", {}, false);
414 QVERIFY(fc.getPassword().isEmpty());
415
416 fc = FileContent::parse("pass\nusername: user@example.com\npassword: "
417 "secret\nurl: https://login.com\n",
418 {"username", "password", "url"}, false);
419 QVERIFY(fc.getNamedValues().length() >= 3);
420
421 fc = FileContent::parse("pass\nkey: value with spaces\n", {"key"}, true);
422 NamedValues nv = fc.getNamedValues();
423 QCOMPARE(nv.length(), 1);
424 QCOMPARE(nv.at(0).name, QString("key"));
425 QVERIFY(nv.at(0).value.contains("spaces"));
426
427 fc = FileContent::parse("pass\n://something\n", {}, false);
428 QVERIFY(fc.getRemainingData().contains("://"));
429
430 fc = FileContent::parse("pass\nno colon line\n", {}, false);
431 QVERIFY(fc.getRemainingData().contains("no colon line"));
432
433 fc = FileContent::parse("pass\nkey: value\nkey2: duplicate\n", {}, true);
434 QVERIFY(fc.getNamedValues().length() >= 2);
435
436 fc = FileContent::parse("pass\n", {}, false);
437 QCOMPARE(fc.getPassword(), QString("pass"));
438 QVERIFY(fc.getNamedValues().isEmpty());
439}
440
441void tst_util::namedValuesEdgeCases() {
442 NamedValues nv;
443 QVERIFY(nv.isEmpty());
444 QVERIFY(nv.takeValue("nonexistent").isEmpty());
445
446 NamedValue n1 = {"key", "value"};
447 nv.append(n1);
448 QCOMPARE(nv.length(), 1);
449 NamedValue n2 = {"key2", "value2"};
450 nv.append(n2);
451 QCOMPARE(nv.length(), 2);
452
453 nv.clear();
454 QVERIFY(nv.isEmpty());
455 QVERIFY(nv.takeValue("anything").isEmpty());
456}
457
458void tst_util::regexPatternEdgeCases() {
459 const QRegularExpression &gpg = Util::endsWithGpg();
460 QVERIFY(gpg.match(".gpg").hasMatch());
461 QVERIFY(gpg.match("a.gpg").hasMatch());
462 QVERIFY(!gpg.match("test.gpgx").hasMatch());
463
464 const QRegularExpression &proto = Util::protocolRegex();
465 QVERIFY(proto.match("webdavs://secure.example.com").hasMatch());
466 QVERIFY(proto.match("ftps://ftp.server.org").hasMatch());
467 QVERIFY(proto.match("sftp://user:pass@host").hasMatch());
468 // file:/// URLs are not matched - see Util::protocolRegex()
469 QVERIFY(!proto.match("file:///path/to/file").hasMatch());
470
471 const QRegularExpression &nl = Util::newLinesRegex();
472 QVERIFY(nl.match("\n").hasMatch());
473 QVERIFY(nl.match("\r").hasMatch());
474 QVERIFY(nl.match("\r\n").hasMatch());
475}
476
477void tst_util::endsWithGpgEdgeCases() {
478 const QRegularExpression &gpg = Util::endsWithGpg();
479 QVERIFY(!gpg.match(".gpgx").hasMatch());
480 QVERIFY(!gpg.match("test.gpg.bak").hasMatch());
481 QVERIFY(!gpg.match("test.gpg~").hasMatch());
482 QVERIFY(!gpg.match("test.gpg.orig").hasMatch());
483 QVERIFY(gpg.match("test.gpg").hasMatch());
484 QVERIFY(gpg.match("test/path/file.gpg").hasMatch());
485 QVERIFY(gpg.match("/absolute/path/file.gpg").hasMatch());
486 QVERIFY(gpg.match("file name with spaces.gpg").hasMatch());
487}
488
489void tst_util::userInfoValidity() {
490 UserInfo info;
491 info.validity = 'f';
492 QVERIFY(info.fullyValid());
493 QVERIFY(!info.marginallyValid());
494 QVERIFY(info.isValid());
495
496 info.validity = 'u';
497 QVERIFY(info.fullyValid());
498 QVERIFY(!info.marginallyValid());
499 QVERIFY(info.isValid());
500
501 info.validity = 'm';
502 QVERIFY(!info.fullyValid());
503 QVERIFY(info.marginallyValid());
504 QVERIFY(info.isValid());
505
506 info.validity = 'n';
507 QVERIFY(!info.fullyValid());
508 QVERIFY(!info.marginallyValid());
509 QVERIFY(!info.isValid());
510
511 info.validity = 'e';
512 QVERIFY(!info.isValid());
513}
514
515void tst_util::userInfoValidityEdgeCases() {
516 UserInfo info;
517 info.validity = '-';
518 QVERIFY(!info.isValid());
519
520 info.validity = 'q';
521 QVERIFY(!info.isValid());
522
523 char nullChar = '\0';
524 info.validity = nullChar;
525 QVERIFY(!info.isValid());
526
527 QVERIFY(!info.have_secret);
528 QVERIFY(!info.enabled);
529}
530
531void tst_util::passwordConfigurationCharacters() {
532 PasswordConfiguration config;
533 QCOMPARE(config.length, 16);
535
536 QVERIFY(!config.Characters[PasswordConfiguration::ALLCHARS].isEmpty());
537 QVERIFY(!config.Characters[PasswordConfiguration::ALPHABETICAL].isEmpty());
538 QVERIFY(!config.Characters[PasswordConfiguration::ALPHANUMERIC].isEmpty());
539 QVERIFY(!config.Characters[PasswordConfiguration::CUSTOM].isEmpty());
540
541 QVERIFY(config.Characters[PasswordConfiguration::ALLCHARS].length() >
543
544 QVERIFY(config.Characters[PasswordConfiguration::ALPHANUMERIC].length() >
546}
547
548void tst_util::simpleTransactionBasic() {
549 simpleTransaction transaction;
552 QCOMPARE(result, Enums::PASS_INSERT);
553}
554
555void tst_util::simpleTransactionNested() {
556 simpleTransaction transaction;
558 transaction.transactionAdd(Enums::GIT_PUSH);
559 Enums::PROCESS passInsertResult =
561 QCOMPARE(passInsertResult, Enums::PASS_INSERT);
562 Enums::PROCESS gitPushResult = transaction.transactionIsOver(Enums::GIT_PUSH);
563 QCOMPARE(gitPushResult, Enums::GIT_PUSH);
564}
565
566void tst_util::createGpgIdFile() {
567 QTemporaryDir tempDir;
568 QString newDir = tempDir.path() + "/testfolder";
569 QVERIFY(QDir().mkdir(newDir));
570
571 QString gpgIdFile = newDir + "/.gpg-id";
572 QStringList keyIds = {"ABCDEF12", "34567890"};
573
574 QFile gpgId(gpgIdFile);
575 QVERIFY(gpgId.open(QIODevice::WriteOnly));
576 for (const QString &keyId : keyIds) {
577 gpgId.write((keyId + "\n").toUtf8());
578 }
579 gpgId.close();
580
581 QVERIFY(QFile::exists(gpgIdFile));
582
583 QFile readFile(gpgIdFile);
584 QVERIFY(readFile.open(QIODevice::ReadOnly));
585 QString content = QString::fromUtf8(readFile.readAll());
586 readFile.close();
587
588 QStringList lines = content.trimmed().split('\n');
589 QCOMPARE(lines.size(), 2);
590 QCOMPARE(lines[0], QString("ABCDEF12"));
591 QCOMPARE(lines[1], QString("34567890"));
592}
593
594void tst_util::createGpgIdFileEmptyKeys() {
595 QTemporaryDir tempDir;
596 QString newDir = tempDir.path() + "/testfolder";
597 QVERIFY(QDir().mkdir(newDir));
598
599 QString gpgIdFile = newDir + "/.gpg-id";
600
601 QFile gpgId(gpgIdFile);
602 QVERIFY(gpgId.open(QIODevice::WriteOnly));
603 gpgId.close();
604
605 QVERIFY(QFile::exists(gpgIdFile));
606
607 QFile readFile(gpgIdFile);
608 QVERIFY(readFile.open(QIODevice::ReadOnly));
609 QString content = QString::fromUtf8(readFile.readAll());
610 readFile.close();
611
612 QVERIFY(content.isEmpty());
613}
614
615void tst_util::generateRandomPassword() {
616 SettingGuard<bool, QtPassSettings::setUsePwgen> pwgenGuard{
618
619 ImitatePass pass;
620 QString charset = "abcdefghijklmnopqrstuvwxyz";
621 QString result = pass.generatePassword(10, charset);
622
623 QCOMPARE(result.length(), 10);
624 for (const QChar &ch : result) {
625 QVERIFY2(
626 charset.contains(ch),
627 "Generated password contains character outside the specified charset");
628 }
629
630 result = pass.generatePassword(100, "abcd");
631 QCOMPARE(result.length(), 100);
632 for (const QChar &ch : result) {
633 QVERIFY2(
634 QStringLiteral("abcd").contains(ch),
635 "Generated password contains character outside the specified charset");
636 }
637
638 result = pass.generatePassword(0, "");
639 QVERIFY(result.isEmpty());
640
641 result = pass.generatePassword(50, "ABC");
642 QCOMPARE(result.length(), 50);
643 for (const QChar &ch : result) {
644 QVERIFY2(
645 QStringLiteral("ABC").contains(ch),
646 "Generated password contains character outside the specified charset");
647 }
648
649 const QString randomCharset = QStringLiteral("abcd");
650 const QString first = pass.generatePassword(32, randomCharset);
651 QCOMPARE(first.length(), 32);
652 bool foundDifferent = false;
653 for (int i = 0; i < 20; ++i) {
654 const QString candidate = pass.generatePassword(32, randomCharset);
655 QCOMPARE(candidate.length(), 32);
656 for (const QChar &ch : candidate) {
657 QVERIFY2(randomCharset.contains(ch),
658 "Generated password contains character outside the specified "
659 "charset");
660 }
661 if (candidate != first) {
662 foundDifferent = true;
663 break;
664 }
665 }
666 QVERIFY2(
667 foundDifferent,
668 "Multiple generated passwords were identical; randomness may be broken");
669}
670
671void tst_util::boundedRandom() {
672 SettingGuard<bool, QtPassSettings::setUsePwgen> pwgenGuard{
674
675 ImitatePass pass;
676
677 QVector<quint32> counts(10, 0);
678 const int iterations = 1000;
679
680 for (int i = 0; i < iterations; ++i) {
681 QString result = pass.generatePassword(1, "0123456789");
682 quint32 val = result.at(0).digitValue();
683 QVERIFY2(val < 10, "generatePassword should only return digit characters");
684 counts[val]++;
685 }
686
687 for (int i = 0; i < 10; ++i) {
688 QVERIFY2(counts[i] > 0, "Each digit should appear at least once");
689 }
690
691 const double expected = static_cast<double>(iterations) / 10.0;
692 double chi2 = 0.0;
693 for (int i = 0; i < 10; ++i) {
694 const double count = static_cast<double>(counts[i]);
695 chi2 += (count - expected) * (count - expected) / expected;
696 }
697 const double chi2Critical = 25.0;
698 QVERIFY2(chi2 < chi2Critical,
699 qPrintable(
700 QStringLiteral("Chi-square %1 exceeds critical value %2 (df=9)")
701 .arg(chi2)
702 .arg(chi2Critical)));
703}
704
705void tst_util::findBinaryInPath() {
706#ifdef Q_OS_WIN
707 const QString binaryName = QStringLiteral("cmd.exe");
708#else
709 const QString binaryName = QStringLiteral("sh");
710#endif
711 QString result = Util::findBinaryInPath(binaryName);
712 QVERIFY2(!result.isEmpty(), "Should find a standard shell in PATH");
713 QVERIFY(result.contains(binaryName));
714
715 result = Util::findBinaryInPath("nonexistentbinary12345");
716 QVERIFY(result.isEmpty());
717}
718
719void tst_util::findPasswordStore() {
720 QString result = Util::findPasswordStore();
721 QVERIFY(!result.isEmpty());
722 QVERIFY(result.endsWith(QDir::separator()));
723}
724
725void tst_util::configIsValid() {
726 QTemporaryDir tempDir;
727 QVERIFY2(tempDir.isValid(), "Temporary directory should be created");
728
729 PassStoreGuard guard(QtPassSettings::getPassStore());
730
731 SettingGuard<bool, QtPassSettings::setUsePass> usePassGuard{
733
734 // No .gpg-id in this store => config must be invalid.
735 QtPassSettings::setPassStore(tempDir.path());
736 bool isValid = Util::configIsValid();
737 QVERIFY2(!isValid, "Expected invalid config when .gpg-id is missing");
738
739 // Create .gpg-id, then force invalid executable configuration.
740 QFile gpgIdFile(tempDir.path() + QDir::separator() +
741 QStringLiteral(".gpg-id"));
742 QVERIFY2(gpgIdFile.open(QIODevice::WriteOnly | QIODevice::Truncate),
743 "Should be able to create .gpg-id");
744 gpgIdFile.write("test@example.com\n");
745 gpgIdFile.close();
746
747 SettingGuard<QString, QtPassSettings::setGpgExecutable> gpgGuard{
749
750 isValid = Util::configIsValid();
751 QVERIFY2(!isValid, "Expected invalid config when .gpg-id exists but gpg "
752 "executable is missing");
753
755 QStringLiteral("definitely_nonexistent_gpg_binary_12345"));
756 isValid = Util::configIsValid();
757 QVERIFY2(!isValid, "Expected invalid config when .gpg-id exists but gpg "
758 "executable is invalid");
759}
760
761void tst_util::getDirBasic() {
762 QTemporaryDir tempDir;
763 QVERIFY2(tempDir.isValid(),
764 "Temporary directory should be created successfully");
765
766 QFileSystemModel fileSystemModel;
767 fileSystemModel.setRootPath(tempDir.path());
768 StoreModel storeModel;
769 storeModel.setModelAndStore(&fileSystemModel, tempDir.path());
770 QVERIFY(storeModel.sourceModel() != nullptr);
771 QVERIFY2(storeModel.getStore() == tempDir.path(),
772 "Store path should match the set value");
773 QModelIndex rootIndex = fileSystemModel.index(tempDir.path());
774 QVERIFY2(rootIndex.isValid(), "Filesystem model root index should be valid");
775 const QString originalStore = QtPassSettings::getPassStore();
776 QtPassSettings::setPassStore(tempDir.path());
777
778 QString result =
779 Util::getDir(QModelIndex(), false, fileSystemModel, storeModel);
780 QString expectedDir = QDir(tempDir.path()).absolutePath();
781 if (!expectedDir.endsWith(QDir::separator())) {
782 expectedDir += QDir::separator();
783 }
784 QVERIFY2(
785 result == expectedDir,
786 qPrintable(QString("Expected '%1', got '%2'").arg(expectedDir, result)));
787 QtPassSettings::setPassStore(originalStore);
788}
789
790void tst_util::getDirWithIndex() {
791 QTemporaryDir tempDir;
792 QVERIFY2(tempDir.isValid(),
793 "Temporary directory should be created successfully");
794
795 const QString dirPath = tempDir.path();
796 const QString filePath =
797 QDir(dirPath).filePath(QStringLiteral("testfile.txt"));
798
799 QFile file(filePath);
800 QVERIFY2(file.open(QIODevice::WriteOnly),
801 "Failed to create test file in temporary directory");
802 const char testData[] = "dummy";
803 const qint64 bytesWritten = file.write(testData, sizeof(testData) - 1);
804 QVERIFY2(bytesWritten == static_cast<qint64>(sizeof(testData) - 1),
805 "Failed to write test data to file in temporary directory");
806 file.close();
807
808 const QString originalPassStore = QtPassSettings::getPassStore();
809 PassStoreGuard passStoreGuard(originalPassStore);
811
812 QFileSystemModel fileSystemModel;
813 fileSystemModel.setRootPath(dirPath);
814
815 StoreModel storeModel;
816 storeModel.setModelAndStore(&fileSystemModel, dirPath);
817 QVERIFY2(storeModel.getStore() == dirPath,
818 "Store path should match the set value");
819
820 QModelIndex sourceIndex = fileSystemModel.index(filePath);
821 QVERIFY2(sourceIndex.isValid(),
822 "Source index should be valid for the test file");
823 QModelIndex fileIndex = storeModel.mapFromSource(sourceIndex);
824 QVERIFY2(fileIndex.isValid(),
825 "Proxy index should be valid for the test file");
826
827 QString result = Util::getDir(fileIndex, false, fileSystemModel, storeModel);
828 QVERIFY2(!result.isEmpty(),
829 "getDir should return a non-empty directory for a valid index");
830 QVERIFY(result.endsWith(QDir::separator()));
831
832 QString expectedPath = dirPath;
833 if (!expectedPath.endsWith(QDir::separator())) {
834 expectedPath += QDir::separator();
835 }
836 QVERIFY2(
837 result == expectedPath,
838 qPrintable(
839 QStringLiteral("Expected '%1', got '%2'").arg(expectedPath, result)));
840
841 QModelIndex invalidIndex;
842 QString invalidResult =
843 Util::getDir(invalidIndex, false, fileSystemModel, storeModel);
844 QString expectedForInvalid = dirPath;
845 if (!expectedForInvalid.endsWith(QDir::separator())) {
846 expectedForInvalid += QDir::separator();
847 }
848 QVERIFY2(invalidResult == expectedForInvalid,
849 qPrintable(QStringLiteral("getDir should return pass store for "
850 "invalid index. Expected '%1', got '%2'")
851 .arg(expectedForInvalid, invalidResult)));
852}
853
854void tst_util::findBinaryInPathNotFound() {
855 QString result = Util::findBinaryInPath("this-binary-does-not-exist-12345");
856 QVERIFY(result.isEmpty());
857}
858
859void tst_util::findPasswordStoreEnvVar() {
860 QString result = Util::findPasswordStore();
861 QVERIFY(!result.isEmpty());
862}
863
864void tst_util::normalizeFolderPathMultipleCalls() {
865 QString result1 = Util::normalizeFolderPath("test1");
866 QString result2 = Util::normalizeFolderPath("test2");
867 QVERIFY(result1.endsWith(QDir::separator()));
868 QVERIFY(result2.endsWith(QDir::separator()));
869}
870
871void tst_util::userInfoFullyValid() {
872 UserInfo ui;
873 ui.validity = 'f';
874 QVERIFY(ui.fullyValid());
875 ui.validity = 'u';
876 QVERIFY(ui.fullyValid());
877 ui.validity = '-';
878 QVERIFY(!ui.fullyValid());
879}
880
881void tst_util::userInfoMarginallyValid() {
882 UserInfo ui;
883 ui.validity = 'm';
884 QVERIFY(ui.marginallyValid());
885 ui.validity = 'f';
886 QVERIFY(!ui.marginallyValid());
887}
888
889void tst_util::userInfoIsValid() {
890 UserInfo ui;
891 ui.validity = 'f';
892 QVERIFY(ui.isValid());
893 ui.validity = 'm';
894 QVERIFY(ui.isValid());
895 ui.validity = '-';
896 QVERIFY(!ui.isValid());
897}
898
899void tst_util::userInfoCreatedAndExpiry() {
900 UserInfo ui;
901 ui.name = "Test User";
902 ui.key_id = "ABCDEF12";
903
904 QVERIFY(!ui.created.isValid());
905 QVERIFY(!ui.expiry.isValid());
906
907 QDateTime future = QDateTime::currentDateTime().addYears(1);
908 ui.expiry = future;
909 QVERIFY(ui.expiry.isValid());
910 QVERIFY(ui.expiry.toSecsSinceEpoch() > 0);
911
912 QDateTime past = QDateTime::currentDateTime().addYears(-1);
913 ui.created = past;
914 QVERIFY(ui.created.isValid());
915 QVERIFY(ui.created.toSecsSinceEpoch() > 0);
916}
917
918void tst_util::qProgressIndicatorBasic() {
919 QProgressIndicator pi;
920 QVERIFY(!pi.isAnimated());
921}
922
923void tst_util::qProgressIndicatorStartStop() {
924 QProgressIndicator pi;
925 pi.startAnimation();
926 QVERIFY(pi.isAnimated());
927 pi.stopAnimation();
928 QVERIFY(!pi.isAnimated());
929}
930
931void tst_util::namedValueBasic() {
932 NamedValue nv;
933 nv.name = "key";
934 nv.value = "value";
935 QCOMPARE(nv.name, QString("key"));
936 QCOMPARE(nv.value, QString("value"));
937}
938
939void tst_util::namedValueMultiple() {
940 NamedValues nvs;
941 NamedValue nv1;
942 nv1.name = "user1";
943 nv1.value = "pass1";
944 nvs.append(nv1);
945 QCOMPARE(nvs.size(), 1);
946}
947
948void tst_util::imitatePassResolveMoveDestination() {
949 ImitatePass pass;
950 QTemporaryDir tmpDir;
951 QString srcPath = tmpDir.path() + "/test.gpg";
952 QFile srcFile(srcPath);
953 QVERIFY(srcFile.open(QFile::WriteOnly));
954 srcFile.write("test");
955 srcFile.close();
956
957 QString destPath = tmpDir.path() + "/dest.gpg";
958 QString result = pass.resolveMoveDestination(srcPath, destPath, false);
959 QString expected = destPath;
960 QVERIFY2(result == expected, "Destination should have .gpg extension");
961}
962
963void tst_util::imitatePassResolveMoveDestinationForce() {
964 ImitatePass pass;
965 QTemporaryDir tmpDir;
966 QString srcPath = tmpDir.path() + "/test.gpg";
967 QFile srcFile(srcPath);
968 QVERIFY(srcFile.open(QFile::WriteOnly));
969 srcFile.write("test");
970 srcFile.close();
971
972 QString destPath = tmpDir.path() + "/existing.gpg";
973 QFile destFile(destPath);
974 QVERIFY(destFile.open(QFile::WriteOnly));
975 destFile.write("old");
976 destFile.close();
977
978 QString result = pass.resolveMoveDestination(srcPath, destPath, true);
979 QVERIFY2(result == destPath, "Should return dest path when force=true");
980}
981
982void tst_util::imitatePassResolveMoveDestinationDestExistsNoForce() {
983 ImitatePass pass;
984 QTemporaryDir tmpDir;
985 QString srcPath = tmpDir.path() + "/test.gpg";
986 QFile srcFile(srcPath);
987 QVERIFY(srcFile.open(QFile::WriteOnly));
988 srcFile.write("test");
989 srcFile.close();
990
991 QString destPath = tmpDir.path() + "/existing.gpg";
992 QFile destFile(destPath);
993 QVERIFY(destFile.open(QFile::WriteOnly));
994 destFile.write("old");
995 destFile.close();
996
997 QString result = pass.resolveMoveDestination(srcPath, destPath, false);
998 QVERIFY2(result.isEmpty(),
999 "Should return empty when dest exists and force=false");
1000}
1001
1002void tst_util::imitatePassResolveMoveDestinationDir() {
1003 ImitatePass pass;
1004 QTemporaryDir tmpDir;
1005 QString srcPath = tmpDir.path() + "/test.gpg";
1006 QFile srcFile(srcPath);
1007 QVERIFY(srcFile.open(QFile::WriteOnly));
1008 srcFile.write("test");
1009 srcFile.close();
1010
1011 QString result = pass.resolveMoveDestination(srcPath, tmpDir.path(), false);
1012 QVERIFY2(result == tmpDir.path() + "/test.gpg",
1013 "Should append filename when dest is dir");
1014}
1015
1016void tst_util::imitatePassResolveMoveDestinationNonExistent() {
1017 ImitatePass pass;
1018 QTemporaryDir tmpDir;
1019 QString destPath = tmpDir.path() + "/dest.gpg";
1020 QString result =
1021 pass.resolveMoveDestination("/non/existent/path.gpg", destPath, false);
1022 QVERIFY2(result.isEmpty(), "Should return empty for non-existent source");
1023}
1024
1025void tst_util::imitatePassRemoveDir() {
1026 ImitatePass pass;
1027 QTemporaryDir tmpDir;
1028 QString subDir = tmpDir.path() + "/testdir";
1029 QVERIFY(QDir().mkpath(subDir));
1030 QVERIFY(QDir(subDir).exists());
1031 bool result = pass.removeDir(subDir);
1032 QVERIFY(result);
1033 QVERIFY(!QDir(subDir).exists());
1034}
1035
1036void tst_util::getRecipientListBasic() {
1037 QTemporaryDir tempDir;
1038 QString passStore = tempDir.path();
1039 QString gpgIdFile = passStore + "/.gpg-id";
1040
1041 QFile file(gpgIdFile);
1042 QVERIFY(file.open(QIODevice::WriteOnly));
1043 file.write("ABCDEF12\n34567890\n");
1044 file.close();
1045
1046 PassStoreGuard guard(QtPassSettings::getPassStore());
1048 QStringList recipients = Pass::getRecipientList(passStore);
1049 QCOMPARE(recipients.size(), 2);
1050 QCOMPARE(recipients[0], QString("ABCDEF12"));
1051 QCOMPARE(recipients[1], QString("34567890"));
1052}
1053
1054void tst_util::getRecipientListEmpty() {
1055 QTemporaryDir tempDir;
1056 QString passStore = tempDir.path();
1057 QString gpgIdFile = passStore + "/.gpg-id";
1058
1059 QFile file(gpgIdFile);
1060 QVERIFY(file.open(QIODevice::WriteOnly));
1061 file.close();
1062
1063 PassStoreGuard guard(QtPassSettings::getPassStore());
1065 QStringList recipients = Pass::getRecipientList(passStore);
1066 QVERIFY(recipients.isEmpty());
1067}
1068
1069void tst_util::getRecipientListWithComments() {
1070 QTemporaryDir tempDir;
1071 QString passStore = tempDir.path();
1072 QString gpgIdFile = passStore + "/.gpg-id";
1073
1074 QFile file(gpgIdFile);
1075 QVERIFY(file.open(QIODevice::WriteOnly));
1076 file.write("ABCDEF12\n# comment\n34567890\n");
1077 file.close();
1078
1079 PassStoreGuard guard(QtPassSettings::getPassStore());
1081 QStringList recipients = Pass::getRecipientList(passStore);
1082 QCOMPARE(recipients.size(), 2);
1083 QVERIFY(!recipients.contains("# comment"));
1084 QVERIFY(!recipients.contains("comment"));
1085}
1086
1087void tst_util::getRecipientListInvalidKeyId() {
1088 QTemporaryDir tempDir;
1089 QString passStore = tempDir.path();
1090 QString gpgIdFile = passStore + "/.gpg-id";
1091
1092 QFile file(gpgIdFile);
1093 QVERIFY(file.open(QIODevice::WriteOnly));
1094 file.write("ABCDEF12\ninvalid\n0xABCDEF123456789012\n<a@b>\nuser@qtpass@"
1095 "example.org\n");
1096 file.close();
1097
1098 const QString originalPassStore = QtPassSettings::getPassStore();
1099 PassStoreGuard originalGuard(originalPassStore);
1101 QStringList recipients = Pass::getRecipientList(passStore);
1102 QVERIFY(!recipients.contains("invalid"));
1103 QVERIFY(recipients.contains("ABCDEF12"));
1104 QVERIFY(recipients.contains("0xABCDEF123456789012"));
1105 QVERIFY(recipients.contains("user@qtpass@example.org"));
1106}
1107
1108void tst_util::isValidKeyIdBasic() {
1109 QVERIFY(Util::isValidKeyId("ABCDEF12"));
1110 QVERIFY(Util::isValidKeyId("abcdef12"));
1111 QVERIFY(Util::isValidKeyId("0123456789ABCDEF"));
1112 QVERIFY(Util::isValidKeyId("0123456789abcdef"));
1113}
1114
1115void tst_util::isValidKeyIdWith0xPrefix() {
1116 QVERIFY(Util::isValidKeyId("0xABCDEF12"));
1117 QVERIFY(Util::isValidKeyId("0XABCDEF12"));
1118 QVERIFY(Util::isValidKeyId("0xabcdef12"));
1119 QVERIFY(Util::isValidKeyId("0Xabcdef12"));
1120 QVERIFY(Util::isValidKeyId("0x0123456789ABCDEF"));
1121}
1122
1123void tst_util::isValidKeyIdWithEmail() {
1124 QVERIFY(Util::isValidKeyId("<a@b>"));
1125 QVERIFY(Util::isValidKeyId("user@qtpass@example.org"));
1126 QVERIFY(Util::isValidKeyId("/any/text/here"));
1127 QVERIFY(Util::isValidKeyId("#anything"));
1128 QVERIFY(Util::isValidKeyId("&anything"));
1129}
1130
1131void tst_util::isValidKeyIdInvalid() {
1132 QVERIFY(!Util::isValidKeyId(""));
1133 QVERIFY(!Util::isValidKeyId("short"));
1134 QVERIFY(!Util::isValidKeyId(QString(41, 'a')));
1135 QVERIFY(!Util::isValidKeyId("invalidchars!"));
1136 QVERIFY(!Util::isValidKeyId("space in key"));
1137}
1138
1139void tst_util::getRecipientStringCount() {
1140 QTemporaryDir tempDir;
1141 QString passStore = tempDir.path();
1142 QString gpgIdFile = passStore + "/.gpg-id";
1143
1144 QFile file(gpgIdFile);
1145 QVERIFY(file.open(QIODevice::WriteOnly));
1146 file.write("ABCDEF12\n34567890\n");
1147 file.close();
1148
1149 const QString originalPassStore = QtPassSettings::getPassStore();
1150 PassStoreGuard originalGuard(originalPassStore);
1152 int count = 0;
1153 QStringList parsedRecipients =
1154 Pass::getRecipientString(passStore, " ", &count);
1155 QStringList recipientsNoCount = Pass::getRecipientString(passStore, " ");
1156
1157 QStringList expectedRecipients = {"ABCDEF12", "34567890"};
1158 // Verify count matches the expected number of parsed recipients.
1159 QVERIFY(count > 0);
1160 QCOMPARE(count, (int)expectedRecipients.size());
1161 // Verify both overloads return the same result
1162 QCOMPARE(parsedRecipients, recipientsNoCount);
1163 // Verify that the parsed recipients match the expected values.
1164 QVERIFY(parsedRecipients.contains("ABCDEF12"));
1165 QVERIFY(parsedRecipients.contains("34567890"));
1166 // Also verify that the recipients returned without count match the expected
1167 // values.
1168 QVERIFY(recipientsNoCount.contains("ABCDEF12"));
1169 QVERIFY(recipientsNoCount.contains("34567890"));
1170}
1171
1172void tst_util::getGpgIdPathBasic() {
1173 QTemporaryDir tempDir;
1174 QString passStore = tempDir.path();
1175 QString gpgIdFile = passStore + "/.gpg-id";
1176
1177 QFile file(gpgIdFile);
1178 QVERIFY(file.open(QIODevice::WriteOnly));
1179 file.write("ABCDEF12\n");
1180 file.close();
1181
1182 PassStoreGuard guard(QtPassSettings::getPassStore());
1184 QString path = QDir::cleanPath(Pass::getGpgIdPath(passStore));
1185 QString expected = QDir::cleanPath(gpgIdFile);
1186 QVERIFY2(path == expected,
1187 qPrintable(QString("Expected %1, got %2").arg(expected, path)));
1188}
1189
1190void tst_util::getGpgIdPathSubfolder() {
1191 QTemporaryDir tempDir;
1192 QString passStore = tempDir.path();
1193 QString subfolder = passStore + "/subfolder";
1194 QString gpgIdFile = subfolder + "/.gpg-id";
1195
1196 QVERIFY(QDir().mkdir(subfolder));
1197 QFile file(gpgIdFile);
1198 QVERIFY(file.open(QIODevice::WriteOnly));
1199 file.write("ABCDEF12\n");
1200 file.close();
1201
1202 PassStoreGuard guard(QtPassSettings::getPassStore());
1204 QString path = Pass::getGpgIdPath(subfolder + "/password.gpg");
1205 QVERIFY2(path == gpgIdFile,
1206 qPrintable(QString("Expected %1, got %2").arg(gpgIdFile, path)));
1207}
1208
1209void tst_util::getGpgIdPathNotFound() {
1210 QTemporaryDir tempDir;
1211 QString passStore = tempDir.path();
1212
1213 PassStoreGuard guard(QtPassSettings::getPassStore());
1215 QString path =
1216 QDir::cleanPath(Pass::getGpgIdPath(passStore + "/nonexistent"));
1217 QString expected = QDir::cleanPath(passStore + "/.gpg-id");
1218 QVERIFY2(path == expected,
1219 qPrintable(QString("Expected %1, got %2").arg(expected, path)));
1220}
1221
1222// Tests for findBinaryInPath - verifies it correctly locates executables in
1223// PATH.
1224
1225void tst_util::findBinaryInPathReturnedPathIsAbsolute() {
1226 // Verify that the returned path is absolute, not a relative fragment.
1227#ifdef Q_OS_WIN
1228 const QString binaryName = QStringLiteral("cmd.exe");
1229#else
1230 const QString binaryName = QStringLiteral("sh");
1231#endif
1232 QString result = Util::findBinaryInPath(binaryName);
1233 QVERIFY2(!result.isEmpty(), "Should find a standard shell");
1234 QFileInfo fi(result);
1235 QVERIFY2(
1236 fi.isAbsolute(),
1237 qPrintable(
1238 QStringLiteral("Returned path '%1' must be absolute").arg(result)));
1239}
1240
1241void tst_util::findBinaryInPathReturnedPathIsExecutable() {
1242 // Verify the returned path satisfies the isExecutable() check that guards
1243 // the assignment inside the loop.
1244#ifdef Q_OS_WIN
1245 const QString binaryName = QStringLiteral("cmd.exe");
1246#else
1247 const QString binaryName = QStringLiteral("sh");
1248#endif
1249 QString result = Util::findBinaryInPath(binaryName);
1250 QVERIFY2(!result.isEmpty(), "Should find a standard shell");
1251 QFileInfo fi(result);
1252 QVERIFY2(
1253 fi.isExecutable(),
1254 qPrintable(
1255 QStringLiteral("Returned path '%1' must be executable").arg(result)));
1256}
1257
1258void tst_util::findBinaryInPathMultipleKnownBinaries() {
1259 // Test finding multiple common binaries in PATH.
1260#ifndef Q_OS_WIN
1261 const QStringList binaries = {QStringLiteral("sh"), QStringLiteral("ls"),
1262 QStringLiteral("cat")};
1263 for (const QString &bin : binaries) {
1264 QString result = Util::findBinaryInPath(bin);
1265 QVERIFY2(!result.isEmpty(),
1266 qPrintable(QStringLiteral("Should find '%1' in PATH").arg(bin)));
1267 QVERIFY2(result.contains(bin),
1268 qPrintable(QStringLiteral("Result '%1' should contain '%2'")
1269 .arg(result, bin)));
1270 QVERIFY2(
1271 QFileInfo(result).isExecutable(),
1272 qPrintable(
1273 QStringLiteral("Result '%1' should be executable").arg(result)));
1274 }
1275#else
1276 QSKIP("Non-Windows binary list not applicable on Windows");
1277#endif
1278}
1279
1280void tst_util::findBinaryInPathConsistency() {
1281 // Calling findBinaryInPath twice for the same binary must return the same
1282 // result, confirming the loop does not corrupt state across calls.
1283#ifdef Q_OS_WIN
1284 const QString binaryName = QStringLiteral("cmd.exe");
1285#else
1286 const QString binaryName = QStringLiteral("sh");
1287#endif
1288 QString first = Util::findBinaryInPath(binaryName);
1289 QString second = Util::findBinaryInPath(binaryName);
1290 QVERIFY2(!first.isEmpty(), "First call should find the binary");
1291 QCOMPARE(first, second);
1292}
1293
1294void tst_util::findBinaryInPathResultContainsBinaryName() {
1295 // The returned absolute path must end with (or at least contain) the
1296 // binary name, ruling out any off-by-one concatenation artefact.
1297#ifdef Q_OS_WIN
1298 const QString binaryName = QStringLiteral("cmd");
1299#else
1300 const QString binaryName = QStringLiteral("sh");
1301#endif
1302 QString result = Util::findBinaryInPath(binaryName);
1303 QVERIFY2(!result.isEmpty(), "Should find the binary");
1304 QVERIFY2(
1305 result.endsWith(binaryName) ||
1306 result.endsWith(binaryName + QStringLiteral(".exe")),
1307 qPrintable(QStringLiteral("Path '%1' should end with binary name '%2'")
1308 .arg(result, binaryName)));
1309}
1310
1311void tst_util::findBinaryInPathTempExecutableInTempDir() {
1312 // Place a real executable in the same directory as "sh" (which is on the
1313 // cached PATH) and verify findBinaryInPath locates it.
1314 //
1315 // This test is skipped in restricted environments where writing to the "sh"
1316 // directory is not allowed. An alternative approach (QTemporaryDir + PATH
1317 // manipulation) doesn't work because Util::_env is cached on first use.
1318#ifndef Q_OS_WIN
1319 QString shPath = Util::findBinaryInPath(QStringLiteral("sh"));
1320 if (shPath.isEmpty()) {
1321 QSKIP("Cannot find 'sh' to determine a writable PATH directory");
1322 }
1323 const QString pathDir = QFileInfo(shPath).absolutePath();
1324 const QString uniqueName = QStringLiteral("qtpass_test_exec_") +
1325 QUuid::createUuid().toString(QUuid::WithoutBraces);
1326 const QString uniquePath = pathDir + QDir::separator() + uniqueName;
1327
1328 QFile exec(uniquePath);
1329 if (!exec.open(QIODevice::WriteOnly)) {
1330 QSKIP("Cannot write to the PATH directory containing 'sh' (need write "
1331 "access)");
1332 }
1333 QVERIFY2(exec.exists(), "File should exist after opening for writing");
1334 exec.write("#!/bin/sh\n");
1335 exec.close();
1336 exec.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner |
1337 QFileDevice::ExeOwner);
1338
1339 QString result = Util::findBinaryInPath(uniqueName);
1340
1341 // Remove file before assertions so it is always cleaned up.
1342 const bool removed = QFile::remove(uniquePath);
1343 QVERIFY2(
1344 removed,
1345 qPrintable(
1346 QStringLiteral("Failed to clean up test file '%1'").arg(uniquePath)));
1347
1348 QVERIFY2(!result.isEmpty(),
1349 "findBinaryInPath should locate the executable placed in a PATH "
1350 "directory");
1351 QVERIFY2(result.endsWith(uniqueName),
1352 qPrintable(QStringLiteral("Result '%1' should end with '%2'")
1353 .arg(result, uniqueName)));
1354 QVERIFY2(QFileInfo(result).isAbsolute(), "Result must be an absolute path");
1355#else
1356 QSKIP("Temp-executable test is Unix-only");
1357#endif
1358}
1359
1360void tst_util::buildClipboardMimeDataLinux() {
1361#ifdef Q_OS_LINUX
1362 QMimeData *mime = buildClipboardMimeData(QStringLiteral("testpassword"));
1363 QVERIFY(mime != nullptr);
1364 QVERIFY2(mime->hasText(), "Mime data should contain text");
1365 QVERIFY2(mime->text() == "testpassword", "Text should match");
1366 QVERIFY2(mime->data("x-kde-passwordManagerHint") == QByteArray("secret"),
1367 "Linux should set password hint");
1368 delete mime;
1369#else
1370 QSKIP("Linux-only test");
1371#endif
1372}
1373
1374void tst_util::buildClipboardMimeDataWindows() {
1375#ifdef Q_OS_WIN
1376 QMimeData *mime = buildClipboardMimeData(QStringLiteral("testpassword"));
1377 QVERIFY(mime != nullptr);
1378 QVERIFY2(mime->hasText(), "Mime data should contain text");
1379 QVERIFY2(mime->text() == "testpassword", "Text should match");
1380 QByteArray excl = mime->data("ExcludeClipboardContentFromMonitorProcessing");
1381 QVERIFY2(excl.size() == 4, "Windows ExcludeClipboard should be 4 bytes");
1382 QVERIFY2(excl == dwordBytes(1), "Windows ExcludeClipboard should be DWORD 1");
1383 QVERIFY(mime->hasFormat("ExcludeClipboardContentFromMonitorProcessing"));
1384 QVERIFY(mime->hasFormat("CanIncludeInClipboardHistory"));
1385 QVERIFY(mime->hasFormat("CanUploadToCloudClipboard"));
1386 QByteArray canHistory = mime->data("CanIncludeInClipboardHistory");
1387 QVERIFY2(canHistory.size() == 4,
1388 "CanIncludeInClipboardHistory should be 4 bytes");
1389 QVERIFY2(canHistory == dwordBytes(0),
1390 "CanIncludeInClipboardHistory should be DWORD 0");
1391 QByteArray cloudClip = mime->data("CanUploadToCloudClipboard");
1392 QVERIFY2(cloudClip.size() == 4,
1393 "CanUploadToCloudClipboard should be 4 bytes");
1394 QVERIFY2(cloudClip == dwordBytes(0),
1395 "CanUploadToCloudClipboard should be DWORD 0");
1396 delete mime;
1397#else
1398 QSKIP("Windows-only test");
1399#endif
1400}
1401
1402void tst_util::buildClipboardMimeDataDword() {
1403#ifdef Q_OS_WIN
1404 QByteArray zero = dwordBytes(0);
1405 QVERIFY2(zero.size() == 4, "DWORD should be 4 bytes");
1406 QVERIFY2(zero.at(0) == char(0), "DWORD 0 should be 0x00");
1407 QVERIFY2(zero.at(1) == char(0), "DWORD 0 should be 0x00");
1408 QVERIFY2(zero.at(2) == char(0), "DWORD 0 should be 0x00");
1409 QVERIFY2(zero.at(3) == char(0), "DWORD 0 should be 0x00");
1410
1411 QByteArray one = dwordBytes(1);
1412 QVERIFY2(one.size() == 4, "DWORD should be 4 bytes");
1413 QVERIFY2(one.at(0) == char(1), "DWORD 1 should be 0x01");
1414 QVERIFY2(one.at(1) == char(0), "DWORD 1 should be 0x00");
1415 QVERIFY2(one.at(2) == char(0), "DWORD 1 should be 0x00");
1416 QVERIFY2(one.at(3) == char(0), "DWORD 1 should be 0x00");
1417#else
1418 QSKIP("Windows-only test");
1419#endif
1420}
1421
1422void tst_util::buildClipboardMimeDataMac() {
1423#ifdef Q_OS_MAC
1424 QMimeData *mime = buildClipboardMimeData(QStringLiteral("testpassword"));
1425 QVERIFY(mime != nullptr);
1426 QVERIFY2(mime->hasText(), "Mime data should contain text");
1427 QVERIFY2(mime->text() == "testpassword", "Text should match");
1428 QVERIFY2(mime->hasFormat("application/x-nspasteboard-concealed-type"),
1429 "macOS should have concealed type format");
1430 QVERIFY2(mime->data("application/x-nspasteboard-concealed-type") ==
1431 QByteArray(),
1432 "macOS concealed type should be empty");
1433 delete mime;
1434#else
1435 QSKIP("macOS-only test");
1436#endif
1437}
1438
1439void tst_util::utilRegexEnsuresGpg() {
1440 const QRegularExpression &rex = Util::endsWithGpg();
1441 QVERIFY2(rex.isValid(), "Regex should be valid");
1442 QVERIFY2(rex.match("file.gpg").hasMatch(), "Should match .gpg extension");
1443 QVERIFY2(!rex.match("file.txt").hasMatch(), "Should not match .txt");
1444 QVERIFY2(!rex.match("test.gpgx").hasMatch(), "Should not match .gpgx");
1445}
1446
1447void tst_util::utilRegexProtocol() {
1448 const QRegularExpression &rex = Util::protocolRegex();
1449 QVERIFY2(rex.isValid(), "Protocol regex should be valid");
1450 QVERIFY2(rex.match("http://example.com").hasMatch(), "Should match http://");
1451 QVERIFY2(rex.match("https://secure.com").hasMatch(), "Should match https://");
1452 QVERIFY2(rex.match("ssh://host").hasMatch(), "Should match ssh://");
1453 QVERIFY2(!rex.match("://no-protocol").hasMatch(), "Should not match invalid");
1454}
1455
1456void tst_util::utilRegexNewLines() {
1457 const QRegularExpression &rex = Util::newLinesRegex();
1458 QVERIFY2(rex.isValid(), "Newlines regex should be valid");
1459 QVERIFY2(rex.match("\n").hasMatch(), "Should match newline");
1460 QVERIFY2(rex.match("line1\nline2").hasMatch(),
1461 "Should match embedded newline");
1462}
1463
1464void tst_util::reencryptPathNormalization() {
1465 QTemporaryDir tempDir;
1466 QVERIFY2(tempDir.isValid(), "Temporary directory should be created");
1467
1468 QString basePath = tempDir.path();
1469 QString withExtraSlashes = basePath + "/./subdir/../";
1470 QString cleaned = QDir::cleanPath(withExtraSlashes);
1471 QString normalized = QDir::cleanPath(basePath);
1472 QVERIFY2(cleaned == normalized,
1473 qPrintable(QString("cleanPath should normalize: expected %1, got %2")
1474 .arg(normalized, cleaned)));
1475}
1476
1477void tst_util::reencryptPathAbsolutePath() {
1478 QTemporaryDir tempDir;
1479 QVERIFY2(tempDir.isValid(), "Temporary directory should be created");
1480
1481 QString tempPath = tempDir.path();
1482 QDir(tempPath).mkdir("testdir");
1483 QString relativePathFromTemp = tempPath + "/testdir";
1484 QDir dir;
1485 QString result = QDir::cleanPath(QDir(relativePathFromTemp).absolutePath());
1486 QString expected = QDir::cleanPath(tempPath + "/testdir");
1487 QVERIFY2(
1488 result == expected,
1489 qPrintable(
1490 QString("Absolute path: expected %1, got %2").arg(expected, result)));
1491}
1492
1493// Tests targeting the const-ref refactor of findBinaryInPath.
1494// The PR changed the signature from findBinaryInPath(QString) to
1495// findBinaryInPath(const QString &). These tests verify that callers using
1496// const-qualified variables continue to work correctly.
1497
1498void tst_util::findBinaryInPathWithConstQStringRef() {
1499 // Pass a const-qualified variable to verify the const-ref signature compiles
1500 // and executes correctly.
1501#ifdef Q_OS_WIN
1502 const QString binaryName = QStringLiteral("cmd.exe");
1503#else
1504 const QString binaryName = QStringLiteral("sh");
1505#endif
1506 const QString result = Util::findBinaryInPath(binaryName);
1507 QVERIFY2(!result.isEmpty(),
1508 "findBinaryInPath should find shell with const QString& arg");
1509 QVERIFY2(result.contains(binaryName),
1510 "Result should contain the binary name");
1511 QVERIFY2(QFileInfo(result).isAbsolute(), "Returned path should be absolute");
1512}
1513
1514void tst_util::findBinaryInPathEmptyString() {
1515 // Boundary case: passing an empty string should return an empty result
1516 // without crashing. This exercises the loop guard when 'binary' is empty.
1517 const QString result = Util::findBinaryInPath(QString());
1518 QVERIFY2(result.isEmpty(),
1519 "findBinaryInPath(\"\") should return empty QString");
1520}
1521
1522void tst_util::findBinaryInPathStringLiteral() {
1523 // Passing a string literal directly (temporary, binds to const ref) must
1524 // work identically to passing a named variable.
1525#ifndef Q_OS_WIN
1526 const QString resultDirect = Util::findBinaryInPath("sh");
1527 const QString binaryName = QStringLiteral("sh");
1528 const QString resultNamed = Util::findBinaryInPath(binaryName);
1529 QVERIFY2(!resultDirect.isEmpty(),
1530 "findBinaryInPath with string literal should succeed");
1531 QCOMPARE(resultDirect, resultNamed);
1532#else
1533 QSKIP("Unix-only test");
1534#endif
1535}
1536
1537void tst_util::setEnvVarAdds() {
1538 TestPass pass;
1539 pass.callSetEnvVar(QStringLiteral("TEST_KEY="), QStringLiteral("hello"));
1540 QStringList env = pass.environment();
1541 QVERIFY2(env.contains(QStringLiteral("TEST_KEY=hello")),
1542 "setEnvVar should append key=value when absent");
1543}
1544
1545void tst_util::setEnvVarUpdates() {
1546 TestPass pass;
1547 pass.callSetEnvVar(QStringLiteral("TEST_KEY="), QStringLiteral("first"));
1548 pass.callSetEnvVar(QStringLiteral("TEST_KEY="), QStringLiteral("second"));
1549 QStringList env = pass.environment();
1550 QVERIFY2(env.contains(QStringLiteral("TEST_KEY=second")),
1551 "setEnvVar should update existing entry");
1552 QVERIFY2(!env.contains(QStringLiteral("TEST_KEY=first")),
1553 "setEnvVar should remove old value");
1554 QCOMPARE(env.filter(QStringLiteral("TEST_KEY=")).size(), 1);
1555}
1556
1557void tst_util::setEnvVarRemoves() {
1558 TestPass pass;
1559 pass.callSetEnvVar(QStringLiteral("TEST_KEY="), QStringLiteral("value"));
1560 pass.callSetEnvVar(QStringLiteral("TEST_KEY="), QString());
1561 QStringList env = pass.environment();
1562 QVERIFY2(env.filter(QStringLiteral("TEST_KEY=")).isEmpty(),
1563 "setEnvVar with empty value should remove the entry");
1564}
1565
1566void tst_util::setEnvVarNoopOnMissingRemove() {
1567 TestPass pass;
1568 QStringList before = pass.environment();
1569 pass.callSetEnvVar(QStringLiteral("NONEXISTENT_KEY="), QString());
1570 QStringList after = pass.environment();
1571 QCOMPARE(before, after);
1572}
1573
1574void tst_util::updateEnvSetsExpectedVars() {
1575 TestPass pass;
1576 QTemporaryDir tmpDir;
1577 QVERIFY(tmpDir.isValid());
1578 PassStoreGuard storeGuard(QtPassSettings::getPassStore());
1579 QtPassSettings::setPassStore(tmpDir.path());
1580
1581 QStringList env = pass.environment();
1582 QVERIFY2(env.filter(QStringLiteral("PASSWORD_STORE_DIR=")).size() == 1,
1583 "updateEnv should set PASSWORD_STORE_DIR");
1584 QVERIFY2(env.filter(QStringLiteral("PASSWORD_STORE_DIR="))
1585 .first()
1586 .contains(tmpDir.path()),
1587 "updateEnv should set PASSWORD_STORE_DIR to configured store path");
1588 QVERIFY2(
1589 env.filter(QStringLiteral("PASSWORD_STORE_GENERATED_LENGTH=")).size() ==
1590 1,
1591 "updateEnv should set PASSWORD_STORE_GENERATED_LENGTH");
1592 QVERIFY2(env.filter(QStringLiteral("PASSWORD_STORE_CHARACTER_SET=")).size() ==
1593 1,
1594 "updateEnv should set PASSWORD_STORE_CHARACTER_SET");
1595}
1596
1597void tst_util::updateEnvEmptyCustomCharsetFallsBackToAllChars() {
1598 TestPass pass;
1599 PasswordConfiguration original = QtPassSettings::getPasswordConfiguration();
1600 struct ConfigRollback {
1601 PasswordConfiguration value;
1602 ~ConfigRollback() { QtPassSettings::setPasswordConfiguration(value); }
1603 } rollback{original};
1604
1605 PasswordConfiguration config = original;
1607 config.Characters[PasswordConfiguration::CUSTOM] = QString();
1609
1610 QStringList env = pass.environment();
1611 QStringList charsetEntries =
1612 env.filter(QStringLiteral("PASSWORD_STORE_CHARACTER_SET="));
1613 QVERIFY2(
1614 charsetEntries.size() == 1,
1615 "PASSWORD_STORE_CHARACTER_SET should be set even when CUSTOM is empty");
1616 QString val = charsetEntries.first().mid(
1617 QStringLiteral("PASSWORD_STORE_CHARACTER_SET=").size());
1618 QVERIFY2(!val.isEmpty(),
1619 "charset should fall back to ALLCHARS, not be empty");
1620}
1621
1622void tst_util::updateEnvWslenvContainsRequiredVars() {
1623 TestPass pass;
1624 const QStringList env = pass.environment();
1625 // Use startsWith to avoid substring false-positives (e.g. MY_WSLENV=).
1626 const QStringList wslenvEntries =
1627 env.filter(QRegularExpression(QStringLiteral("^WSLENV=")));
1628 QVERIFY2(!wslenvEntries.isEmpty(),
1629 "At least one WSLENV entry expected after Pass construction");
1630 // Verify Pass::Pass() merged all required keys with correct WSL flags.
1631 const QString wslenv = wslenvEntries.first();
1632 QVERIFY2(wslenv.contains(QStringLiteral("PASSWORD_STORE_DIR/p")),
1633 "WSLENV should include PASSWORD_STORE_DIR/p");
1634 QVERIFY2(wslenv.contains(QStringLiteral("PASSWORD_STORE_GENERATED_LENGTH/w")),
1635 "WSLENV should include PASSWORD_STORE_GENERATED_LENGTH/w");
1636 QVERIFY2(wslenv.contains(QStringLiteral("PASSWORD_STORE_CHARACTER_SET/w")),
1637 "WSLENV should include PASSWORD_STORE_CHARACTER_SET/w");
1638}
1639
1640// --- gpgErrorMessage tests ---
1641
1642void tst_util::gpgErrorMessageKeyExpiredStatusToken() {
1643 QString err = "[GNUPG:] KEYEXPIRED 1234567890\n"
1644 "gpg: key DEADBEEF: key has expired\n"
1645 "gpg: [stdin]: encryption failed: Unusable public key";
1646 QString msg = gpgErrorMessage(err);
1647 QVERIFY2(!msg.isEmpty(), "Should recognise KEYEXPIRED status token");
1648 QVERIFY2(msg.contains("expired", Qt::CaseInsensitive),
1649 qPrintable("Expected 'expired' in: " + msg));
1650}
1651
1652void tst_util::gpgErrorMessageKeyRevokedStatusToken() {
1653 QString err = "[GNUPG:] KEYREVOKED\n"
1654 "gpg: [stdin]: encryption failed: Unusable public key";
1655 QString msg = gpgErrorMessage(err);
1656 QVERIFY2(!msg.isEmpty(), "Should recognise KEYREVOKED status token");
1657 QVERIFY2(msg.contains("revoked", Qt::CaseInsensitive),
1658 qPrintable("Expected 'revoked' in: " + msg));
1659}
1660
1661void tst_util::gpgErrorMessageNoPubkeyStatusToken() {
1662 QString err = "[GNUPG:] NO_PUBKEY DEADBEEFDEADBEEF\n"
1663 "gpg: DEADBEEF: skipped: No public key";
1664 QString msg = gpgErrorMessage(err);
1665 QVERIFY2(!msg.isEmpty(), "Should recognise NO_PUBKEY status token");
1666 QVERIFY2(msg.contains("not found", Qt::CaseInsensitive) ||
1667 msg.contains("invalid", Qt::CaseInsensitive),
1668 qPrintable("Expected 'not found' or 'invalid' in: " + msg));
1669}
1670
1671void tst_util::gpgErrorMessageInvRecpStatusToken() {
1672 // reason code 5 = expired
1673 QString errExpired = "[GNUPG:] INV_RECP 5 DEADBEEF\n"
1674 "gpg: [stdin]: encryption failed: Unusable public key";
1675 QString msgExpired = gpgErrorMessage(errExpired);
1676 QVERIFY2(!msgExpired.isEmpty(), "Should recognise INV_RECP 5 (expired)");
1677 QVERIFY2(msgExpired.contains("expired", Qt::CaseInsensitive),
1678 qPrintable("Expected 'expired' for INV_RECP 5: " + msgExpired));
1679
1680 // reason code 4 = revoked
1681 QString errRevoked = "[GNUPG:] INV_RECP 4 DEADBEEF\n"
1682 "gpg: [stdin]: encryption failed: Unusable public key";
1683 QString msgRevoked = gpgErrorMessage(errRevoked);
1684 QVERIFY2(!msgRevoked.isEmpty(), "Should recognise INV_RECP 4 (revoked)");
1685 QVERIFY2(msgRevoked.contains("revoked", Qt::CaseInsensitive),
1686 qPrintable("Expected 'revoked' for INV_RECP 4: " + msgRevoked));
1687
1688 // generic reason code
1689 QString errGeneric = "[GNUPG:] INV_RECP 10 DEADBEEF\n"
1690 "gpg: [stdin]: encryption failed: Unusable public key";
1691 QString msgGeneric = gpgErrorMessage(errGeneric);
1692 QVERIFY2(!msgGeneric.isEmpty(), "Should recognise INV_RECP status token");
1693 QVERIFY2(msgGeneric.contains("not found", Qt::CaseInsensitive) ||
1694 msgGeneric.contains("invalid", Qt::CaseInsensitive),
1695 qPrintable("Expected 'not found' or 'invalid' in: " + msgGeneric));
1696}
1697
1698void tst_util::gpgErrorMessageFailureStatusToken() {
1699 QString err = "[GNUPG:] FAILURE encrypt 67108949";
1700 QString msg = gpgErrorMessage(err);
1701 QVERIFY2(!msg.isEmpty(), "Should recognise FAILURE status token");
1702 QVERIFY2(msg.contains("failed", Qt::CaseInsensitive),
1703 qPrintable("Expected 'failed' in: " + msg));
1704}
1705
1706void tst_util::gpgErrorMessageKeyExpiredFallback() {
1707 QString err = "gpg: key DEADBEEF: key has expired\n"
1708 "gpg: [stdin]: encryption failed: Unusable public key";
1709 QString msg = gpgErrorMessage(err);
1710 QVERIFY2(!msg.isEmpty(), "Should recognise 'key has expired' fallback");
1711 QVERIFY2(msg.contains("expired", Qt::CaseInsensitive),
1712 qPrintable("Expected 'expired' in: " + msg));
1713}
1714
1715void tst_util::gpgErrorMessageRevokedFallback() {
1716 QString err = "gpg: key DEADBEEF: key has been revoked\n"
1717 "gpg: [stdin]: encryption failed: Unusable public key";
1718 QString msg = gpgErrorMessage(err);
1719 QVERIFY2(!msg.isEmpty(), "Should recognise 'key has been revoked' fallback");
1720 QVERIFY2(msg.contains("revoked", Qt::CaseInsensitive),
1721 qPrintable("Expected 'revoked' in: " + msg));
1722}
1723
1724void tst_util::gpgErrorMessageNoPubkeyFallback() {
1725 QString err = "gpg: DEADBEEF: skipped: No public key\n"
1726 "gpg: [stdin]: encryption failed: No public key";
1727 QString msg = gpgErrorMessage(err);
1728 QVERIFY2(!msg.isEmpty(), "Should recognise 'No public key' fallback");
1729 QVERIFY2(msg.contains("not found", Qt::CaseInsensitive) ||
1730 msg.contains("invalid", Qt::CaseInsensitive),
1731 qPrintable("Expected 'not found' or 'invalid' in: " + msg));
1732}
1733
1734void tst_util::gpgErrorMessageEncryptionFailedFallback() {
1735 QString err = "gpg: [stdin]: encryption failed: Unusable public key";
1736 QString msg = gpgErrorMessage(err);
1737 QVERIFY2(!msg.isEmpty(),
1738 "Should recognise generic 'encryption failed' fallback");
1739 QVERIFY2(msg.contains("failed", Qt::CaseInsensitive),
1740 qPrintable("Expected 'failed' in: " + msg));
1741}
1742
1743void tst_util::gpgErrorMessageUnknownReturnsEmpty() {
1744 QString err = "some unrelated process error output";
1745 QString msg = gpgErrorMessage(err);
1746 QVERIFY2(msg.isEmpty(),
1747 "Should return empty string for unrecognised GPG output");
1748}
1749
1750void tst_util::gpgErrorMessageStatusTokenTakesPriorityOverFallback() {
1751 // KEYEXPIRED token present alongside a generic "encryption failed" line —
1752 // the specific expired message should be returned, not the generic fallback.
1753 QString err = "[GNUPG:] KEYEXPIRED 1234567890\n"
1754 "gpg: [stdin]: encryption failed: Unusable public key";
1755 QString msg = gpgErrorMessage(err);
1756 QVERIFY2(msg.contains("expired", Qt::CaseInsensitive),
1757 "KEYEXPIRED token should take priority and mention 'expired'");
1758}
1759
1760// --- parseGrepOutput tests ---
1761
1762void tst_util::parseGrepOutputEmpty() {
1763 auto results = parseGrepOutput(QString());
1764 QVERIFY(results.isEmpty());
1765}
1766
1767void tst_util::parseGrepOutputSingleEntry() {
1768 // Simulate: header with ANSI blue, then one match line
1769 const QString raw = QStringLiteral("\x1B[94mmy/entry\x1B[0m:\nsome match\n");
1770 auto results = parseGrepOutput(raw);
1771 QCOMPARE(results.size(), 1);
1772 QCOMPARE(results[0].first, QStringLiteral("my/entry"));
1773 QCOMPARE(results[0].second.size(), 1);
1774 QCOMPARE(results[0].second[0], QStringLiteral("some match"));
1775}
1776
1777void tst_util::parseGrepOutputMultipleEntries() {
1778 const QString raw = QStringLiteral("\x1B[94mentry/a\x1B[0m:\nline1\nline2\n"
1779 "\x1B[94mentry/b\x1B[0m:\nlineX\n");
1780 auto results = parseGrepOutput(raw);
1781 QCOMPARE(results.size(), 2);
1782 QCOMPARE(results[0].first, QStringLiteral("entry/a"));
1783 QCOMPARE(results[0].second.size(), 2);
1784 QCOMPARE(results[1].first, QStringLiteral("entry/b"));
1785 QCOMPARE(results[1].second.size(), 1);
1786}
1787
1788void tst_util::parseGrepOutputAnsiStripped() {
1789 // Match line itself contains ANSI colour (grep highlights the match)
1790 const QString raw =
1791 QStringLiteral("\x1B[94mfoo\x1B[0m:\n\x1B[1mhi\x1B[0m there\n");
1792 auto results = parseGrepOutput(raw);
1793 QCOMPARE(results.size(), 1);
1794 QCOMPARE(results[0].second[0], QStringLiteral("hi there"));
1795}
1796
1797void tst_util::parseGrepOutputHeaderColonStripped() {
1798 const QString raw = QStringLiteral("\x1B[94msome/path:\x1B[0m\nvalue\n");
1799 auto results = parseGrepOutput(raw);
1800 QCOMPARE(results.size(), 1);
1801 // Trailing colon must be removed from the entry name
1802 QVERIFY2(
1803 !results[0].first.endsWith(':'),
1804 qPrintable("Entry should not end with ':', got: " + results[0].first));
1805}
1806
1807void tst_util::parseGrepOutputCrlfHandled() {
1808 const QString raw = QStringLiteral("\x1B[94mentry\x1B[0m:\r\nmatch line\r\n");
1809 auto results = parseGrepOutput(raw);
1810 QCOMPARE(results.size(), 1);
1811 QVERIFY2(!results[0].second[0].contains('\r'),
1812 "Match line must not contain CR");
1813}
1814
1815void tst_util::parseGrepOutputOrphanMatchesIgnored() {
1816 // Lines before any header should be silently dropped
1817 const QString raw =
1818 QStringLiteral("orphan line\n\x1B[94mentry\x1B[0m:\nreal match\n");
1819 auto results = parseGrepOutput(raw);
1820 QCOMPARE(results.size(), 1);
1821 QCOMPARE(results[0].second.size(), 1);
1822 QCOMPARE(results[0].second[0], QStringLiteral("real match"));
1823}
1824
1825void tst_util::parseGrepOutputEmptyMatchLinesIgnored() {
1826 const QString raw = QStringLiteral("\x1B[94mentry\x1B[0m:\n\n \nmatch\n");
1827 auto results = parseGrepOutput(raw);
1828 QCOMPARE(results.size(), 1);
1829 QCOMPARE(results[0].second.size(), 1);
1830}
1831
1832void tst_util::parseGrepOutputLastEntryIncluded() {
1833 // Ensure final entry is flushed even without a trailing newline
1834 const QString raw = QStringLiteral("\x1B[94mfinal\x1B[0m:\nvalue");
1835 auto results = parseGrepOutput(raw);
1836 QCOMPARE(results.size(), 1);
1837 QCOMPARE(results[0].first, QStringLiteral("final"));
1838}
1839
1840void tst_util::parseGrepOutputEmbeddedBlueNotHeader() {
1841 // Match line contains \x1B[94m mid-line (grep highlights the search term in
1842 // blue); must not be mistaken for a header — previous matches must not be
1843 // lost
1844 const QString raw = QStringLiteral(
1845 "\x1B[94mentry/a\x1B[0m:\nfirst match\ncontains \x1B[94mblue\x1B[0m "
1846 "highlight\n\x1B[94mentry/b\x1B[0m:\nother\n");
1847 auto results = parseGrepOutput(raw);
1848 QCOMPARE(results.size(), 2);
1849 QCOMPARE(results[0].first, QStringLiteral("entry/a"));
1850 QCOMPARE(results[0].second.size(), 2);
1851 QCOMPARE(results[0].second[1], QStringLiteral("contains blue highlight"));
1852}
1853
1854void tst_util::parseGrepOutputPlainTextHeaders() {
1855 // pass grep without ANSI colours (e.g. NO_COLOR or non-TTY output)
1856 const QString raw =
1857 QStringLiteral("entry/a:\n match one\n match two\nentry/b:\n other\n");
1858 auto results = parseGrepOutput(raw);
1859 QCOMPARE(results.size(), 2);
1860 QCOMPARE(results[0].first, QStringLiteral("entry/a"));
1861 QCOMPARE(results[0].second.size(), 2);
1862 QCOMPARE(results[1].first, QStringLiteral("entry/b"));
1863 QCOMPARE(results[1].second.size(), 1);
1864}
1865
1866// --- Pass::finished PASS_GREP exit-code tests ---
1867
1868void tst_util::passFinishedGrepNoMatchEmitsEmpty() {
1869 TestPass pass;
1870 QSignalSpy spy(&pass, &Pass::finishedGrep);
1871 QSignalSpy errSpy(&pass, &Pass::processErrorExit);
1872 // exit code 1 = no matches (standard grep behaviour)
1873 pass.callPassFinished(static_cast<int>(Enums::PASS_GREP), 1, QString(),
1874 QString());
1875 QCOMPARE(spy.count(), 1);
1876 QCOMPARE(errSpy.count(), 0);
1877 const auto results = spy[0][0].value<GrepResults>();
1878 QVERIFY(results.isEmpty());
1879}
1880
1881void tst_util::passFinishedGrepErrorEmitsProcessError() {
1882 TestPass pass;
1883 QSignalSpy spy(&pass, &Pass::finishedGrep);
1884 QSignalSpy errSpy(&pass, &Pass::processErrorExit);
1885 // exit code 2 = real grep error
1886 pass.callPassFinished(static_cast<int>(Enums::PASS_GREP), 2, QString(),
1887 QStringLiteral("some gpg error"));
1888 QCOMPARE(errSpy.count(), 1);
1889 QCOMPARE(spy.count(), 1);
1890 QVERIFY(spy[0][0].value<GrepResults>().isEmpty());
1891}
1892
1893void tst_util::passFinishedGrepSuccessEmitsResults() {
1894 TestPass pass;
1895 QSignalSpy spy(&pass, &Pass::finishedGrep);
1896 // Simulate output from 'pass grep' with one matching entry
1897 const QString out =
1898 QStringLiteral("\x1B[94mwork/github\x1B[0m:\ntoken: abc123\n");
1899 pass.callPassFinished(static_cast<int>(Enums::PASS_GREP), 0, out, QString());
1900 // Verify signal was emitted
1901 QVERIFY2(spy.count() == 1, "finishedGrep should be emitted exactly once");
1902 const auto results = spy[0][0].value<GrepResults>();
1903 QVERIFY2(results.size() == 1, "Should have one matching entry");
1904 const auto &entry = results[0];
1905 QVERIFY2(entry.first == "work/github", "Entry path should be 'work/github'");
1906 QVERIFY2(entry.second.size() == 1, "Should have one matched line");
1907 QVERIFY2(entry.second[0] == "token: abc123",
1908 "Matched line should be 'token: abc123'");
1909}
1910
1911// --- ImitatePass helper / Grep tests ---
1912
1913void tst_util::grepMatchFileFailedDecryptReturnsEmpty() {
1914 // grepMatchFile with a non-existent file: executeBlocking fails (rc != 0)
1915 // so the result must be empty regardless of the regex
1916 QRegularExpression rx(QStringLiteral(".*"));
1917 const QStringList env;
1918 const QStringList matches =
1919 ImitatePass::grepMatchFile(env, QStringLiteral("/nonexistent/gpg"),
1920 QStringLiteral("/no/such.gpg"), rx);
1921 QVERIFY(matches.isEmpty());
1922}
1923
1924void tst_util::grepScanStoreEmptyDirReturnsEmpty() {
1925 QTemporaryDir tmp;
1926 QVERIFY(tmp.isValid());
1927 QRegularExpression rx(QStringLiteral(".*"));
1928 const QStringList env;
1929 const auto results = ImitatePass::grepScanStore(
1930 env, QStringLiteral("/nonexistent/gpg"), tmp.path(), rx);
1931 QVERIFY(results.isEmpty());
1932}
1933
1934void tst_util::grepImitatePassEmptyStoreEmitsEmpty() {
1935 QTemporaryDir tmp;
1936 QVERIFY(tmp.isValid());
1937 PassStoreGuard guard(QtPassSettings::getPassStore());
1938 QtPassSettings::setPassStore(tmp.path());
1939
1940 TestPass pass;
1941 QSignalSpy spy(&pass, &Pass::finishedGrep);
1942 pass.Grep(QStringLiteral("anything"));
1943 // Wait up to 3 s for the background thread to emit
1944 QVERIFY(spy.wait(3000));
1945 QCOMPARE(spy.count(), 1);
1946 const auto results = spy[0][0].value<GrepResults>();
1947 QVERIFY(results.isEmpty());
1948}
1949
1950void tst_util::grepImitatePassInvalidRegexEmitsEmpty() {
1951 QTemporaryDir tmp;
1952 QVERIFY(tmp.isValid());
1953 PassStoreGuard guard(QtPassSettings::getPassStore());
1954 QtPassSettings::setPassStore(tmp.path());
1955
1956 TestPass pass;
1957 QSignalSpy spy(&pass, &Pass::finishedGrep);
1958 // An invalid regex (unmatched '[') must still emit an empty result
1959 pass.Grep(QStringLiteral("[invalid"));
1960 QVERIFY(spy.wait(3000));
1961 QCOMPARE(spy.count(), 1);
1962 const auto results = spy[0][0].value<GrepResults>();
1963 QVERIFY(results.isEmpty());
1964}
1965
1966QTEST_MAIN(tst_util)
1967#include "tst_util.moc"
auto getRemainingData() const -> QString
Gets remaining data not in named values.
auto getNamedValues() const -> NamedValues
Gets named value pairs from the parsed file.
auto getRemainingDataForDisplay() const -> QString
Gets remaining data for display (excludes hidden fields like OTP).
auto getPassword() const -> QString
Gets the password from the parsed file.
static auto parse(const QString &fileContent, const QStringList &templateFields, bool allFields) -> FileContent
parse parses the given fileContent in a FileContent object. The password is accessible through getPas...
Implementation that imitates 'pass' when the real tool is unavailable.
Definition imitatepass.h:26
auto removeDir(const QString &dirName) -> bool
Remove directory recursively.
void finished(int id, int exitCode, const QString &out, const QString &err) override
Handle process completion.
auto resolveMoveDestination(const QString &src, const QString &dest, bool force) -> QString
Resolve destination for move operation.
auto takeValue(const QString &name) -> QString
Finds and removes a named value by name.
virtual auto generatePassword(unsigned int length, const QString &charset) -> QString
Generate random password.
Definition pass.cpp:139
void setEnvVar(const QString &key, const QString &value)
Set or remove an environment variable.
Definition pass.cpp:711
static auto getRecipientList(const QString &for_file) -> QStringList
Get list of recipients for a password file.
Definition pass.cpp:778
void finishedGrep(const QList< QPair< QString, QStringList > > &results)
Emitted when grep finishes with matching results.
static auto getGpgIdPath(const QString &for_file) -> QString
Get .gpg-id file path for a password file.
Definition pass.cpp:748
void processErrorExit(int exitCode, const QString &err)
Emitted on process error exit.
void updateEnv()
Update environment for subprocesses.
Definition pass.cpp:727
virtual void finished(int id, int exitCode, const QString &out, const QString &err)
Handle process completion.
Definition pass.cpp:606
static auto getRecipientString(const QString &for_file, const QString &separator=" ", int *count=nullptr) -> QStringList
Get recipients as string.
Definition pass.cpp:801
void stopAnimation()
Stops the spin animation.
auto isAnimated() const -> bool
Returns a Boolean value indicating whether the component is currently animated.
void startAnimation()
Starts the spin animation.
static void setPassStore(const QString &passStore)
Save password store path.
static void setPasswordConfiguration(const PasswordConfiguration &config)
Save complete password generation configuration.
static auto getPasswordConfiguration() -> PasswordConfiguration
Get complete password generation configuration.
static auto isUsePass(const bool &defaultValue=QVariant().toBool()) -> bool
Get whether to use pass (true) or GPG (false).
static auto getPassStore(const QString &defaultValue=QVariant().toString()) -> QString
Get password store directory path.
static auto getGpgExecutable(const QString &defaultValue=QVariant().toString()) -> QString
Get GPG executable path.
static void setGpgExecutable(const QString &gpgExecutable)
Save GPG executable path.
static auto isUsePwgen(const bool &defaultValue=QVariant().toBool()) -> bool
Check whether pwgen support is enabled.
auto getStore() const -> QString
Get the password store root path.
Definition storemodel.h:123
void setModelAndStore(QFileSystemModel *sourceModel, const QString &passStore)
Initialize model with source model and store path.
static auto protocolRegex() -> const QRegularExpression &
Returns a regex to match URL protocols.
Definition util.cpp:269
static auto endsWithGpg() -> const QRegularExpression &
Returns a regex to match .gpg file extensions.
Definition util.cpp:252
static auto findPasswordStore() -> QString
Locate the password store directory.
Definition util.cpp:77
static auto getDir(const QModelIndex &index, bool forPass, const QFileSystemModel &model, const StoreModel &storeModel) -> QString
Get the selected folder path, either relative to the configured pass store or absolute.
Definition util.cpp:234
static auto isValidKeyId(const QString &keyId) -> bool
Check if a string looks like a valid GPG key ID. Accepts:
Definition util.cpp:280
static auto newLinesRegex() -> const QRegularExpression &
Returns a regex to match newline characters.
Definition util.cpp:275
static auto normalizeFolderPath(const QString &path) -> QString
Ensure a folder path always ends with the native directory separator.
Definition util.cpp:94
static auto findBinaryInPath(const QString &binary) -> QString
Locate an executable by searching the process PATH and (on Windows) falling back to WSL.
Definition util.cpp:120
static auto configIsValid() -> bool
Verify that the required configuration is complete.
Definition util.cpp:191
auto transactionIsOver(Enums::PROCESS) -> Enums::PROCESS
transactionIsOver checks wheather currently finished process is last in current transaction
void transactionAdd(Enums::PROCESS)
transactionAdd If called after call to transactionStart() and before transactionEnd(),...
The tst_util class is our first unit test.
Definition tst_util.cpp:32
void cleanup()
tst_util::cleanup unit test cleanup method
Definition tst_util.cpp:225
tst_util()
tst_util::tst_util basic constructor
void init()
tst_util::init unit test init method
Definition tst_util.cpp:216
~tst_util() override
tst_util::~tst_util basic destructor
PROCESS
Identifies different subprocess operations used in QtPass.
Definition enums.h:26
@ PASS_INSERT
Definition enums.h:34
@ PASS_GREP
Definition enums.h:43
@ GIT_PUSH
Definition enums.h:32
auto gpgErrorMessage(const QString &err) -> QString
Maps GPG stderr (which may include –status-fd 2 tokens) to a translated user-friendly encryption erro...
Definition pass.cpp:507
auto parseGrepOutput(const QString &rawOut) -> QList< QPair< QString, QStringList > >
Parses 'pass grep' raw output into (entry, matches) pairs.
Definition pass.cpp:568
auto buildClipboardMimeData(const QString &text) -> QMimeData *
Build clipboard MIME data with platform-specific security hints.
Definition qtpass.cpp:478
QString name
Definition filecontent.h:11
QString value
Definition filecontent.h:12
int length
Length of the password.
enum PasswordConfiguration::characterSet selected
QString Characters[CHARSETS_COUNT]
The different character sets.
bool have_secret
UserInfo::have_secret whether secret key is available (can decrypt with this key).
Definition userinfo.h:51
bool enabled
UserInfo::enabled Whether this user/key is enabled for normal use. True when the key should be treate...
Definition userinfo.h:58
auto marginallyValid() const -> bool
UserInfo::marginallyValid when validity is m. http://git.gnupg.org/cgi-bin/gitweb....
Definition userinfo.h:27
QString key_id
UserInfo::key_id hexadecimal representation of the GnuPG key identifier.
Definition userinfo.h:41
auto fullyValid() const -> bool
UserInfo::fullyValid when validity is f or u. http://git.gnupg.org/cgi-bin/gitweb....
Definition userinfo.h:20
auto isValid() const -> bool
UserInfo::isValid when fullyValid or marginallyValid.
Definition userinfo.h:31
QDateTime created
UserInfo::created date/time when key was created.
Definition userinfo.h:66
QString name
UserInfo::name GPG user ID / full name.
Definition userinfo.h:36
char validity
UserInfo::validity GnuPG representation of validity http://git.gnupg.org/cgi-bin/gitweb....
Definition userinfo.h:46
QDateTime expiry
UserInfo::expiry date/time when key expires.
Definition userinfo.h:62
QList< QPair< QString, QStringList > > GrepResults
Integration tests for ImitatePass and RealPass backends.
QList< QPair< QString, QStringList > > GrepResults
Definition tst_util.cpp:26