diff options
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.kt | 140 |
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>" + } +} |
