QtPass 1.6.0
Multi-platform GUI for pass, the standard unix password manager.
Loading...
Searching...
No Matches
tst_gpgkeystate.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#include <QtTest>
4#include <algorithm>
5
9
10class tst_gpgkeystate : public QObject {
11 Q_OBJECT
12
13private Q_SLOTS:
14 void parseMultiKeyPublic();
15 void parseMultiKeyPublic_data();
16 void parseSecretKeys();
17 void parseSecretKeys_data();
18 void parseSingleKey();
19 void parseSingleKey_data();
20 void parseKeyRollover();
21 void parseKeyRollover_data();
22 void classifyRecordTypes();
23 void classifyRecordEmpty();
24 void handlePubSecEmptyFields();
25 void handlePubSecShortList();
26 void handleFprEdgeCases();
27 void classifyRecordWithConstQString();
28 void parseGpgColonOutputWithGrpRecord();
29 void parseGpgColonOutputUnknownRecordTypes();
30 void parseGpgColonOutputAllPublicRecordTypes();
31};
32
33void tst_gpgkeystate::parseMultiKeyPublic() {
34 QFETCH(QString, input);
35 QFETCH(int, expectedCount);
36
37 const bool includeSecretKeys = false;
38 QList<UserInfo> result = parseGpgColonOutput(input, includeSecretKeys);
39
40 QVERIFY2(result.size() == expectedCount,
41 qPrintable(QString("Expected %1 keys, got %2")
42 .arg(expectedCount)
43 .arg(result.size())));
44
45 for (const UserInfo &user : result) {
46 QVERIFY2(!user.have_secret,
47 "Public keys should not have secret capability");
48 }
49
50 if (expectedCount > 0) {
51 QVERIFY2(!result.at(0).key_id.isEmpty(), "First key should have key_id");
52 QVERIFY2(!result.at(0).name.isEmpty(), "First key should have name");
53 }
54 if (expectedCount > 1) {
55 QVERIFY2(!result.at(1).key_id.isEmpty(), "Second key should have key_id");
56 QVERIFY2(!result.at(1).name.isEmpty(), "Second key should have name");
57 }
58}
59
60void tst_gpgkeystate::parseMultiKeyPublic_data() {
61 QTest::addColumn<QString>("input");
62 QTest::addColumn<int>("expectedCount");
63
64 QString input = R"(tru::1:1775005973:0:3:1:5
65pub:u:4096:1:31850CF72D9CDDE9:1774947438:::u:::escarESCA::::::23::0:
66fpr:::::::::13A47CCE2B3DA3AC340A274A31850CF72D9CDDE9:
67uid:u::::1774947438::CBF23008234AA5F88824CE76140F482FAE34923E::Anne Jan Brouwer <henk@annejan.com>::::::::::0:
68sub:u:4096:1:6DF67C6BAD8383CB:1774947438::::::esa::::::23:
69pub:f:4096:1:693A0AF3FA364E76:1775005968:::f:::escarESCA::::::23::0:
70fpr:::::::::4EF2550F79F4E9E68B09F71D693A0AF3FA364E76:
71uid:f::::1775005968::8AA011711F27F6E08DF71653718C299A13B323A0::Harrie de Bot <harrie@annejan.com>::::::::::0:)";
72
73 QTest::newRow("two-public-keys") << input << 2;
74}
75
76void tst_gpgkeystate::parseSecretKeys() {
77 QFETCH(QString, input);
78 QFETCH(int, expectedCount);
79 QFETCH(bool, expectHaveSecret);
80
81 const bool includeSecretKeys = true;
82 QList<UserInfo> result = parseGpgColonOutput(input, includeSecretKeys);
83
84 QVERIFY2(result.size() == expectedCount,
85 qPrintable(QString("Expected %1 keys, got %2")
86 .arg(expectedCount)
87 .arg(result.size())));
88
89 if (expectedCount > 0) {
90 QVERIFY(!result.isEmpty());
91 for (int i = 0; i < result.size(); ++i) {
92 const UserInfo &user = result.at(i);
93 QVERIFY2(
94 user.have_secret == expectHaveSecret,
95 qPrintable(QString("Key at index %1 has have_secret=%2, expected %3")
96 .arg(i)
97 .arg(user.have_secret)
98 .arg(expectHaveSecret)));
99 }
100 }
101}
102
103void tst_gpgkeystate::parseSecretKeys_data() {
104 QTest::addColumn<QString>("input");
105 QTest::addColumn<int>("expectedCount");
106 QTest::addColumn<bool>("expectHaveSecret");
107
108 QString input =
109 R"(sec:u:4096:1:31850CF72D9CDDE9:1774947438:::u:::escarESCA:::+:::23::0:
110fpr:::::::::13A47CCE2B3DA3AC340A274A31850CF72D9CDDE9:
111uid:u::::1774947438::CBF23008234AA5F88824CE76140F482FAE34923E::Anne Jan Brouwer <henk@annejan.com>::::::::::0:
112ssb:u:4096:1:6DF67C6BAD8383CB:1774947438::::::esa:::+:::23:)";
113
114 QTest::newRow("single-secret-key") << input << 1 << true;
115}
116
117void tst_gpgkeystate::parseSingleKey() {
118 QFETCH(QString, input);
119 QFETCH(int, expectedCount);
120 QFETCH(QString, expectedKeyId);
121
122 const bool includeSecretKeys = false;
123 QList<UserInfo> result = parseGpgColonOutput(input, includeSecretKeys);
124 QVERIFY2(result.size() == expectedCount,
125 qPrintable(QString("Expected %1 keys, got %2")
126 .arg(expectedCount)
127 .arg(result.size())));
128
129 if (!result.isEmpty()) {
130 QVERIFY2(!result.first().key_id.isEmpty(),
131 "Parsed key should have a key_id");
132 if (!expectedKeyId.isEmpty()) {
133 QVERIFY2(result.first().key_id == expectedKeyId,
134 qPrintable(QString("Expected key_id %1, got %2")
135 .arg(expectedKeyId)
136 .arg(result.first().key_id)));
137 }
138 }
139}
140
141void tst_gpgkeystate::parseSingleKey_data() {
142 QTest::addColumn<QString>("input");
143 QTest::addColumn<int>("expectedCount");
144 QTest::addColumn<QString>("expectedKeyId");
145
146 QTest::newRow("pub-only")
147 << "pub:u:4096:1:ABC123:1774947438:::u::::::23::0:" << 1 << "ABC123";
148 QTest::newRow("pub-with-fpr") << "pub:u:4096:1:ABC123:1774947438:::u::::::23:"
149 ":0:\nfpr:::::::::FINGERPRINT123456789:"
150 << 1 << "ABC123";
151}
152
153void tst_gpgkeystate::parseKeyRollover() {
154 QFETCH(QString, input);
155 QFETCH(int, expectedCount);
156
157 const bool includeSecretKeys = false;
158 QList<UserInfo> result = parseGpgColonOutput(input, includeSecretKeys);
159 QVERIFY2(result.size() == expectedCount,
160 qPrintable(QString("Expected %1 keys, got %2")
161 .arg(expectedCount)
162 .arg(result.size())));
163
164 auto containsKeyId = [&](const QString &keyId) {
165 return std::any_of(
166 result.cbegin(), result.cend(),
167 [&](const UserInfo &user) { return user.key_id == keyId; });
168 };
169
170 QVERIFY2(containsKeyId("AAA111"), "Expected AAA111 key to be parsed");
171 QVERIFY2(containsKeyId("BBB222"), "Expected BBB222 key to be parsed");
172 QVERIFY2(containsKeyId("CCC333"), "Expected CCC333 key to be parsed");
173}
174
175void tst_gpgkeystate::parseKeyRollover_data() {
176 QTest::addColumn<QString>("input");
177 QTest::addColumn<int>("expectedCount");
178
179 QString input = R"(pub:u:4096:1:AAA111:1774947438:::u::::
180fpr:::::::::AAA111FINGERPRINT:
181uid:u::::1774947438::NAME1::user1@test.com:::::::0:
182pub:u:4096:1:BBB222:1774947438:::u::::
183fpr:::::::::BBB222FINGERPRINT:
184uid:u::::1774947438::NAME2::user2@test.com:::::::0:
185pub:u:4096:1:CCC333:1774947438:::u::::
186fpr:::::::::CCC333FINGERPRINT:
187uid:u::::1774947438::NAME3::user3@test.com:::::::0:)";
188
189 QTest::newRow("three-keys-rollover") << input << 3;
190}
191
192void tst_gpgkeystate::classifyRecordTypes() {
193 QVERIFY2(classifyRecord("pub") == GpgRecordType::Pub,
194 "Should classify pub record");
195 QVERIFY2(classifyRecord("sec") == GpgRecordType::Sec,
196 "Should classify sec record");
197 QVERIFY2(classifyRecord("uid") == GpgRecordType::Uid,
198 "Should classify uid record");
199 QVERIFY2(classifyRecord("fpr") == GpgRecordType::Fpr,
200 "Should classify fpr record");
201 QVERIFY2(classifyRecord("sub") == GpgRecordType::Sub,
202 "Should classify sub record");
203 QVERIFY2(classifyRecord("ssb") == GpgRecordType::Ssb,
204 "Should classify ssb record");
205 QVERIFY2(classifyRecord("grp") == GpgRecordType::Grp,
206 "Should classify grp record");
207 QVERIFY2(classifyRecord("unknown") == GpgRecordType::Unknown,
208 "Should classify unknown record types as Unknown");
209}
210
211void tst_gpgkeystate::classifyRecordEmpty() {
213 "Should classify empty as Unknown");
214 QVERIFY2(classifyRecord("PUB") == GpgRecordType::Unknown,
215 "Should be case-sensitive");
216 QVERIFY2(classifyRecord("pubx") == GpgRecordType::Unknown,
217 "Should not match partial");
218}
219
220void tst_gpgkeystate::handlePubSecEmptyFields() {
221 UserInfo user;
222 QStringList props;
223 props << "pub"
224 << "" // validity empty
225 << "4096"
226 << "1"
227 << "keyId00001"
228 << "" // created empty
229 << "" // expiry empty
230 << ""
231 << ""
232 << "Test User"; // name
233
234 handlePubSecRecord(props, false, user);
235
236 QVERIFY2(user.key_id == "keyId00001", "Should parse key_id");
237 QVERIFY2(user.name == "Test User", "Should parse name");
238 QVERIFY2(user.validity == '-', "Empty validity should be dash");
239 QVERIFY2(!user.created.isValid(), "Empty created should be invalid");
240 QVERIFY2(!user.expiry.isValid(), "Empty expiry should be invalid");
241 QVERIFY2(!user.have_secret, "Should not have secret");
242}
243
244void tst_gpgkeystate::handlePubSecShortList() {
245 QStringList shortProps;
246 shortProps.append("pub");
247 shortProps.append("");
248 shortProps.append("4096");
249 shortProps.append("1");
250 shortProps.append(""); // 5: created
251
252 auto verifyShortListIgnored = [](const UserInfo &u, const char *ctx) {
253 QVERIFY2(u.key_id.isEmpty(), "Short list should be ignored");
254 QVERIFY2(u.name.isEmpty(), "Short list ignored: name empty");
255 QVERIFY2(u.validity == '-', "Short list ignored: default validity");
256 QVERIFY2(!u.created.isValid(), "Short list ignored: created invalid");
257 QVERIFY2(!u.expiry.isValid(), "Short list ignored: expiry invalid");
258 QVERIFY2(!u.have_secret, ctx);
259 };
260
261 UserInfo publicUser;
262 handlePubSecRecord(shortProps, false, publicUser);
263 verifyShortListIgnored(publicUser,
264 "Short list ignored: no secret for public");
265
266 UserInfo secretUser;
267 handlePubSecRecord(shortProps, true, secretUser);
268 verifyShortListIgnored(secretUser,
269 "Short list ignored: no secret for secret input");
270}
271
272void tst_gpgkeystate::handleFprEdgeCases() {
273 UserInfo user;
274 user.key_id = "id123";
275
276 QStringList emptyProps;
277 for (int i = 0; i < 10; ++i)
278 emptyProps.append("");
279 handleFprRecord(emptyProps, user);
280 QVERIFY2(user.key_id == "id123", "Empty fpr should not change key_id");
281
282 QStringList nonMatchingProps;
283 for (int i = 0; i < 10; ++i)
284 nonMatchingProps.append("");
285 nonMatchingProps[9] = "otherFingerprint";
286 handleFprRecord(nonMatchingProps, user);
287 QVERIFY2(user.key_id == "id123", "Non-matching fpr should not change key_id");
288
289 user.key_id = "id456";
290 QStringList matchingProps;
291 for (int i = 0; i < 10; ++i)
292 matchingProps.append("");
293 matchingProps[9] = "full fingerprint id456";
294 handleFprRecord(matchingProps, user);
295 QVERIFY2(user.key_id == "full fingerprint id456",
296 "fpr ending with key_id should update to full fingerprint");
297}
298
299// Tests targeting the const-ref refactor in gpgkeystate.cpp.
300// The PR changed `const QString record_type = props[0]` to
301// `const QString &record_type = props[0]` inside parseGpgColonOutput.
302// These tests verify that classifyRecord and parseGpgColonOutput correctly
303// handle all record types with the const-ref binding in place.
304
305void tst_gpgkeystate::classifyRecordWithConstQString() {
306 // Verify classifyRecord is callable with a const QString& and returns the
307 // correct GpgRecordType for each recognised tag.
308 const QString pubTag = QStringLiteral("pub");
309 QCOMPARE(classifyRecord(pubTag), GpgRecordType::Pub);
310
311 const QString secTag = QStringLiteral("sec");
312 QCOMPARE(classifyRecord(secTag), GpgRecordType::Sec);
313
314 const QString uidTag = QStringLiteral("uid");
315 QCOMPARE(classifyRecord(uidTag), GpgRecordType::Uid);
316
317 const QString fprTag = QStringLiteral("fpr");
318 QCOMPARE(classifyRecord(fprTag), GpgRecordType::Fpr);
319
320 const QString subTag = QStringLiteral("sub");
321 QCOMPARE(classifyRecord(subTag), GpgRecordType::Sub);
322
323 const QString ssbTag = QStringLiteral("ssb");
324 QCOMPARE(classifyRecord(ssbTag), GpgRecordType::Ssb);
325
326 const QString grpTag = QStringLiteral("grp");
327 QCOMPARE(classifyRecord(grpTag), GpgRecordType::Grp);
328
329 const QString unknownTag = QStringLiteral("tru");
330 QCOMPARE(classifyRecord(unknownTag), GpgRecordType::Unknown);
331}
332
333void tst_gpgkeystate::parseGpgColonOutputWithGrpRecord() {
334 // A 'grp' record appears in real GPG output but is not a key/uid carrier.
335 // After the const-ref change, parseGpgColonOutput should still skip grp
336 // lines without crashing and return only the real key.
337 const QString input =
338 QStringLiteral("pub:u:4096:1:AAABBBCCC:1774947438:::u::::\n"
339 "grp:::::::::GROUPKEYID:\n"
340 "uid:u::::1774947438::HASH::Alice <alice@test.org>::::\n");
341
342 QList<UserInfo> result = parseGpgColonOutput(input, false);
343 QVERIFY2(result.size() == 1,
344 "grp record should be ignored; only one key expected");
345 QVERIFY2(result.first().key_id == QStringLiteral("AAABBBCCC"),
346 "key_id should be parsed correctly alongside grp record");
347}
348
349void tst_gpgkeystate::parseGpgColonOutputUnknownRecordTypes() {
350 // Lines with unrecognised record types ('tru', 'rvk', 'spk', etc.) must
351 // be silently skipped. The const-ref binding of record_type should not
352 // introduce dangling-reference problems for these paths.
353 const QString input =
354 QStringLiteral("tru::1:9999999999:0:3:1:5\n"
355 "pub:f:4096:1:DEADBEEF01:1774947438:::f::::\n"
356 "rvk:::::::::REVOKER_FINGERPRINT:\n"
357 "fpr:::::::::DEADBEEF01FINGERPRINT:\n"
358 "uid:f::::1774947438::H::Bob <bob@test.org>::::\n");
359
360 QList<UserInfo> result = parseGpgColonOutput(input, false);
361 QVERIFY2(result.size() == 1,
362 "tru/rvk records should be ignored; one key expected");
363 QVERIFY2(result.first().key_id == QStringLiteral("DEADBEEF01"),
364 "key_id should be parsed despite surrounding unknown records");
365}
366
367void tst_gpgkeystate::parseGpgColonOutputAllPublicRecordTypes() {
368 // Exercise sub and ssb record types together with pub/uid/fpr to confirm
369 // the const-ref record_type variable classifies them all correctly within
370 // the parse loop.
371 const QString input =
372 QStringLiteral("pub:u:4096:1:MAINKEY001:1774947438:::u::::\n"
373 "fpr:::::::::MAINKEY001FINGERPRINT:\n"
374 "uid:u::::1774947438::HASH::Carol <carol@test.org>::::\n"
375 "sub:u:4096:1:SUBKEY001:1774947438::::::esa:::\n"
376 "ssb:u:4096:1:SUBKEY002:1774947438::::::esa:::\n");
377
378 QList<UserInfo> result = parseGpgColonOutput(input, false);
379 // sub and ssb records extend the current key; they do not create new entries.
380 QVERIFY2(result.size() == 1,
381 "sub/ssb records should not create additional UserInfo entries");
382 QVERIFY2(result.first().key_id == QStringLiteral("MAINKEY001"),
383 "key_id should reflect the pub record");
384 QVERIFY2(!result.first().name.isEmpty(), "UID name should be populated");
385}
386
387QTEST_MAIN(tst_gpgkeystate)
388#include "tst_gpgkeystate.moc"
void handlePubSecRecord(const QStringList &props, bool secret, UserInfo &current_user)
Handle a pub or sec record in GPG colon output.
auto parseGpgColonOutput(const QString &output, bool secret) -> QList< UserInfo >
Parse GPG –with-colons output into a list of UserInfo.
auto classifyRecord(const QString &record_type) -> GpgRecordType
Classify a GPG colon output record type.
void handleFprRecord(const QStringList &props, UserInfo &current_user)
Handle an fpr (fingerprint) record in GPG colon output.
Stores key info lines including validity, creation date and more.
Definition userinfo.h:13
bool have_secret
UserInfo::have_secret whether secret key is available (can decrypt with this key).
Definition userinfo.h:51
QString key_id
UserInfo::key_id hexadecimal representation of the GnuPG key identifier.
Definition userinfo.h:41
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