summaryrefslogtreecommitdiff
path: root/app/src/test/java/org/terst/feedreader/readability/ConsentNoticeTest.kt
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/test/java/org/terst/feedreader/readability/ConsentNoticeTest.kt')
-rw-r--r--app/src/test/java/org/terst/feedreader/readability/ConsentNoticeTest.kt140
1 files changed, 140 insertions, 0 deletions
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>"
+ }
+}