summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/test_mainwindow.cpp44
-rw-r--r--tests/test_messageview.cpp110
-rw-r--r--tests/test_mimeparser.cpp135
-rw-r--r--tests/test_threadlistmodel.cpp42
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"