diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/CMakeLists.txt | 8 | ||||
| -rw-r--r-- | tests/test_htmlbuilder.cpp | 171 | ||||
| -rw-r--r-- | tests/test_messageview.cpp | 74 | ||||
| -rw-r--r-- | tests/test_notmuchworker.cpp | 51 |
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("<script>"))); +} + +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" |
