diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test_mainwindow.cpp | 44 | ||||
| -rw-r--r-- | tests/test_messageview.cpp | 110 | ||||
| -rw-r--r-- | tests/test_mimeparser.cpp | 135 | ||||
| -rw-r--r-- | tests/test_threadlistmodel.cpp | 42 |
4 files changed, 331 insertions, 0 deletions
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp index b4d9e4a..ab904f9 100644 --- a/tests/test_mainwindow.cpp +++ b/tests/test_mainwindow.cpp @@ -25,10 +25,13 @@ #include <QStandardPaths> #include <QTemporaryDir> +#include <QTableView> + #include "config.h" #include "keymap.h" #include "mainwindow.h" #include "messageview.h" +#include "threadlistmodel.h" /// MainWindow is mostly wiring, and the parts that need a real database are /// still verified manually. What is checked here is the action registry: the @@ -47,6 +50,7 @@ private slots: void uiStateIsNotWrittenIntoTheUserConfig(); void uiStateSurvivesARestart(); void missingUiStateLeavesTheDefaults(); + void headerStateFromADifferentColumnLayoutIsDiscarded(); }; void TestMainWindow::everyKnownActionIsRegistered() @@ -222,6 +226,46 @@ void TestMainWindow::missingUiStateLeavesTheDefaults() QStandardPaths::setTestModeEnabled(false); } +void TestMainWindow::headerStateFromADifferentColumnLayoutIsDiscarded() +{ + // The upgrade hazard: a 0.3.0 state file holds a three-column header blob, + // and 0.4.0 added the attachment column in front. QHeaderView:: + // restoreState() returns TRUE for a blob with fewer sections than the + // model and applies the old widths shifted one column right, mangling the + // layout with no error to detect it by (verified on Qt 6.11). The stored + // column count is what makes that detectable. + QStandardPaths::setTestModeEnabled(true); + QFile::remove(MainWindow::uiStatePath()); + + { + const Config config; + MainWindow window(config); + window.close(); + } + + // Forge a state file from an older layout: same blob, wrong column count. + { + QSettings state(MainWindow::uiStatePath(), QSettings::IniFormat); + state.setValue(QStringLiteral("threadlist/columns"), + int(ThreadListModel::ColumnCount) - 1); + state.setValue(QStringLiteral("threadlist/header"), + QByteArray("not a header this model could have saved")); + } + + // Constructing must not apply it, and must not crash on the garbage blob. + const Config config; + MainWindow reopened(config); + + auto *view = reopened.findChild<QTableView *>(); + QVERIFY(view); + QCOMPARE(view->columnWidth(ThreadListModel::AttachmentColumn), 28); + QCOMPARE(view->columnWidth(ThreadListModel::DateColumn), 130); + QCOMPARE(view->columnWidth(ThreadListModel::SubjectColumn), 520); + + QFile::remove(MainWindow::uiStatePath()); + QStandardPaths::setTestModeEnabled(false); +} + // Constructing a MainWindow needs a QApplication and a platform plugin. The // test has no display under ctest, so it runs offscreen unless the caller // asked for something else. diff --git a/tests/test_messageview.cpp b/tests/test_messageview.cpp index 4b6bc4c..278270a 100644 --- a/tests/test_messageview.cpp +++ b/tests/test_messageview.cpp @@ -16,6 +16,7 @@ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. */ +#include <QPushButton> #include <QSignalSpy> #include <QWebEngineUrlScheme> #include <QWebEngineView> @@ -38,6 +39,8 @@ private slots: void dataUrlSubResourceStillBlocked(); void zoomIsClampedToARenderableRange(); void zoomSurvivesANewDocument(); + void attachmentBarOffersEveryAttachment(); + void attachmentBarClearsBetweenThreads(); private: QWebEngineView *webViewOf(MessageView *view) const @@ -230,5 +233,112 @@ void TestMessageView::zoomSurvivesANewDocument() QCOMPARE(view.zoomFactor(), 1.5); } +/// The buttons in the attachment bar, by their label. Excludes the +/// "Load remote content" button, which lives in the same pane but is not part +/// of the bar. +static QStringList attachmentButtonLabels(MessageView *view) +{ + QStringList labels; + for (QPushButton *button : view->findChildren<QPushButton *>()) { + if (button->text() != QStringLiteral("Load remote content")) + labels.append(button->text()); + } + return labels; +} + +void TestMessageView::attachmentBarOffersEveryAttachment() +{ + // The bar existed as an empty placeholder for two releases: it was created + // and added to the layout, and nothing ever put anything in it, so + // attachments were parsed and then unreachable. + ParsedMessage first; + first.ok = true; + first.from = QStringLiteral("Sender <sender@example.org>"); + first.subject = QStringLiteral("With files"); + first.plainBody = QStringLiteral("see attached"); + first.attachments.append({ QStringLiteral("notes.txt"), + QStringLiteral("text/plain"), + QByteArray("hello") }); + + ParsedMessage second; + second.ok = true; + second.from = QStringLiteral("Other <other@example.org>"); + second.subject = QStringLiteral("Reply"); + second.plainBody = QStringLiteral("mine too"); + second.attachments.append({ QStringLiteral("../../etc/passwd"), + QStringLiteral("text/plain"), + QByteArray("root:x:0:0") }); + + ThreadRenderItem itemA; + itemA.message = first; + itemA.cidPrefix = QStringLiteral("m0"); + itemA.expanded = true; + + ThreadRenderItem itemB; + itemB.message = second; + itemB.cidPrefix = QStringLiteral("m1"); + itemB.expanded = true; + + MessageView view; + view.showThread({ itemA, itemB }); + + // ONE button whatever the count, carrying the total. A button per + // attachment made the bar as wide as the window on a thread with fifteen + // of them and pushed the splitter over, leaving the thread list unusable. + const QStringList labels = attachmentButtonLabels(&view); + QCOMPARE(labels.size(), 1); + QVERIFY2(labels.first().contains(QStringLiteral("2")), + qPrintable(QStringLiteral("expected the count in '%1'") + .arg(labels.first()))); + + // A filename never reaches the bar, so a long one cannot widen it. + QVERIFY(!labels.first().contains(QStringLiteral("notes.txt"))); + QVERIFY(!labels.first().contains(QStringLiteral("passwd"))); +} + +void TestMessageView::attachmentBarClearsBetweenThreads() +{ + ParsedMessage withFile; + withFile.ok = true; + withFile.from = QStringLiteral("Sender <sender@example.org>"); + withFile.subject = QStringLiteral("With a file"); + withFile.plainBody = QStringLiteral("attached"); + withFile.attachments.append({ QStringLiteral("report.pdf"), + QStringLiteral("application/pdf"), + QByteArray("%PDF-1.4") }); + + ThreadRenderItem carrying; + carrying.message = withFile; + carrying.cidPrefix = QStringLiteral("m0"); + carrying.expanded = true; + + MessageView view; + view.showThread({ carrying }); + QCOMPARE(attachmentButtonLabels(&view).size(), 1); + + // Moving to a thread without attachments must not leave the previous + // thread's buttons behind, still offering to save a file from a message + // that is no longer on screen. + ParsedMessage plain; + plain.ok = true; + plain.from = QStringLiteral("Sender <sender@example.org>"); + plain.subject = QStringLiteral("Nothing attached"); + plain.plainBody = QStringLiteral("just text"); + + ThreadRenderItem bare; + bare.message = plain; + bare.cidPrefix = QStringLiteral("m0"); + bare.expanded = true; + + view.showThread({ bare }); + QVERIFY(attachmentButtonLabels(&view).isEmpty()); + + view.showThread({ carrying }); + QCOMPARE(attachmentButtonLabels(&view).size(), 1); + + view.clear(); + QVERIFY(attachmentButtonLabels(&view).isEmpty()); +} + QTEST_MAIN(TestMessageView) #include "test_messageview.moc" diff --git a/tests/test_mimeparser.cpp b/tests/test_mimeparser.cpp index 74b095c..f1bbc8a 100644 --- a/tests/test_mimeparser.cpp +++ b/tests/test_mimeparser.cpp @@ -39,6 +39,9 @@ private slots: void savedAttachmentMatchesBytes(); void safeFilenameStripsPathComponents(); void pathInsideDirectoryRejectsSiblingPrefix(); + void attachmentFolderNameIsASinglePlainComponent(); + void folderNameSurvivesATimezoneComment(); + void savingABatchNeverOverwrites(); private: QString fixture(const QString &name) const @@ -243,5 +246,137 @@ void TestMimeParser::pathInsideDirectoryRejectsSiblingPrefix() QVERIFY(!Attachment::isPathInsideDirectory(base, QStringLiteral("/etc/passwd"))); } +void TestMimeParser::attachmentFolderNameIsASinglePlainComponent() +{ + const QString validDate = QStringLiteral("Thu, 7 May 2026 16:51:48 +0200"); + + // The ordinary case: date prefix so the folders sort chronologically. + QCOMPARE(attachmentFolderName(validDate, QStringLiteral("Quarterly report")), + QStringLiteral("2026-05-07 Quarterly report")); + + // A subject is attacker-controlled and is about to become a directory + // name. None of these may produce anything but one plain component. + const QStringList hostile = { + QStringLiteral("../../etc"), + QStringLiteral("/etc/passwd"), + QStringLiteral("a/b/c"), + QStringLiteral(".."), + QStringLiteral("."), + QStringLiteral(".hidden"), + QStringLiteral("with\\backslash"), + QStringLiteral("null\0byte"), + }; + for (const QString &subject : hostile) { + const QString folder = attachmentFolderName(validDate, subject); + QVERIFY2(!folder.contains(QLatin1Char('/')), + qPrintable(QStringLiteral("'%1' -> '%2'").arg(subject, folder))); + QVERIFY2(!folder.contains(QLatin1Char('\\')), + qPrintable(QStringLiteral("'%1' -> '%2'").arg(subject, folder))); + QVERIFY2(!folder.startsWith(QLatin1Char('.')), + qPrintable(QStringLiteral("'%1' -> '%2'").arg(subject, folder))); + QVERIFY2(folder != QLatin1String("..") && folder != QLatin1String("."), + qPrintable(QStringLiteral("'%1' -> '%2'").arg(subject, folder))); + QVERIFY(!folder.isEmpty()); + + // The decisive check: joining it onto a directory cannot escape. + QVERIFY2(Attachment::isPathInsideDirectory( + QStringLiteral("/tmp/parent"), + QDir(QStringLiteral("/tmp/parent")).absoluteFilePath(folder)), + qPrintable(QStringLiteral("'%1' escaped as '%2'") + .arg(subject, folder))); + } + + // An unparseable Date: is dropped rather than guessed at. + QCOMPARE(attachmentFolderName(QStringLiteral("not a date"), + QStringLiteral("Subject here")), + QStringLiteral("Subject here")); + + // Neither a usable date nor a usable subject still yields a name, since + // the caller is about to create a directory with it. + const QString generated = attachmentFolderName(QString(), QStringLiteral("///")); + QVERIFY(!generated.isEmpty()); + QVERIFY(!generated.contains(QLatin1Char('/'))); + + // A subject can be far longer than a filesystem component allows. + const QString huge = attachmentFolderName(validDate, QString(500, QLatin1Char('x'))); + QVERIFY2(huge.size() <= 120, + qPrintable(QStringLiteral("length %1").arg(huge.size()))); +} + +void TestMimeParser::folderNameSurvivesATimezoneComment() +{ + // "+0200 (CEST)" is legal per RFC 5322 and common in real mail, but + // Qt::RFC2822Date rejects the entire string when the comment is present + // (verified on Qt 6.11). Every such message silently lost its date prefix. + QCOMPARE(attachmentFolderName( + QStringLiteral("Thu, 7 May 2026 16:51:48 +0200 (CEST)"), + QStringLiteral("Report")), + QStringLiteral("2026-05-07 Report")); + + // The same date without the comment must not regress. + QCOMPARE(attachmentFolderName( + QStringLiteral("Thu, 7 May 2026 16:51:48 +0200"), + QStringLiteral("Report")), + QStringLiteral("2026-05-07 Report")); +} + +void TestMimeParser::savingABatchNeverOverwrites() +{ + // Saving a thread's attachments with saveTo() destroyed files: several + // messages in one thread commonly attach the same filename, each write + // landed on the previous one, and all of them reported success. Sixteen + // attachments produced ten files. + QTemporaryDir dir; + + Attachment first; + first.filename = QStringLiteral("questionario.pdf"); + first.data = QByteArray("first copy"); + + Attachment second; + second.filename = QStringLiteral("questionario.pdf"); + second.data = QByteArray("second copy, different bytes"); + + Attachment third; + third.filename = QStringLiteral("questionario.pdf"); + third.data = QByteArray("third"); + + QString error; + const QString pathA = first.saveWithoutOverwriting(dir.path(), &error); + const QString pathB = second.saveWithoutOverwriting(dir.path(), &error); + const QString pathC = third.saveWithoutOverwriting(dir.path(), &error); + + QVERIFY(!pathA.isEmpty()); + QVERIFY(!pathB.isEmpty()); + QVERIFY(!pathC.isEmpty()); + + // Three distinct files, and every one still holds its own bytes. + QCOMPARE(QDir(dir.path()).entryList(QDir::Files).size(), 3); + QVERIFY(pathA != pathB); + QVERIFY(pathB != pathC); + + const auto contentsOf = [](const QString &path) { + QFile file(path); + file.open(QIODevice::ReadOnly); + return file.readAll(); + }; + QCOMPARE(contentsOf(pathA), QByteArray("first copy")); + QCOMPARE(contentsOf(pathB), QByteArray("second copy, different bytes")); + QCOMPARE(contentsOf(pathC), QByteArray("third")); + + // The extension is kept whole rather than split at the first dot. + Attachment tarball; + tarball.filename = QStringLiteral("archive.tar.gz"); + tarball.data = QByteArray("one"); + Attachment tarballAgain = tarball; + tarballAgain.data = QByteArray("two"); + + QVERIFY(!tarball.saveWithoutOverwriting(dir.path(), &error).isEmpty()); + const QString second_tar = + tarballAgain.saveWithoutOverwriting(dir.path(), &error); + QVERIFY(second_tar.endsWith(QStringLiteral(".gz"))); + QVERIFY2(second_tar.contains(QStringLiteral("archive.tar")), + qPrintable(second_tar)); +} + QTEST_MAIN(TestMimeParser) #include "test_mimeparser.moc" diff --git a/tests/test_threadlistmodel.cpp b/tests/test_threadlistmodel.cpp index e8a5fa8..97f7fde 100644 --- a/tests/test_threadlistmodel.cpp +++ b/tests/test_threadlistmodel.cpp @@ -38,6 +38,7 @@ private slots: void unreadStylingSurvivesAnAccountChip(); void accountChipUsesTheConfiguredColour(); void deletedThreadsAreRedAndStruckThrough(); + void attachmentColumnIsFirstAndMarksOnlyTaggedThreads(); void spamThreadsAreOrangeAndStruckThrough(); void doomedStylingCoversEveryColumn(); void ordinaryThreadsCarryNoRowColour(); @@ -472,5 +473,46 @@ void TestThreadListModel::modelPassesQtTester() model.clear(); } +void TestThreadListModel::attachmentColumnIsFirstAndMarksOnlyTaggedThreads() +{ + // Leftmost, and narrow: the point is to see an attachment without opening + // the thread, which only works if the column is never scrolled away. + QCOMPARE(ThreadListModel::AttachmentColumn, 0); + + ThreadSummary plain = makeThread(QStringLiteral("t1"), + QStringLiteral("no attachment")); + ThreadSummary withFile = makeThread(QStringLiteral("t2"), + QStringLiteral("has one")); + // notmuch applies this tag itself while indexing, so no MIME parsing and + // no extra worker query are involved. + withFile.tags.append(QStringLiteral("attachment")); + + ThreadListModel model; + model.appendBatch({ plain, withFile }); + + const QModelIndex plainCell = + model.index(0, ThreadListModel::AttachmentColumn); + const QModelIndex fileCell = + model.index(1, ThreadListModel::AttachmentColumn); + + QVERIFY(model.data(plainCell, Qt::DisplayRole).toString().isEmpty()); + QCOMPARE(model.data(fileCell, Qt::DisplayRole).toString(), + ThreadListModel::attachmentGlyph()); + + // The glyph must be something a font can draw. An unrenderable codepoint + // shows as a tofu box, which reads as breakage rather than as a marker. + QVERIFY(!ThreadListModel::attachmentGlyph().isEmpty()); + + // Only the marked thread gets a tooltip, or an empty cell would claim to + // have an attachment on hover. + QVERIFY(model.data(plainCell, Qt::ToolTipRole).toString().isEmpty()); + QVERIFY(!model.data(fileCell, Qt::ToolTipRole).toString().isEmpty()); + + // The header carries no text: a label would set a minimum width far wider + // than the icon and defeat the narrow column. + QVERIFY(model.headerData(ThreadListModel::AttachmentColumn, Qt::Horizontal, + Qt::DisplayRole).toString().isEmpty()); +} + QTEST_MAIN(TestThreadListModel) #include "test_threadlistmodel.moc" |
