diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/CMakeLists.txt | 3 | ||||
| -rw-r--r-- | tests/fixtures/alternative.eml | 16 | ||||
| -rw-r--r-- | tests/fixtures/attachment.eml | 18 | ||||
| -rw-r--r-- | tests/fixtures/encoded_subject.eml | 7 | ||||
| -rw-r--r-- | tests/fixtures/hostile_filename.eml | 17 | ||||
| -rw-r--r-- | tests/fixtures/inline_image.eml | 19 | ||||
| -rw-r--r-- | tests/fixtures/plain.eml | 12 | ||||
| -rw-r--r-- | tests/fixtures/truncated.eml | 11 | ||||
| -rw-r--r-- | tests/test_mimeparser.cpp | 171 |
9 files changed, 274 insertions, 0 deletions
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index bec7621..13cb5da 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -7,3 +7,6 @@ endfunction() add_qtmaildir_test(keymap) add_qtmaildir_test(config) +add_qtmaildir_test(mimeparser) +target_compile_definitions(test_mimeparser PRIVATE + FIXTURE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/fixtures") diff --git a/tests/fixtures/alternative.eml b/tests/fixtures/alternative.eml new file mode 100644 index 0000000..1c35d79 --- /dev/null +++ b/tests/fixtures/alternative.eml @@ -0,0 +1,16 @@ +From: Alice <alice@example.org> +Subject: Both parts +Date: Sat, 01 Aug 2026 10:00:00 +0000 +Message-ID: <alt-1@example.org> +MIME-Version: 1.0 +Content-Type: multipart/alternative; boundary="BOUND" + +--BOUND +Content-Type: text/plain; charset=utf-8 + +plain version +--BOUND +Content-Type: text/html; charset=utf-8 + +<html><body><p>html version</p></body></html> +--BOUND-- diff --git a/tests/fixtures/attachment.eml b/tests/fixtures/attachment.eml new file mode 100644 index 0000000..a0410e4 --- /dev/null +++ b/tests/fixtures/attachment.eml @@ -0,0 +1,18 @@ +From: Alice <alice@example.org> +Subject: With attachment +Date: Sat, 01 Aug 2026 10:00:00 +0000 +Message-ID: <att-1@example.org> +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="MIX" + +--MIX +Content-Type: text/plain; charset=utf-8 + +see attached +--MIX +Content-Type: text/plain; charset=utf-8; name="notes.txt" +Content-Disposition: attachment; filename="notes.txt" +Content-Transfer-Encoding: quoted-printable + +caf=C3=A9 notes +--MIX-- diff --git a/tests/fixtures/encoded_subject.eml b/tests/fixtures/encoded_subject.eml new file mode 100644 index 0000000..7dcb6f8 --- /dev/null +++ b/tests/fixtures/encoded_subject.eml @@ -0,0 +1,7 @@ +From: =?utf-8?B?w4RsaWNl?= <alice@example.org> +Subject: =?utf-8?Q?Caf=C3=A9_meeting?= +Date: Sat, 01 Aug 2026 10:00:00 +0000 +Message-ID: <enc-1@example.org> +Content-Type: text/plain; charset=utf-8 + +body diff --git a/tests/fixtures/hostile_filename.eml b/tests/fixtures/hostile_filename.eml new file mode 100644 index 0000000..4dc79b6 --- /dev/null +++ b/tests/fixtures/hostile_filename.eml @@ -0,0 +1,17 @@ +From: Attacker <bad@example.org> +Subject: Hostile attachment name +Date: Sat, 01 Aug 2026 10:00:00 +0000 +Message-ID: <evil-1@example.org> +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="EVIL" + +--EVIL +Content-Type: text/plain; charset=utf-8 + +body +--EVIL +Content-Type: text/plain; name="../../../../tmp/pwned.txt" +Content-Disposition: attachment; filename="../../../../tmp/pwned.txt" + +owned +--EVIL-- diff --git a/tests/fixtures/inline_image.eml b/tests/fixtures/inline_image.eml new file mode 100644 index 0000000..a9dd24b --- /dev/null +++ b/tests/fixtures/inline_image.eml @@ -0,0 +1,19 @@ +From: Alice <alice@example.org> +Subject: Inline image +Date: Sat, 01 Aug 2026 10:00:00 +0000 +Message-ID: <cid-1@example.org> +MIME-Version: 1.0 +Content-Type: multipart/related; boundary="REL" + +--REL +Content-Type: text/html; charset=utf-8 + +<html><body><img src="cid:logo@example.org"></body></html> +--REL +Content-Type: image/png +Content-Transfer-Encoding: base64 +Content-ID: <logo@example.org> + +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9 +awAAAABJRU5ErkJggg== +--REL-- diff --git a/tests/fixtures/plain.eml b/tests/fixtures/plain.eml new file mode 100644 index 0000000..81f06b4 --- /dev/null +++ b/tests/fixtures/plain.eml @@ -0,0 +1,12 @@ +From: Alice <alice@example.org> +To: Bob <bob@example.net> +Subject: Plain hello +Date: Sat, 01 Aug 2026 10:00:00 +0000 +Message-ID: <plain-1@example.org> +Content-Type: text/plain; charset=utf-8 + +Hello Bob. + +> quoted line +Regards, +Alice diff --git a/tests/fixtures/truncated.eml b/tests/fixtures/truncated.eml new file mode 100644 index 0000000..912a522 --- /dev/null +++ b/tests/fixtures/truncated.eml @@ -0,0 +1,11 @@ +From: Alice <alice@example.org> +Subject: Truncated +Date: Sat, 01 Aug 2026 10:00:00 +0000 +Message-ID: <trunc-1@example.org> +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="CUT" + +--CUT +Content-Type: text/plain; charset=utf-8 + +this part never closes diff --git a/tests/test_mimeparser.cpp b/tests/test_mimeparser.cpp new file mode 100644 index 0000000..72bdff7 --- /dev/null +++ b/tests/test_mimeparser.cpp @@ -0,0 +1,171 @@ +#include <QtTest> +#include <QTemporaryDir> +#include <QDir> +#include "mimeparser.h" + +class TestMimeParser : public QObject +{ + Q_OBJECT +private slots: + void initTestCase(); + + void parsesPlainText(); + void prefersHtmlWhenAvailable(); + void fallsBackToPlainWhenHtmlDisabled(); + void collectsInlineCidParts(); + void decodesQuotedPrintableAttachment(); + void decodesEncodedHeaders(); + void malformedMessageDoesNotCrash(); + void missingFileIsReported(); + void hostileFilenameIsSanitised(); + void savedAttachmentMatchesBytes(); + +private: + QString fixture(const QString &name) const + { return m_fixtureDir + QLatin1Char('/') + name; } + + QString m_fixtureDir; +}; + +void TestMimeParser::initTestCase() +{ + // FIXTURE_DIR is defined by CMake so the test can run from any cwd. + m_fixtureDir = QStringLiteral(FIXTURE_DIR); + QVERIFY2(QDir(m_fixtureDir).exists(), "fixture directory missing"); +} + +void TestMimeParser::parsesPlainText() +{ + MimeParser parser; + const ParsedMessage msg = parser.parse(fixture(QStringLiteral("plain.eml"))); + + QVERIFY(msg.ok); + QCOMPARE(msg.subject, QStringLiteral("Plain hello")); + QCOMPARE(msg.from, QStringLiteral("Alice <alice@example.org>")); + QVERIFY(msg.plainBody.contains(QStringLiteral("Hello Bob."))); + QVERIFY(msg.htmlBody.isEmpty()); + QVERIFY(msg.attachments.isEmpty()); +} + +void TestMimeParser::prefersHtmlWhenAvailable() +{ + MimeParser parser; + const ParsedMessage msg = + parser.parse(fixture(QStringLiteral("alternative.eml"))); + + QVERIFY(msg.ok); + QVERIFY(msg.htmlBody.contains(QStringLiteral("html version"))); + // The plain alternative is kept so the user can toggle to it. + QVERIFY(msg.plainBody.contains(QStringLiteral("plain version"))); + QVERIFY(msg.hasHtml()); +} + +void TestMimeParser::fallsBackToPlainWhenHtmlDisabled() +{ + MimeParser parser; + const ParsedMessage msg = parser.parse(fixture(QStringLiteral("plain.eml"))); + + QVERIFY(!msg.hasHtml()); + QVERIFY(!msg.plainBody.isEmpty()); +} + +void TestMimeParser::collectsInlineCidParts() +{ + MimeParser parser; + const ParsedMessage msg = + parser.parse(fixture(QStringLiteral("inline_image.eml"))); + + QVERIFY(msg.ok); + QCOMPARE(msg.inlineParts.size(), 1); + // Content-ID angle brackets are stripped so it matches the cid: URL body. + QVERIFY(msg.inlineParts.contains(QStringLiteral("logo@example.org"))); + + const InlinePart part = msg.inlineParts.value(QStringLiteral("logo@example.org")); + QCOMPARE(part.mimeType, QStringLiteral("image/png")); + // Decoded 1x1 PNG starts with the PNG magic bytes. + QVERIFY(part.data.startsWith(QByteArray("\x89PNG", 4))); +} + +void TestMimeParser::decodesQuotedPrintableAttachment() +{ + MimeParser parser; + const ParsedMessage msg = + parser.parse(fixture(QStringLiteral("attachment.eml"))); + + QVERIFY(msg.ok); + QCOMPARE(msg.attachments.size(), 1); + QCOMPARE(msg.attachments.first().filename, QStringLiteral("notes.txt")); + QCOMPARE(QString::fromUtf8(msg.attachments.first().data), + QStringLiteral("café notes")); +} + +void TestMimeParser::decodesEncodedHeaders() +{ + MimeParser parser; + const ParsedMessage msg = + parser.parse(fixture(QStringLiteral("encoded_subject.eml"))); + + QVERIFY(msg.ok); + QCOMPARE(msg.subject, QStringLiteral("Café meeting")); + QVERIFY(msg.from.contains(QStringLiteral("Älice"))); +} + +void TestMimeParser::malformedMessageDoesNotCrash() +{ + MimeParser parser; + const ParsedMessage msg = + parser.parse(fixture(QStringLiteral("truncated.eml"))); + + // GMime is tolerant: it recovers the headers and whatever body it found. + // The requirement is only that parsing terminates and reports something. + QCOMPARE(msg.subject, QStringLiteral("Truncated")); +} + +void TestMimeParser::missingFileIsReported() +{ + MimeParser parser; + const ParsedMessage msg = + parser.parse(fixture(QStringLiteral("does_not_exist.eml"))); + + QVERIFY(!msg.ok); + QVERIFY(!msg.error.isEmpty()); +} + +void TestMimeParser::hostileFilenameIsSanitised() +{ + MimeParser parser; + const ParsedMessage msg = + parser.parse(fixture(QStringLiteral("hostile_filename.eml"))); + + QVERIFY(msg.ok); + QCOMPARE(msg.attachments.size(), 1); + + // The raw header value is preserved for display... + QVERIFY(msg.attachments.first().filename.contains(QStringLiteral(".."))); + // ...but the name used on disk is reduced to a basename. + QCOMPARE(msg.attachments.first().safeFilename(), QStringLiteral("pwned.txt")); +} + +void TestMimeParser::savedAttachmentMatchesBytes() +{ + MimeParser parser; + const ParsedMessage msg = + parser.parse(fixture(QStringLiteral("attachment.eml"))); + QVERIFY(msg.ok); + + QTemporaryDir dir; + QString error; + const QString written = + msg.attachments.first().saveTo(dir.path(), &error); + + QVERIFY2(!written.isEmpty(), qPrintable(error)); + // Never escapes the target directory. + QVERIFY(written.startsWith(dir.path())); + + QFile f(written); + QVERIFY(f.open(QIODevice::ReadOnly)); + QCOMPARE(f.readAll(), msg.attachments.first().data); +} + +QTEST_MAIN(TestMimeParser) +#include "test_mimeparser.moc" |
