aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/CMakeLists.txt8
-rw-r--r--tests/test_htmlbuilder.cpp171
-rw-r--r--tests/test_messageview.cpp74
-rw-r--r--tests/test_notmuchworker.cpp51
4 files changed, 303 insertions, 1 deletions
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 0f6ec5a..c09ef78 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -1,6 +1,12 @@
# add_qtmaildir_test(<name>) builds tests/test_<name>.cpp and registers it.
+#
+# resources.qrc is compiled into each test binary for the same reason the app
+# compiles it rather than the static library: a qrc inside a .a registers from
+# a global initialiser the linker then drops. Library code reads :/fonts/ when
+# it builds the placeholder pane, so a test that never links the qrc would
+# exercise only the missing-resource fallback and pass against a broken build.
function(add_qtmaildir_test name)
- add_executable(test_${name} test_${name}.cpp)
+ add_executable(test_${name} test_${name}.cpp ${CMAKE_SOURCE_DIR}/src/resources.qrc)
target_link_libraries(test_${name} PRIVATE qtmaildir_lib Qt6::Test)
add_test(NAME ${name} COMMAND test_${name})
endfunction()
diff --git a/tests/test_htmlbuilder.cpp b/tests/test_htmlbuilder.cpp
index 7fa45a2..fdb57c5 100644
--- a/tests/test_htmlbuilder.cpp
+++ b/tests/test_htmlbuilder.cpp
@@ -48,6 +48,15 @@ private slots:
// Theming.
void aDarkPaletteProducesADarkDocument();
void everyColourComesFromThePalette();
+
+ // The placeholder pane (item 30).
+ void placeholderPicksTheBrandSetFromTheDesktopTheme();
+ void placeholderHelperBecomesALinkOnItsQuery();
+ void placeholderHelperWithoutAQueryIsNotALink();
+ void placeholderEscapesHelperText();
+ void placeholderEmbedsItsFontsRatherThanFetchingThem();
+ void placeholderReferencesNoRemoteResource();
+ void placeholderStyleHasNoUnsubstitutedTokens();
void theBodyAlwaysGetsABackground();
void aSendersOwnHtmlIsNotRecoloured();
};
@@ -357,6 +366,168 @@ void TestHtmlBuilder::everyColourComesFromThePalette()
}
}
+void TestHtmlBuilder::placeholderPicksTheBrandSetFromTheDesktopTheme()
+{
+ // The brand colours are fixed, deliberately: this is the one place where
+ // the desktop palette does NOT supply the values. What the desktop decides
+ // is which of the two sets is used, and getting that backwards is the
+ // failure the item warns about, a light lockup on a dark desktop.
+ const HtmlBuilder::BrandPalette dark = HtmlBuilder::brandPaletteFrom(
+ makeTestPalette(QColor(0x1a, 0x1a, 0x1a), QColor(0xee, 0xee, 0xee)));
+ const HtmlBuilder::BrandPalette light = HtmlBuilder::brandPaletteFrom(
+ makeTestPalette(QColor(0xff, 0xff, 0xff), QColor(0x11, 0x11, 0x11)));
+
+ QCOMPARE(dark.background.name(), QStringLiteral("#060b10"));
+ QCOMPARE(light.background.name(), QStringLiteral("#ffffff"));
+
+ // Not merely different: the right way round. A set whose background is
+ // darker than its text is the dark set, whichever values it holds.
+ QVERIFY(dark.background.lightnessF() < dark.title.lightnessF());
+ QVERIFY(light.background.lightnessF() > light.title.lightnessF());
+}
+
+void TestHtmlBuilder::placeholderHelperBecomesALinkOnItsQuery()
+{
+ // JavaScript is off in this profile, so a helper can only be actionable by
+ // being a real link that the page's navigation handler intercepts.
+ const QString html = HtmlBuilder::buildPlaceholder(
+ { { QStringLiteral("12 unread"), QStringLiteral("tag:unread") } },
+ QStringLiteral("0.10.0"),
+ HtmlBuilder::brandPaletteFrom(QPalette()));
+
+ QVERIFY(html.contains(QStringLiteral("href=\"qtmaildir-query:tag%3Aunread\"")));
+ QVERIFY(html.contains(QStringLiteral("12 unread")));
+}
+
+void TestHtmlBuilder::placeholderHelperWithoutAQueryIsNotALink()
+{
+ // The sync line reports a state rather than naming a search, so clicking it
+ // must not run an empty query and wipe the thread list.
+ const QString html = HtmlBuilder::buildPlaceholder(
+ { { QStringLiteral("3 edits waiting to sync"), QString() } },
+ QStringLiteral("0.10.0"),
+ HtmlBuilder::brandPaletteFrom(QPalette()));
+
+ QVERIFY(html.contains(QStringLiteral("3 edits waiting to sync")));
+ QVERIFY(!html.contains(QStringLiteral("qtmaildir-query:")));
+}
+
+void TestHtmlBuilder::placeholderEscapesHelperText()
+{
+ // A helper label carries a count this code produced, but the query half is
+ // built from configuration and a saved query is user-written. Neither may
+ // reach the document unescaped, and the query is doubly encoded: percent
+ // for the URL, then HTML for the attribute.
+ const QString html = HtmlBuilder::buildPlaceholder(
+ { { QStringLiteral("<script>alert(1)</script>"),
+ QStringLiteral("tag:\"a\"><script>") } },
+ QStringLiteral("0.10.0"),
+ HtmlBuilder::brandPaletteFrom(QPalette()));
+
+ QVERIFY(!html.contains(QStringLiteral("<script>")));
+ QVERIFY(html.contains(QStringLiteral("&lt;script&gt;")));
+}
+
+void TestHtmlBuilder::placeholderEmbedsItsFontsRatherThanFetchingThem()
+{
+ // The mockup @imports Google Fonts, which the interceptor blocks by design.
+ // The fonts ship in the binary and must arrive as data: URIs, or the pane
+ // silently falls back to a system font and stops looking like the brand.
+ const QString html = HtmlBuilder::buildPlaceholder(
+ {}, QStringLiteral("0.10.0"),
+ HtmlBuilder::brandPaletteFrom(QPalette()));
+
+ QVERIFY(!html.contains(QStringLiteral("fonts.googleapis.com")));
+
+ // BOTH faces, each with a real payload. Counting @font-face rules or
+ // checking the document's total size passes with one face missing, since
+ // the other is large enough on its own to carry either check: a mutation
+ // pointing one src at a nonexistent resource survived exactly that test.
+ // A missing resource yields an empty src, so the length is what catches it.
+ static const QRegularExpression src(
+ QStringLiteral("src: url\\('data:font/woff2;base64,([^']*)'\\)"));
+ auto it = src.globalMatch(html);
+ int faces = 0;
+ while (it.hasNext()) {
+ ++faces;
+ QVERIFY(it.next().captured(1).size() > 1000);
+ }
+ QCOMPARE(faces, 2);
+}
+
+void TestHtmlBuilder::placeholderReferencesNoRemoteResource()
+{
+ // The load-bearing security check, asserted as a negative for the same
+ // reason everyColourComesFromThePalette is: one leftover reference is the
+ // entire defect, and it would be invisible because the interceptor blocks
+ // it and the pane just renders slightly wrong.
+ const QString html = HtmlBuilder::buildPlaceholder(
+ { { QStringLiteral("12 unread"), QStringLiteral("tag:unread") } },
+ QStringLiteral("0.10.0"),
+ HtmlBuilder::brandPaletteFrom(QPalette()));
+
+ QVERIFY(!html.contains(QStringLiteral("//fonts")));
+ QVERIFY(!html.contains(QStringLiteral("@import")));
+
+ // The one http: URL is the SVG namespace, which is an identifier and never
+ // fetched. Asserting its exact value rather than excluding the scheme
+ // wholesale: a second http: URL appearing later would be a real resource.
+ static const QRegularExpression http(QStringLiteral("http://[^\"' ]*"));
+ auto plain = http.globalMatch(html);
+ while (plain.hasNext()) {
+ QCOMPARE(plain.next().captured(0),
+ QStringLiteral("http://www.w3.org/2000/svg"));
+ }
+
+ // Every https: URL must be the footer's website link, which is a link the
+ // user clicks and not a resource the document fetches.
+ static const QRegularExpression https(QStringLiteral("https://[^\"' ]*"));
+ auto it = https.globalMatch(html);
+ while (it.hasNext()) {
+ QCOMPARE(it.next().captured(0),
+ QStringLiteral("https://danix.xyz/qtmaildir"));
+ }
+}
+
+void TestHtmlBuilder::placeholderStyleHasNoUnsubstitutedTokens()
+{
+ // The defect this exists for shipped once and was invisible. The template
+ // used QString::arg with "%%" for every CSS percentage, and arg() does NOT
+ // collapse "%%" into "%", so the stylesheet reached the browser carrying
+ // "50%%". Each declaration holding one was dropped as invalid, which
+ // silently disabled the grid mask, the glow and both radial gradients. The
+ // pane still rendered, still looked plausible, and a geometry probe that
+ // happened to measure only percentage-free properties reported it correct.
+ const QString html = HtmlBuilder::buildPlaceholder(
+ { { QStringLiteral("12 unread"), QStringLiteral("tag:unread") } },
+ QStringLiteral("0.10.0"),
+ HtmlBuilder::brandPaletteFrom(QPalette()));
+
+ const qsizetype start = html.indexOf(QStringLiteral("<style>"));
+ const qsizetype end = html.indexOf(QStringLiteral("</style>"));
+ QVERIFY(start >= 0 && end > start);
+ const QString style = html.mid(start, end - start);
+
+ // No doubled percent survives into the document.
+ QVERIFY2(!style.contains(QStringLiteral("%%")),
+ "the stylesheet carries '%%', which CSS rejects: every rule "
+ "containing one is silently dropped");
+
+ // No token went unreplaced. A renamed colour would otherwise leave
+ // '@ACCENT@' sitting in the CSS as a dropped declaration.
+ static const QRegularExpression token(QStringLiteral("@[A-Z_]+@"));
+ const QRegularExpressionMatch leftover = token.match(style);
+ QVERIFY2(!leftover.hasMatch(),
+ qPrintable(QStringLiteral("unsubstituted token '%1' in the "
+ "stylesheet").arg(leftover.captured(0))));
+
+ // The three effects the bug disabled, each asserted by name so that
+ // deleting one is a test failure rather than a silent visual regression.
+ QVERIFY(style.contains(QStringLiteral("mask-image")));
+ QVERIFY(style.contains(QStringLiteral("radial-gradient")));
+ QVERIFY(style.contains(QStringLiteral("aspect-ratio")));
+}
+
void TestHtmlBuilder::theBodyAlwaysGetsABackground()
{
// The original CSS set no background at all, which is why the pane was
diff --git a/tests/test_messageview.cpp b/tests/test_messageview.cpp
index 57d7b42..e94fb86 100644
--- a/tests/test_messageview.cpp
+++ b/tests/test_messageview.cpp
@@ -47,6 +47,8 @@ private slots:
void headerEscapesUntrustedValues();
void headerOmitsAnAbsentCc();
void detailsDialogIsOfferedForEveryThread();
+ void placeholderRendersAndReportsItself();
+ void aMessageBodyCannotRunAQuery();
private:
QWebEngineView *webViewOf(MessageView *view) const
@@ -492,5 +494,77 @@ void TestMessageView::detailsDialogIsOfferedForEveryThread()
QVERIFY(!button || !button->isVisible());
}
+void TestMessageView::placeholderRendersAndReportsItself()
+{
+ MessageView view;
+ QWebEngineView *web = webViewOf(&view);
+ QVERIFY(web);
+
+ QSignalSpy loaded(web, &QWebEngineView::loadFinished);
+ view.showPlaceholder({ { QStringLiteral("7 unread"),
+ QStringLiteral("tag:unread") } });
+
+ QVERIFY2(loaded.wait(15000), "the placeholder document never loaded");
+ QVERIFY2(loaded.last().at(0).toBool(),
+ "loadFinished reported failure: the navigation was rejected, "
+ "which is what a base-URL mismatch looks like");
+
+ // Rendered, not merely loaded. The wordmark is split across elements by
+ // the accent span, so the helper line is what proves the content arrived.
+ QString text;
+ bool done = false;
+ web->page()->toPlainText([&](const QString &result) {
+ text = result;
+ done = true;
+ });
+ QTRY_VERIFY_WITH_TIMEOUT(done, 15000);
+ QVERIFY2(text.contains(QStringLiteral("7 unread")), qPrintable(text));
+
+ QVERIFY(view.showingPlaceholder());
+}
+
+void TestMessageView::aMessageBodyCannotRunAQuery()
+{
+ // The gate behind queryRequested(). A message body is attacker-controlled
+ // HTML and can carry a qtmaildir-query: link; the view only honours one
+ // while the placeholder is what is displayed, so this asserts the state
+ // that decides it rather than synthesising a click, which would need the
+ // page's protected navigation handler.
+ MessageView view;
+ QSignalSpy queries(&view, &MessageView::queryRequested);
+
+ view.showPlaceholder({ { QStringLiteral("7 unread"),
+ QStringLiteral("tag:unread") } });
+ QVERIFY(view.showingPlaceholder());
+
+ ParsedMessage message;
+ message.ok = true;
+ message.from = QStringLiteral("Mallory <mallory@example.org>");
+ message.subject = QStringLiteral("Click me");
+ message.htmlBody = QStringLiteral(
+ "<a href=\"qtmaildir-query:tag%3Adeleted\">a link</a>");
+
+ ThreadRenderItem item;
+ item.message = message;
+ item.cidPrefix = QStringLiteral("m0");
+ item.expanded = true;
+
+ view.showThread({ item });
+
+ // Showing any message closes the gate, so a link in that message's own
+ // body has nothing to reach.
+ QVERIFY2(!view.showingPlaceholder(),
+ "the gate stayed open while a message was displayed: a link in a "
+ "message body could run a query");
+
+ view.clear();
+ QVERIFY(!view.showingPlaceholder());
+
+ view.showError(QStringLiteral("broken"), QStringLiteral("/tmp/x"));
+ QVERIFY(!view.showingPlaceholder());
+
+ QVERIFY(queries.isEmpty());
+}
+
QTEST_MAIN(TestMessageView)
#include "test_messageview.moc"
diff --git a/tests/test_notmuchworker.cpp b/tests/test_notmuchworker.cpp
index 488b630..be62ad3 100644
--- a/tests/test_notmuchworker.cpp
+++ b/tests/test_notmuchworker.cpp
@@ -58,6 +58,9 @@ private slots:
void requestAllTagsReturnsSortedTags();
void requestAllTagsOnUnreadableConfigEmitsError();
+ void requestCountsAnswersOneCountPerQuery();
+ void requestCountsKeepsPositionOnAnInvalidQuery();
+
private:
/// Tags of one message, read back through a fresh worker query.
QStringList tagsOf(const QString &messageId);
@@ -472,5 +475,53 @@ void TestNotmuchWorker::requestAllTagsOnUnreadableConfigEmitsError()
QVERIFY(ready.isEmpty());
}
+void TestNotmuchWorker::requestCountsAnswersOneCountPerQuery()
+{
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy spy(&worker, &NotmuchWorker::countsReady);
+
+ worker.requestCounts({ QStringLiteral("tag:unread"),
+ QStringLiteral("tag:inbox"),
+ QStringLiteral("tag:flagged") }, 9);
+
+ QCOMPARE(spy.count(), 1);
+ QCOMPARE(spy.at(0).at(1).value<quint64>(), quint64(9));
+
+ // Threads, not messages: thread A holds two messages and must count once,
+ // which is the number the pane's "N in inbox" line claims to be showing.
+ const QVector<int> counts = spy.at(0).at(0).value<QVector<int>>();
+ QCOMPARE(counts, QVector<int>({ 1, 3, 0 }));
+}
+
+void TestNotmuchWorker::requestCountsKeepsPositionOnAnInvalidQuery()
+{
+ // The caller pairs answers with its own labels by index, so every query
+ // must produce exactly one entry at its own position. Dropping one would
+ // shift every later count onto the wrong label, and the pane would show a
+ // real number against the wrong name rather than visibly breaking.
+ //
+ // **notmuch's query parser rejects almost nothing.** malformedQuery...
+ // above records the same finding: an unbalanced quote parses and matches
+ // nothing. `((((` behaves the same way and counts 0 rather than failing,
+ // which is why this asserts the positional contract rather than a -1 that
+ // no query string can actually provoke. The -1 branch remains for a
+ // notmuch_query_create allocation failure, which a test cannot reach.
+ NotmuchWorker worker(m_fixture.configPath());
+ QSignalSpy spy(&worker, &NotmuchWorker::countsReady);
+
+ worker.requestCounts({ QStringLiteral("tag:unread"),
+ QStringLiteral("(((("),
+ QStringLiteral("tag:inbox") }, 1);
+
+ QCOMPARE(spy.count(), 1);
+ const QVector<int> counts = spy.at(0).at(0).value<QVector<int>>();
+ QCOMPARE(counts.size(), 3);
+
+ // The queries either side keep their own answers, which is the property
+ // the pane depends on.
+ QCOMPARE(counts.at(0), 1);
+ QCOMPARE(counts.at(2), 3);
+}
+
QTEST_MAIN(TestNotmuchWorker)
#include "test_notmuchworker.moc"