QtPass 1.6.0
Multi-platform GUI for pass, the standard unix password manager.
Loading...
Searching...
No Matches
tst_integration.cpp
Go to the documentation of this file.
1// SPDX-FileCopyrightText: 2026 Anne Jan Brouwer
2// SPDX-License-Identifier: GPL-3.0-or-later
3
15
16#include <QCoreApplication>
17#include <QDir>
18#include <QFile>
19#include <QFileInfo>
20#include <QProcess>
21#include <QRegularExpression>
22#include <QSignalSpy>
23#include <QStandardPaths>
24#include <QTemporaryDir>
25#include <QtTest>
26
27#include "../../../src/enums.h"
29#include "../../../src/pass.h"
33
34using GrepResults = QList<QPair<QString, QStringList>>;
35Q_DECLARE_METATYPE(GrepResults)
36
37// ---------------------------------------------------------------------------
38// Helpers
39// ---------------------------------------------------------------------------
40
41static QString findGpg() {
42 for (const auto &c : {"/usr/bin/gpg2", "/usr/bin/gpg", "/usr/local/bin/gpg2",
43 "/usr/local/bin/gpg"}) {
44 if (QFile::exists(c))
45 return c;
46 }
47 return QStandardPaths::findExecutable("gpg");
48}
49
50static QString findGpgconf() {
51 for (const auto &c : {"/usr/bin/gpgconf", "/usr/local/bin/gpgconf"}) {
52 if (QFile::exists(c))
53 return c;
54 }
55 return QStandardPaths::findExecutable("gpgconf");
56}
57
58static QString findPass() { return QStandardPaths::findExecutable("pass"); }
59
60// Run gpg synchronously with the given GNUPGHOME, return exit code.
61static int runGpg(const QString &gnupgHome, const QStringList &args,
62 const QString &input = QString(), QString *out = nullptr,
63 QString *err = nullptr) {
64 QProcess p;
65 QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
66 env.insert("GNUPGHOME", gnupgHome);
67 p.setProcessEnvironment(env);
68 p.start(findGpg(), args);
69 if (!input.isEmpty()) {
70 p.write(input.toUtf8());
71 p.closeWriteChannel();
72 }
73 p.waitForFinished(30000);
74 if (out)
75 *out = QString::fromUtf8(p.readAllStandardOutput());
76 if (err)
77 *err = QString::fromUtf8(p.readAllStandardError());
78 return p.exitCode();
79}
80
81// Generate a test GPG key in the given GNUPGHOME; returns the key fingerprint.
82static QString generateTestKey(const QString &gnupgHome) {
83 const QString batch = QStringLiteral("%no-protection\n"
84 "Key-Type: RSA\n"
85 "Key-Length: 2048\n"
86 "Subkey-Type: RSA\n"
87 "Subkey-Length: 2048\n"
88 "Name-Real: QtPass Integration Test\n"
89 "Name-Email: qtpass-test@localhost\n"
90 "Expire-Date: 0\n"
91 "%commit\n");
92
93 int rc = runGpg(gnupgHome, {"--batch", "--gen-key"}, batch);
94 if (rc != 0)
95 return {};
96
97 QString out;
98 runGpg(gnupgHome, {"--with-colons", "--fingerprint", "qtpass-test@localhost"},
99 QString(), &out);
100
101 QString fingerprint;
102 for (const auto &line : out.split('\n')) {
103 if (line.startsWith("fpr:")) {
104 const auto parts = line.split(':');
105 if (parts.size() >= 10) {
106 fingerprint = parts[9].trimmed();
107 break;
108 }
109 }
110 }
111 if (fingerprint.isEmpty())
112 return {};
113
114 // Explicitly set ultimate trust — some GPG versions don't auto-assign it
115 // from --gen-key --batch, which causes encryption to refuse the key.
116 if (runGpg(gnupgHome, {"--batch", "--import-ownertrust"},
117 fingerprint + ":6:\n") != 0) {
118 qWarning() << "Failed to import ownertrust for key:" << fingerprint;
119 return {};
120 }
121
122 return fingerprint;
123}
124
125// ---------------------------------------------------------------------------
126// Test class
127// ---------------------------------------------------------------------------
128
129class tst_integration : public QObject {
130 Q_OBJECT
131
132 QString m_gpgExe;
133 QTemporaryDir m_gnupgHome;
134 QString m_keyFingerprint;
135 QString m_originalPassSigningKey;
136
137 // Wait for a signal spy to receive at least one signal (up to timeoutMs).
138 static bool waitForSignal(QSignalSpy &spy, int timeoutMs = 15000) {
139 if (spy.count() > 0)
140 return true;
141 return spy.wait(timeoutMs);
142 }
143
144 // Initialize a Pass object with the test keyring and store.
145 static void setupPass(Pass &pass) {
146 pass.init();
147 pass.updateEnv();
148 }
149
150 static auto gpgInsertErrorMsg(const QSignalSpy &errorSpy) -> QByteArray {
151 if (errorSpy.count() > 0)
152 return QString("GPG Insert error (rc=%1): %2")
153 .arg(errorSpy[0][0].toInt())
154 .arg(errorSpy[0][1].toString())
155 .toUtf8();
156 return "finishedInsert not emitted (GPG may have hung or failed to start)";
157 }
158
159 // Run a git config command and verify it succeeds.
160 static auto runGitConfig(QProcess &proc, const QString &gitExe,
161 const QStringList &args) -> bool {
162 proc.start(gitExe, args);
163 if (!proc.waitForStarted()) {
164 return false;
165 }
166 if (!proc.waitForFinished()) {
167 return false;
168 }
169 if (proc.exitStatus() != QProcess::NormalExit || proc.exitCode() != 0) {
170 return false;
171 }
172 return true;
173 }
174
175private Q_SLOTS:
176 void initTestCase();
177 void cleanupTestCase();
178
179 // ImitatePass backend
180 void imitatePass_insertAndShow();
181 void imitatePass_insertAndGrep();
182 void imitatePass_insertMoveAndShow();
183 void imitatePass_insertCopyAndShow();
184 void imitatePass_insertAndRemove();
185 void imitatePass_nestedDirectoryInsertAndShow();
186 void imitatePass_editExistingEntry();
187 void imitatePass_gitInitAndCommit();
188
189 // RealPass backend (skipped if `pass` not installed)
190 void realPass_insertAndShow();
191 void realPass_insertAndGrep();
192
193 // pass-otp (skipped if extension not installed)
194 void imitatePass_otpGenerate();
195};
196
197// ---------------------------------------------------------------------------
198
199void tst_integration::initTestCase() {
200 m_gpgExe = findGpg();
201 if (m_gpgExe.isEmpty())
202 QSKIP("gpg not found – skipping integration tests");
203
204 QVERIFY2(m_gnupgHome.isValid(), "Failed to create temp GNUPGHOME");
205 // Restrict permissions so gpg2 doesn't complain about unsafe homedir.
206 QFile::setPermissions(m_gnupgHome.path(),
207 QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner);
208
209 // Configure gpg-agent for headless/CI: allow loopback pinentry so GPG
210 // never blocks waiting for a PIN dialog on systems without a pinentry GUI.
211 {
212 QByteArray agentPayload =
213 "allow-loopback-pinentry\ndefault-cache-ttl 300\n";
214#ifdef Q_OS_LINUX
215 agentPayload += "pinentry-program /usr/bin/pinentry-tty\n";
216#endif
217 QFile agentConf(QDir::cleanPath(m_gnupgHome.path() + "/gpg-agent.conf"));
218 QVERIFY2(
219 agentConf.open(QIODevice::WriteOnly | QIODevice::Text),
220 qPrintable("Cannot open gpg-agent.conf: " + agentConf.errorString()));
221 QVERIFY2(
222 agentConf.write(agentPayload) == agentPayload.size(),
223 qPrintable("Cannot write gpg-agent.conf: " + agentConf.errorString()));
224 const QByteArray gpgPayload = "pinentry-mode loopback\nbatch\nno-tty\n";
225 QFile gpgConf(QDir::cleanPath(m_gnupgHome.path() + "/gpg.conf"));
226 QVERIFY2(gpgConf.open(QIODevice::WriteOnly | QIODevice::Text),
227 qPrintable("Cannot open gpg.conf: " + gpgConf.errorString()));
228 QVERIFY2(gpgConf.write(gpgPayload) == gpgPayload.size(),
229 qPrintable("Cannot write gpg.conf: " + gpgConf.errorString()));
230 }
231
232 // Pre-start the agent so GPG subprocesses don't hang waiting for it.
233 {
234 QString gpgconfExe = findGpgconf();
235 if (gpgconfExe.isEmpty()) {
236 QSKIP("gpgconf not found - skipping GPG integration tests");
237 }
238 QProcess agentLaunch;
239 QProcessEnvironment agentEnv = QProcessEnvironment::systemEnvironment();
240 agentEnv.insert("GNUPGHOME", m_gnupgHome.path());
241 agentLaunch.setProcessEnvironment(agentEnv);
242 agentLaunch.start(
243 gpgconfExe, {"--homedir", m_gnupgHome.path(), "--launch", "gpg-agent"});
244 QVERIFY2(agentLaunch.waitForStarted(5000), "gpgconf failed to start");
245 QVERIFY2(agentLaunch.waitForFinished(10000),
246 "gpgconf --launch gpg-agent timed out");
247 QVERIFY2(agentLaunch.exitStatus() == QProcess::NormalExit &&
248 agentLaunch.exitCode() == 0,
249 qPrintable("gpgconf --launch gpg-agent failed (rc=" +
250 QString::number(agentLaunch.exitCode()) +
251 "): " + agentLaunch.readAllStandardError()));
252 }
253
254 m_keyFingerprint = generateTestKey(m_gnupgHome.path());
255 QVERIFY2(!m_keyFingerprint.isEmpty(),
256 "Failed to generate GPG key for integration tests");
257
258 // Redirect GNUPGHOME process-wide so all child processes inherit the test
259 // keyring. Also set it in QtPassSettings so Pass::init() propagates it
260 // to the Executor environment via updateEnv().
261 qputenv("GNUPGHOME", m_gnupgHome.path().toLocal8Bit());
263 m_gnupgHome.path());
265 m_originalPassSigningKey = QtPassSettings::getPassSigningKey();
267 qRegisterMetaType<GrepResults>("GrepResults");
268 qRegisterMetaType<GrepResults>(
269 "QList<QPair<QString,QStringList>>"); // Qt5 fallback
270}
271
272void tst_integration::cleanupTestCase() {
273 // Restore original pass signing key
274 QtPassSettings::setPassSigningKey(m_originalPassSigningKey);
275
276 // Kill any gpg-agent started in our temporary homedir so it doesn't linger.
277 QProcess killer;
278 QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
279 env.insert("GNUPGHOME", m_gnupgHome.path());
280 killer.setProcessEnvironment(env);
281 killer.start("gpgconf", {"--kill", "gpg-agent"});
282 killer.waitForFinished(5000);
283}
284
285// ---------------------------------------------------------------------------
286// ImitatePass tests
287// ---------------------------------------------------------------------------
288
289void tst_integration::imitatePass_insertAndShow() {
290 QTemporaryDir storeDir;
291 QVERIFY(storeDir.isValid());
292
293 QtPassSettings::setPassStore(storeDir.path());
294
295 {
296 QFile gpgId(storeDir.path() + "/.gpg-id");
297 QVERIFY(gpgId.open(QIODevice::WriteOnly | QIODevice::Text));
298 gpgId.write((m_keyFingerprint + "\n").toUtf8());
299 }
300
301 ImitatePass pass;
302 setupPass(pass);
303
304 const QString entryName = QStringLiteral("test/password");
305 const QString entryContent = QStringLiteral("hunter2\nuser: testuser\n");
306
307 // ImitatePass::Insert does not create parent directories; the UI does.
308 QVERIFY(QDir(storeDir.path()).mkpath("test"));
309
310 QSignalSpy insertSpy(&pass, &Pass::finishedInsert);
311 QSignalSpy insertErrorSpy(&pass, &Pass::processErrorExit);
312 pass.Insert(entryName, entryContent, false);
313 QVERIFY2(waitForSignal(insertSpy), gpgInsertErrorMsg(insertErrorSpy));
314
315 // Verify the .gpg file was created.
316 const QString expectedFile = storeDir.path() + "/" + entryName + ".gpg";
317 QVERIFY2(QFile::exists(expectedFile), "encrypted file should exist");
318
319 // Now show it.
320 QSignalSpy showSpy(&pass, &Pass::finishedShow);
321 pass.Show(entryName);
322 QVERIFY2(waitForSignal(showSpy), "finishedShow not emitted");
323
324 const QString decrypted = showSpy[0][0].toString();
325 QVERIFY2(decrypted.contains("hunter2"),
326 "decrypted output should contain password");
327 QVERIFY2(decrypted.contains("testuser"),
328 "decrypted output should contain user");
329}
330
331void tst_integration::imitatePass_insertAndGrep() {
332 QTemporaryDir storeDir;
333 QVERIFY(storeDir.isValid());
334
335 QtPassSettings::setPassStore(storeDir.path());
336
337 {
338 QFile gpgId(storeDir.path() + "/.gpg-id");
339 QVERIFY(gpgId.open(QIODevice::WriteOnly | QIODevice::Text));
340 gpgId.write((m_keyFingerprint + "\n").toUtf8());
341 }
342
343 ImitatePass pass;
344 setupPass(pass);
345
346 QVERIFY(QDir(storeDir.path()).mkpath("work"));
347
348 QSignalSpy insertSpy(&pass, &Pass::finishedInsert);
349 QSignalSpy insertErrorSpy(&pass, &Pass::processErrorExit);
350 pass.Insert(QStringLiteral("work/github"),
351 QStringLiteral("s3cr3t\ntoken: abc123\n"), false);
352 QVERIFY2(waitForSignal(insertSpy), gpgInsertErrorMsg(insertErrorSpy));
353
354 insertSpy.clear();
355 insertErrorSpy.clear();
356 pass.Insert(QStringLiteral("work/gitlab"),
357 QStringLiteral("another\ntoken: xyz789\n"), false);
358 QVERIFY2(waitForSignal(insertSpy), gpgInsertErrorMsg(insertErrorSpy));
359
360 QSignalSpy grepSpy(&pass, &Pass::finishedGrep);
361 pass.Grep(QStringLiteral("token"));
362 QVERIFY2(waitForSignal(grepSpy, 20000), "finishedGrep not emitted");
363
364 const auto results = grepSpy[0][0].value<GrepResults>();
365 QVERIFY2(results.size() == 2, "grep should find both entries");
366}
367
368void tst_integration::imitatePass_insertMoveAndShow() {
369 QTemporaryDir storeDir;
370 QVERIFY(storeDir.isValid());
371
372 QtPassSettings::setPassStore(storeDir.path());
373
374 {
375 QFile gpgId(storeDir.path() + "/.gpg-id");
376 QVERIFY(gpgId.open(QIODevice::WriteOnly | QIODevice::Text));
377 gpgId.write((m_keyFingerprint + "\n").toUtf8());
378 }
379
380 ImitatePass pass;
381 setupPass(pass);
382
383 QSignalSpy insertSpy(&pass, &Pass::finishedInsert);
384 QSignalSpy insertErrorSpy(&pass, &Pass::processErrorExit);
385 pass.Insert(QStringLiteral("original"), QStringLiteral("moveme\n"), false);
386 QVERIFY2(waitForSignal(insertSpy), gpgInsertErrorMsg(insertErrorSpy));
387
388 const QString src = storeDir.path() + "/original.gpg";
389 const QString dst = storeDir.path() + "/moved.gpg";
390 QVERIFY2(QFile::exists(src), "source .gpg must exist before move");
391
392 // Without git, Move is synchronous — no signal emitted.
393 pass.Move(src, dst);
394 QVERIFY2(!QFile::exists(src), "source should be gone after move");
395 QVERIFY2(QFile::exists(dst), "destination should exist after move");
396
397 QSignalSpy showSpy(&pass, &Pass::finishedShow);
398 pass.Show(QStringLiteral("moved"));
399 QVERIFY2(waitForSignal(showSpy), "finishedShow not emitted after move");
400 QVERIFY2(showSpy[0][0].toString().contains("moveme"),
401 "decrypted moved entry should contain original content");
402}
403
404void tst_integration::imitatePass_insertCopyAndShow() {
405 QTemporaryDir storeDir;
406 QVERIFY(storeDir.isValid());
407
408 QtPassSettings::setPassStore(storeDir.path());
409
410 {
411 QFile gpgId(storeDir.path() + "/.gpg-id");
412 QVERIFY(gpgId.open(QIODevice::WriteOnly | QIODevice::Text));
413 gpgId.write((m_keyFingerprint + "\n").toUtf8());
414 }
415
416 ImitatePass pass;
417 setupPass(pass);
418
419 QSignalSpy insertSpy(&pass, &Pass::finishedInsert);
420 QSignalSpy insertErrorSpy(&pass, &Pass::processErrorExit);
421 pass.Insert(QStringLiteral("original"), QStringLiteral("copyme\n"), false);
422 QVERIFY2(waitForSignal(insertSpy), gpgInsertErrorMsg(insertErrorSpy));
423
424 const QString src = storeDir.path() + "/original.gpg";
425 const QString dst = storeDir.path() + "/copy.gpg";
426
427 // Without git, Copy is synchronous — no finishedCopy signal emitted.
428 pass.Copy(src, dst);
429 QVERIFY2(QFile::exists(src), "source should still exist after copy");
430 QVERIFY2(QFile::exists(dst), "destination should exist after copy");
431
432 QSignalSpy showSpy(&pass, &Pass::finishedShow);
433 pass.Show(QStringLiteral("copy"));
434 QVERIFY2(waitForSignal(showSpy), "finishedShow not emitted after copy");
435 QVERIFY2(showSpy[0][0].toString().contains("copyme"),
436 "decrypted copy should contain original content");
437}
438
439void tst_integration::imitatePass_insertAndRemove() {
440 QTemporaryDir storeDir;
441 QVERIFY(storeDir.isValid());
442
443 QtPassSettings::setPassStore(storeDir.path());
444
445 {
446 QFile gpgId(storeDir.path() + "/.gpg-id");
447 QVERIFY(gpgId.open(QIODevice::WriteOnly | QIODevice::Text));
448 gpgId.write((m_keyFingerprint + "\n").toUtf8());
449 }
450
451 ImitatePass pass;
452 setupPass(pass);
453
454 QSignalSpy insertSpy(&pass, &Pass::finishedInsert);
455 QSignalSpy insertErrorSpy(&pass, &Pass::processErrorExit);
456 pass.Insert(QStringLiteral("deleteme"), QStringLiteral("gone\n"), false);
457 QVERIFY2(waitForSignal(insertSpy), gpgInsertErrorMsg(insertErrorSpy));
458
459 const QString gpgFile = storeDir.path() + "/deleteme.gpg";
460 QVERIFY2(QFile::exists(gpgFile), "file must exist before remove");
461
462 // Without git, ImitatePass::Remove removes the file synchronously; no signal.
463 pass.Remove(QStringLiteral("deleteme"), false);
464 QVERIFY2(!QFile::exists(gpgFile), "file should be gone after remove");
465}
466
467void tst_integration::imitatePass_nestedDirectoryInsertAndShow() {
468 QTemporaryDir storeDir;
469 QVERIFY(storeDir.isValid());
470
471 QtPassSettings::setPassStore(storeDir.path());
472
473 {
474 QFile gpgId(storeDir.path() + "/.gpg-id");
475 QVERIFY(gpgId.open(QIODevice::WriteOnly | QIODevice::Text));
476 gpgId.write((m_keyFingerprint + "\n").toUtf8());
477 }
478
479 ImitatePass pass;
480 setupPass(pass);
481
482 QVERIFY(QDir(storeDir.path()).mkpath("level1/level2/level3"));
483
484 QSignalSpy insertSpy(&pass, &Pass::finishedInsert);
485 QSignalSpy insertErrorSpy(&pass, &Pass::processErrorExit);
486 pass.Insert(QStringLiteral("level1/level2/level3/deep"),
487 QStringLiteral("deepvalue\n"), false);
488 QVERIFY2(waitForSignal(insertSpy), gpgInsertErrorMsg(insertErrorSpy));
489
490 const QString gpgFile = storeDir.path() + "/level1/level2/level3/deep.gpg";
491 QVERIFY2(QFile::exists(gpgFile), "nested .gpg file should be created");
492
493 QSignalSpy showSpy(&pass, &Pass::finishedShow);
494 pass.Show(QStringLiteral("level1/level2/level3/deep"));
495 QVERIFY2(waitForSignal(showSpy), "finishedShow not emitted for nested entry");
496 QVERIFY2(showSpy[0][0].toString().contains("deepvalue"),
497 "decrypted nested entry should contain the content");
498}
499
500namespace {
501// RAII guard to ensure QtPassSettings::setUseGit is restored on any exit path
502struct RestoreUseGit {
503 bool orig;
504 RestoreUseGit() : orig(QtPassSettings::isUseGit()) {}
505 ~RestoreUseGit() { QtPassSettings::setUseGit(orig); }
506};
507} // namespace
508
509void tst_integration::imitatePass_editExistingEntry() {
510 RestoreUseGit restoreUseGit;
511 QtPassSettings::setUseGit(false); // Ensure git is off for this test
512
513 QTemporaryDir storeDir;
514 QVERIFY(storeDir.isValid());
515
516 QtPassSettings::setPassStore(storeDir.path());
517
518 {
519 QFile gpgId(QDir::cleanPath(storeDir.path() + "/.gpg-id"));
520 QVERIFY(gpgId.open(QIODevice::WriteOnly | QIODevice::Text));
521 gpgId.write((m_keyFingerprint + "\n").toUtf8());
522 }
523
524 ImitatePass pass;
525 setupPass(pass);
526
527 // Insert initial entry
528 const QString entryName = QStringLiteral("editme");
529 const QString originalContent = QStringLiteral("original\n");
530 QSignalSpy insertSpy(&pass, &Pass::finishedInsert);
531 QSignalSpy insertErrorSpy(&pass, &Pass::processErrorExit);
532 pass.Insert(entryName, originalContent, false);
533 QVERIFY2(waitForSignal(insertSpy), gpgInsertErrorMsg(insertErrorSpy));
534
535 // Verify file exists
536 const QString gpgFile =
537 QDir::cleanPath(storeDir.path() + "/" + entryName + ".gpg");
538 QVERIFY2(QFile::exists(gpgFile), "encrypted file should exist");
539
540 // Edit (overwrite) the entry
541 const QString newContent = QStringLiteral("updated\npassword: newpass\n");
542 QSignalSpy editSpy(&pass, &Pass::finishedInsert);
543 pass.Insert(entryName, newContent, true);
544 QVERIFY2(waitForSignal(editSpy), gpgInsertErrorMsg(insertErrorSpy));
545
546 // Show the edited entry and verify content changed
547 QSignalSpy showSpy(&pass, &Pass::finishedShow);
548 pass.Show(entryName);
549 QVERIFY2(waitForSignal(showSpy), "finishedShow not emitted");
550
551 const QString decrypted = showSpy[0][0].toString();
552 QVERIFY2(decrypted.contains("updated"),
553 "decrypted should contain new content");
554 QVERIFY2(decrypted.contains("newpass"),
555 "decrypted should contain new password");
556 QVERIFY2(!decrypted.contains("original"),
557 "decrypted should NOT contain original content");
558}
559
560void tst_integration::imitatePass_gitInitAndCommit() {
561 const QString gitExe = QStandardPaths::findExecutable("git");
562 if (gitExe.isEmpty())
563 QSKIP("git not installed – skipping Git integration test");
564
565 RestoreUseGit restoreUseGit;
566
567 QTemporaryDir storeDir;
568 QVERIFY(storeDir.isValid());
569
570 QtPassSettings::setPassStore(storeDir.path());
573
574 {
575 QFile gpgId(QDir::cleanPath(storeDir.path() + "/.gpg-id"));
576 QVERIFY(gpgId.open(QIODevice::WriteOnly | QIODevice::Text));
577 gpgId.write((m_keyFingerprint + "\n").toUtf8());
578 }
579
580 QProcess gitInit;
581 gitInit.setWorkingDirectory(storeDir.path());
582 gitInit.start(gitExe, {"init"});
583 QVERIFY2(gitInit.waitForFinished(), "git init should complete");
584 QVERIFY2(gitInit.exitCode() == 0, "git init should succeed");
585
586 // Configure local git identity so ImitatePass commits succeed
587 QProcess gitConfig;
588 gitConfig.setWorkingDirectory(storeDir.path());
589 QVERIFY2(
590 runGitConfig(gitConfig, gitExe, {"config", "user.name", "Test User"}),
591 qPrintable(
592 QString("git config user.name failed: %1")
593 .arg(QString::fromUtf8(gitConfig.readAllStandardError()))));
594
595 QProcess gitConfigEmail;
596 gitConfigEmail.setWorkingDirectory(storeDir.path());
597 QVERIFY2(runGitConfig(gitConfigEmail, gitExe,
598 {"config", "user.email", "test@example.com"}),
599 qPrintable(QString("git config user.email failed: %1")
600 .arg(QString::fromUtf8(
601 gitConfigEmail.readAllStandardError()))));
602
603 QProcess gitConfigSign;
604 gitConfigSign.setWorkingDirectory(storeDir.path());
605 QVERIFY2(runGitConfig(gitConfigSign, gitExe,
606 {"config", "commit.gpgsign", "false"}),
607 qPrintable(QString("git config commit.gpgsign failed: %1")
608 .arg(QString::fromUtf8(
609 gitConfigSign.readAllStandardError()))));
610
611 ImitatePass pass;
612 setupPass(pass);
613
614 const QString entryName = QStringLiteral("gitentry");
615 const QString entryContent = QStringLiteral("secret\nurl: example.com\n");
616
617 QSignalSpy insertSpy(&pass, &Pass::finishedInsert);
618 QSignalSpy insertErrorSpy(&pass, &Pass::processErrorExit);
619 pass.Insert(entryName, entryContent, false);
620 QVERIFY2(waitForSignal(insertSpy), gpgInsertErrorMsg(insertErrorSpy));
621
622 QProcess gitLog;
623 gitLog.setWorkingDirectory(storeDir.path());
624 gitLog.start(gitExe, {"log", "--format=%s", "-1"});
625 QVERIFY2(gitLog.waitForFinished(), "git log should complete");
626 QVERIFY2(gitLog.exitCode() == 0, "git log should succeed");
627
628 const QString commitMsg = QString::fromUtf8(gitLog.readAll()).trimmed();
629 QVERIFY2(commitMsg.contains("gitentry"),
630 qPrintable(QString("commit message should mention gitentry: %1")
631 .arg(commitMsg)));
632}
633
634// ---------------------------------------------------------------------------
635// RealPass tests
636// ---------------------------------------------------------------------------
637
638void tst_integration::realPass_insertAndShow() {
639 const QString passExe = findPass();
640 if (passExe.isEmpty())
641 QSKIP("pass not installed – skipping RealPass integration test");
642
643 QTemporaryDir storeDir;
644 QVERIFY(storeDir.isValid());
645
646 QtPassSettings::setPassStore(storeDir.path());
650
651 // Initialise the store via `pass init`.
652 QProcess initProc;
653 QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
654 env.insert("GNUPGHOME", m_gnupgHome.path());
655 env.insert("PASSWORD_STORE_DIR", storeDir.path());
656 initProc.setProcessEnvironment(env);
657 initProc.start(passExe, {"init", m_keyFingerprint});
658 QVERIFY2(initProc.waitForFinished(15000), "pass init timed out");
659 QVERIFY2(initProc.exitCode() == 0, "pass init should succeed");
660
661 RealPass pass;
662 setupPass(pass);
663
664 QSignalSpy insertSpy(&pass, &Pass::finishedInsert);
665 QSignalSpy insertErrorSpy(&pass, &Pass::processErrorExit);
666 pass.Insert(QStringLiteral("realtest"),
667 QStringLiteral("realpassword\nurl: example.com\n"), false);
668 QVERIFY2(waitForSignal(insertSpy, 20000), gpgInsertErrorMsg(insertErrorSpy));
669
670 QSignalSpy showSpy(&pass, &Pass::finishedShow);
671 pass.Show(QStringLiteral("realtest"));
672 QVERIFY2(waitForSignal(showSpy, 20000), "RealPass finishedShow not emitted");
673 QVERIFY2(showSpy[0][0].toString().contains("realpassword"),
674 "RealPass show should return inserted content");
675
677}
678
679void tst_integration::realPass_insertAndGrep() {
680 const QString passExe = findPass();
681 if (passExe.isEmpty())
682 QSKIP("pass not installed – skipping RealPass grep integration test");
683
684 QTemporaryDir storeDir;
685 QVERIFY(storeDir.isValid());
686
687 QtPassSettings::setPassStore(storeDir.path());
691
692 QProcess initProc;
693 QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
694 env.insert("GNUPGHOME", m_gnupgHome.path());
695 env.insert("PASSWORD_STORE_DIR", storeDir.path());
696 initProc.setProcessEnvironment(env);
697 initProc.start(passExe, {"init", m_keyFingerprint});
698 QVERIFY2(initProc.waitForFinished(15000), "pass init timed out");
699 QVERIFY2(initProc.exitCode() == 0, "pass init should succeed");
700
701 RealPass pass;
702 setupPass(pass);
703
704 QSignalSpy insertSpy(&pass, &Pass::finishedInsert);
705 QSignalSpy insertErrorSpy(&pass, &Pass::processErrorExit);
706 pass.Insert(QStringLiteral("email/gmail"),
707 QStringLiteral("gmailpass\nurl: mail.google.com\n"), false);
708 QVERIFY2(waitForSignal(insertSpy, 20000), gpgInsertErrorMsg(insertErrorSpy));
709
710 insertSpy.clear();
711 insertErrorSpy.clear();
712 pass.Insert(QStringLiteral("email/outlook"),
713 QStringLiteral("outlookpass\nurl: outlook.com\n"), false);
714 QVERIFY2(waitForSignal(insertSpy, 20000), gpgInsertErrorMsg(insertErrorSpy));
715
716 QSignalSpy grepSpy(&pass, &Pass::finishedGrep);
717 pass.Grep(QStringLiteral("url"));
718 QVERIFY2(waitForSignal(grepSpy, 30000), "RealPass finishedGrep not emitted");
719
720 const auto results = grepSpy[0][0].value<GrepResults>();
721 QVERIFY2(results.size() >= 2, "grep should find both entries with 'url'");
722
724}
725
726// ---------------------------------------------------------------------------
727// OTP test
728// ---------------------------------------------------------------------------
729
730void tst_integration::imitatePass_otpGenerate() {
731 const QString passExe = findPass();
732 if (passExe.isEmpty())
733 QSKIP("pass not installed – skipping OTP integration test");
734
735 const bool hasOtp =
736 QFile::exists("/usr/lib/password-store/extensions/otp.bash");
737 if (!hasOtp)
738 QSKIP("pass-otp extension not found – skipping OTP integration test");
739
740 QTemporaryDir storeDir;
741 QVERIFY(storeDir.isValid());
742
743 QtPassSettings::setPassStore(storeDir.path());
747
748 QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
749 env.insert("GNUPGHOME", m_gnupgHome.path());
750 env.insert("PASSWORD_STORE_DIR", storeDir.path());
751
752 QProcess initProc;
753 initProc.setProcessEnvironment(env);
754 initProc.start(passExe, {"init", m_keyFingerprint});
755 QVERIFY2(initProc.waitForFinished(15000), "pass init timed out");
756 QVERIFY2(initProc.exitCode() == 0, "pass init should succeed");
757
758 // A known TOTP secret (RFC 6238 test vector, base32 encoded).
759 const QString totpUri = QStringLiteral(
760 "otpauth://totp/test@example.com?secret=JBSWY3DPEHPK3PXP"
761 "&issuer=IntegrationTest&algorithm=SHA1&digits=6&period=30");
762
763 // Insert OTP entry via `pass insert` (non-interactive: pipe URI via stdin).
764 // `pass otp insert` reads the URI from stdin, so we write it there.
765 QProcess otpInsert;
766 otpInsert.setProcessEnvironment(env);
767 otpInsert.start(passExe, {"insert", "--force", "otp/testaccount"});
768 QVERIFY2(otpInsert.waitForStarted(10000), "pass insert failed to start");
769 otpInsert.write((totpUri + "\n" + totpUri + "\n").toUtf8());
770 otpInsert.closeWriteChannel();
771 QVERIFY2(otpInsert.waitForFinished(20000), "pass insert timed out");
772 if (otpInsert.exitCode() != 0)
773 QSKIP("pass insert for OTP failed – skipping OTP generation test");
774
775 // Use RealPass::OtpGenerate to generate the TOTP token.
776 RealPass pass;
777 setupPass(pass);
778
779 QSignalSpy otpSpy(&pass, &Pass::finishedOtpGenerate);
780 pass.OtpGenerate(QStringLiteral("otp/testaccount"));
781 QVERIFY2(waitForSignal(otpSpy, 20000), "finishedOtpGenerate not emitted");
782
783 const QString token = otpSpy[0][0].toString().trimmed();
784 // TOTP token is a 6-digit number.
785 QVERIFY2(QRegularExpression("^\\d{6}$").match(token).hasMatch(),
786 qPrintable("OTP token should be 6 digits, got: " + token));
787
789}
790
791QTEST_MAIN(tst_integration)
792#include "tst_integration.moc"
void Grep(QString pattern, bool caseInsensitive=false) override
Search all password content by GPG-decrypting each .gpg file.
void Insert(QString file, QString newValue, bool overwrite=false) override
Insert new password.
void Move(const QString src, const QString dest, const bool force=false) override
Move password file.
void Copy(const QString src, const QString dest, const bool force=false) override
Copy password file.
void Remove(QString file, bool isDir=false) override
Remove password.
void Show(QString file) override
Show decrypted password.
Abstract base class for password store operations.
Definition pass.h:35
void init()
Initialize the Pass instance.
Definition pass.cpp:115
void finishedShow(const QString &)
Emitted when show finishes.
void finishedInsert(const QString &, const QString &)
Emitted when insert finishes.
void finishedGrep(const QList< QPair< QString, QStringList > > &results)
Emitted when grep finishes with matching results.
void finishedOtpGenerate(const QString &)
Emitted when OTP generation finishes.
void processErrorExit(int exitCode, const QString &err)
Emitted on process error exit.
void updateEnv()
Update environment for subprocesses.
Definition pass.cpp:727
static void setPassExecutable(const QString &passExecutable)
Save pass executable path.
static auto getInstance() -> QtPassSettings *
Get the singleton instance.
static void setPassStore(const QString &passStore)
Save password store path.
static void setGitExecutable(const QString &gitExecutable)
Save git executable path.
static void setPassSigningKey(const QString &passSigningKey)
Save GPG signing key.
static auto getPassSigningKey(const QString &defaultValue=QVariant().toString()) -> QString
Get GPG signing key for pass.
static void setGpgExecutable(const QString &gpgExecutable)
Save GPG executable path.
static void setUsePass(const bool &usePass)
Save use pass setting.
static void setUseGit(const bool &useGit)
Save Git integration flag.
void OtpGenerate(QString file) override
Generate OTP code.
Definition realpass.cpp:73
void Insert(QString file, QString newValue, bool overwrite=false) override
Insert new password.
Definition realpass.cpp:80
void Show(QString file) override
Show decrypted password.
Definition realpass.cpp:65
void Grep(QString pattern, bool caseInsensitive=false) override
Search password content via 'pass grep'.
Definition realpass.cpp:200
static const QString gpgHome
QList< QPair< QString, QStringList > > GrepResults
Integration tests for ImitatePass and RealPass backends.