summaryrefslogtreecommitdiff
path: root/app/src/test/java
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/test/java')
-rw-r--r--app/src/test/java/org/terst/feedreader/data/CachedExtractionTest.kt67
-rw-r--r--app/src/test/java/org/terst/feedreader/readability/ConsentNoticeTest.kt140
-rw-r--r--app/src/test/java/org/terst/feedreader/readability/EvalOutcomeTest.kt89
3 files changed, 296 insertions, 0 deletions
diff --git a/app/src/test/java/org/terst/feedreader/data/CachedExtractionTest.kt b/app/src/test/java/org/terst/feedreader/data/CachedExtractionTest.kt
new file mode 100644
index 0000000..72dc73f
--- /dev/null
+++ b/app/src/test/java/org/terst/feedreader/data/CachedExtractionTest.kt
@@ -0,0 +1,67 @@
+package org.terst.feedreader.data
+
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * The reported bug came with "extractedContent cached: true", which is the half
+ * that a fixed extractor cannot reach: the cookie disclaimer was already written
+ * to `articles.extractedContent`, and every open after that read it straight back
+ * out without ever looking at it again.
+ *
+ * So a poisoned cache has to be recognised and dropped on read, not just kept out
+ * on write.
+ */
+class CachedExtractionTest {
+
+ private val cookieNotice =
+ "<div id=\"readability-page-1\" class=\"page\"><div id=\"didomi-host\"><p>This website uses " +
+ "cookies and other tracking technologies (“Cookies”) to enhance user experience, to " +
+ "display targeted ads, and to analyze performance and user activity on our website. For " +
+ "more information about our use of Cookies and our privacy practices, see our Privacy " +
+ "Policy. By continuing to use this website, you agree to our Terms and Conditions.</p>" +
+ "</div></div>"
+
+ @Test
+ fun `real cached content is used as-is and the cache is left alone`() = runTest {
+ var cleared = false
+ val content = "<p>" + "The actual article body. ".repeat(60) + "</p>"
+ val result = loadCachedExtraction(content) { cleared = true }
+ assertEquals(content, result.content)
+ assertFalse(result.discardedConsentNotice)
+ assertFalse("a good cache must not be cleared", cleared)
+ }
+
+ @Test
+ fun `a cached consent notice is discarded and deleted`() = runTest {
+ var cleared = false
+ val result = loadCachedExtraction(cookieNotice) { cleared = true }
+ assertNull("the notice must not be shown as the article", result.content)
+ assertTrue(result.discardedConsentNotice)
+ assertTrue("the poisoned row must be cleared so a re-extract can replace it", cleared)
+ }
+
+ @Test
+ fun `nothing cached touches nothing`() = runTest {
+ var cleared = false
+ val result = loadCachedExtraction(null) { cleared = true }
+ assertNull(result.content)
+ assertFalse(result.discardedConsentNotice)
+ assertFalse(cleared)
+ }
+
+ // A blank value is as good as nothing cached -- it is not a consent notice, so
+ // reporting it as a discarded one would make the caller re-extract on a false
+ // premise.
+ @Test
+ fun `a blank cached value is not reported as a discarded notice`() = runTest {
+ var cleared = false
+ val result = loadCachedExtraction(" ") { cleared = true }
+ assertFalse(result.discardedConsentNotice)
+ assertFalse(cleared)
+ }
+}
diff --git a/app/src/test/java/org/terst/feedreader/readability/ConsentNoticeTest.kt b/app/src/test/java/org/terst/feedreader/readability/ConsentNoticeTest.kt
new file mode 100644
index 0000000..7f4e9cc
--- /dev/null
+++ b/app/src/test/java/org/terst/feedreader/readability/ConsentNoticeTest.kt
@@ -0,0 +1,140 @@
+package org.terst.feedreader.readability
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Reported live: reader mode on a New Republic feature article showed nothing but
+ * the site's cookie disclaimer, and cached it.
+ *
+ * Reproduced outside the app by running the extractor's own injected script
+ * (ReaderExtractor's `script`, Readability.js and all) against the real page: when
+ * the article body is present, extraction is perfect. When the body is NOT in the
+ * DOM at the moment Readability runs, Readability's own fallback pass kicks in --
+ * its first pass strips "unlikely candidates" (which the consent banner is, its
+ * class says `didomi-notice-banner`), comes back under its 500-character
+ * threshold, and so it retries with that stripping disabled. The Didomi notice's
+ * single paragraph is 510 characters, i.e. just over that threshold, so it is
+ * accepted as the article.
+ *
+ * [EXTRACTED_COOKIE_NOTICE] below is not a hand-written approximation of that --
+ * it is the verbatim `content` string that reproduction produced.
+ *
+ * Nothing on the Kotlin side looked at that: any non-blank `content` counted as a
+ * successful extraction, got written to `articles.extractedContent`, and was never
+ * re-checked. Hence a permanently cached cookie disclaimer.
+ */
+class ConsentNoticeTest {
+
+ @Test
+ fun `the reported New Republic extraction is recognised as a consent notice`() {
+ assertTrue(ConsentNotice.looksLikeConsentNotice(EXTRACTED_COOKIE_NOTICE))
+ }
+
+ // Readability strips class attributes but keeps ids, so the CMP container's id
+ // survives into the extracted HTML and is the most precise signal available.
+ @Test
+ fun `other consent-management platforms are recognised by their container id`() {
+ val cmpIds = listOf(
+ "didomi-host",
+ "onetrust-banner-sdk",
+ "onetrust-consent-sdk",
+ "CybotCookiebotDialog",
+ "qc-cmp2-container",
+ "sp_message_container_1234",
+ "truste-consent-track",
+ "usercentrics-root",
+ "osano-cm-dialog",
+ "iubenda-cs-banner",
+ "optanon-alert-box-wrapper",
+ "cookie-law-info-bar",
+ )
+ for (id in cmpIds) {
+ val html = """<div id="readability-page-1"><div id="$id"><p>Please review your choices.</p></div></div>"""
+ assertTrue("$id should be recognised as a consent container", ConsentNotice.looksLikeConsentNotice(html))
+ }
+ }
+
+ // Not every cookie banner is a known CMP. A short result whose text is
+ // unmistakably consent boilerplate counts too, with no id to go on.
+ @Test
+ fun `a short unbranded cookie banner is recognised from its wording`() {
+ val html = """
+ <div id="readability-page-1"><div><p>We use cookies and similar tracking technologies
+ to personalise content and ads. By continuing to use this site you consent to our use
+ of cookies. You can manage your preferences at any time.</p></div></div>
+ """.trimIndent()
+ assertTrue(ConsentNotice.looksLikeConsentNotice(html))
+ }
+
+ // One stray phrase is not enough -- plenty of ordinary writing mentions cookies.
+ @Test
+ fun `a single incidental phrase is not enough`() {
+ val html = """<div id="readability-page-1"><div><p>The recipe calls for cookies.
+ We use cookies in the frosting too.</p></div></div>"""
+ assertFalse(ConsentNotice.looksLikeConsentNotice(html))
+ }
+
+ // The whole point is to not throw away real articles. A genuine article ABOUT
+ // cookie-consent law would hit every phrase there is; length is what separates
+ // it from a banner. Nothing long is ever rejected.
+ @Test
+ fun `a long article about cookie consent is never rejected`() {
+ val paragraph = "<p>We use cookies and other tracking technologies, and by continuing " +
+ "to use this website you consent to our use of cookies -- that is the boilerplate " +
+ "the GDPR produced, and this article is about where it came from. You can manage " +
+ "your preferences, in theory.</p>"
+ val html = "<div id=\"readability-page-1\"><div>${paragraph.repeat(12)}</div></div>"
+ assertTrue("fixture must be past the length cap", html.length > 2_000)
+ assertFalse(ConsentNotice.looksLikeConsentNotice(html))
+ }
+
+ // Short posts are normal (kottke-style linkblogging is called out in
+ // ReaderExtractor's own comments) and must survive.
+ @Test
+ fun `an ordinary short post is not a consent notice`() {
+ val html = """
+ <div id="readability-page-1"><div><p>A lovely short note about a bridge in Lisbon,
+ with one link and nothing else. This is the entire post.</p></div></div>
+ """.trimIndent()
+ assertFalse(ConsentNotice.looksLikeConsentNotice(html))
+ }
+
+ @Test
+ fun `null and blank content are not consent notices`() {
+ assertFalse(ConsentNotice.looksLikeConsentNotice(null))
+ assertFalse(ConsentNotice.looksLikeConsentNotice(""))
+ assertFalse(ConsentNotice.looksLikeConsentNotice(" \n "))
+ }
+
+ // Markup must not count towards the length budget: a banner wrapped in enough
+ // nested divs and inline styles to exceed the cap in raw characters is still a
+ // banner.
+ @Test
+ fun `length is measured on text not markup`() {
+ val padding = "<div style=\"${"border-color: rgb(0, 0, 0); ".repeat(80)}\">"
+ val html = padding + EXTRACTED_COOKIE_NOTICE + "</div>"
+ assertTrue("fixture must be past the length cap in raw characters", html.length > 2_000)
+ assertTrue(ConsentNotice.looksLikeConsentNotice(html))
+ }
+
+ companion object {
+ /**
+ * Verbatim output of running ReaderExtractor's injected script against the
+ * reported page in a state where the article body was absent. Do not tidy
+ * this up -- its value is that it is exactly what the app produced.
+ */
+ const val EXTRACTED_COOKIE_NOTICE: String =
+ "<div id=\"readability-page-1\" class=\"page\"><div id=\"didomi-host\" data-testid=\"notice\" " +
+ "data-nosnippet=\"true\" lang=\"en\" data-lang-dir=\"ltr\"><p>This website uses cookies and " +
+ "other tracking technologies (“Cookies”) to enhance user experience, to display " +
+ "targeted ads, and to analyze performance and user activity on our website. We also may " +
+ "share information about your activity on our website with our advertising and analytics " +
+ "providers. For more information about our use of Cookies and our privacy practices, see " +
+ "our <a href=\"https://newrepublic.com/pages/privacy\" target=\"_blank\">Privacy Policy</a>. " +
+ "By continuing to use this website, you agree to our " +
+ "<a href=\"https://newrepublic.com/pages/terms-and-conditions\" target=\"_blank\">Terms and " +
+ "Conditions</a>, including our dispute resolution provisions.</p></div></div>"
+ }
+}
diff --git a/app/src/test/java/org/terst/feedreader/readability/EvalOutcomeTest.kt b/app/src/test/java/org/terst/feedreader/readability/EvalOutcomeTest.kt
new file mode 100644
index 0000000..8995e22
--- /dev/null
+++ b/app/src/test/java/org/terst/feedreader/readability/EvalOutcomeTest.kt
@@ -0,0 +1,89 @@
+package org.terst.feedreader.readability
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * The decision ReaderExtractor makes about each `evaluateJavascript` result:
+ * accept it, inject again because the page probably was not ready, or give up
+ * with a reason. It used to live inline in a WebView callback, which meant the
+ * only way to exercise it was on a device against a live page.
+ *
+ * The case that mattered and was never covered: a result that is perfectly
+ * well-formed and non-blank, and is the page's cookie-consent banner rather than
+ * the article. That counted as a success, got cached, and stayed cached.
+ */
+class EvalOutcomeTest {
+
+ private val maxAttempts = 6
+
+ private fun decide(article: ExtractedArticle?, attempt: Int, raw: String = "\"{}\"") =
+ decideEvalOutcome(article, raw, attempt, maxAttempts)
+
+ private fun article(content: String?, error: String? = null) =
+ ExtractedArticle(title = "t", content = content, textContent = content, error = error)
+
+ @Test
+ fun `real content is accepted on the first attempt`() {
+ val a = article("<p>" + "The actual article body. ".repeat(60) + "</p>")
+ val decision = decide(a, attempt = 1)
+ assertEquals(EvalDecision.Done(ExtractionResult.Success(a)), decision)
+ }
+
+ // Retrying cannot change Readability's opinion of the markup, so a thrown
+ // error is final however early it arrives.
+ @Test
+ fun `a readability error is final immediately`() {
+ val decision = decide(article(null, error = "TypeError: x is null"), attempt = 1)
+ assertEquals(
+ EvalDecision.Done(ExtractionResult.Failure(ExtractionFailure.ReadabilityError("TypeError: x is null"))),
+ decision
+ )
+ }
+
+ @Test
+ fun `an undecodable result retries and then reports a bad result`() {
+ assertEquals(EvalDecision.Retry, decide(null, attempt = 1, raw = "null"))
+ val last = decide(null, attempt = maxAttempts, raw = "null")
+ assertEquals(EvalDecision.Done(ExtractionResult.Failure(ExtractionFailure.BadResult("null"))), last)
+ }
+
+ @Test
+ fun `blank content retries and then reports no content`() {
+ assertEquals(EvalDecision.Retry, decide(article(""), attempt = 1))
+ assertEquals(
+ EvalDecision.Done(ExtractionResult.Failure(ExtractionFailure.NoContent)),
+ decide(article(""), attempt = maxAttempts)
+ )
+ }
+
+ // A cookie banner is exactly what a half-loaded page yields -- the banner is
+ // injected early, the article body may not be in the DOM yet -- so the right
+ // first move is the retry the extractor already does for an empty result.
+ @Test
+ fun `a consent notice retries rather than being accepted`() {
+ val decision = decide(article(ConsentNoticeTest.EXTRACTED_COOKIE_NOTICE), attempt = 1)
+ assertEquals(EvalDecision.Retry, decision)
+ }
+
+ @Test
+ fun `a consent notice that survives every attempt is reported as a failure`() {
+ val decision = decide(article(ConsentNoticeTest.EXTRACTED_COOKIE_NOTICE), attempt = maxAttempts)
+ assertEquals(
+ EvalDecision.Done(ExtractionResult.Failure(ExtractionFailure.ConsentInterstitial)),
+ decision
+ )
+ }
+
+ // The failure has to be distinguishable from "no content": one means the page
+ // has no article, the other means the page would not show it without consent.
+ @Test
+ fun `the consent failure says what happened`() {
+ val f: ExtractionFailure = ExtractionFailure.ConsentInterstitial
+ assertTrue(f.userMessage().isNotBlank())
+ assertTrue(f.diagnostic().isNotBlank())
+ assertTrue(f.userMessage().lowercase().contains("cookie") || f.userMessage().lowercase().contains("consent"))
+ assertTrue(f.userMessage() != ExtractionFailure.NoContent.userMessage())
+ }
+}