diff --git a/docs/how-tos/configure_ai_moderation.rst b/docs/how-tos/configure_ai_moderation.rst index a916c015..3d0b8574 100644 --- a/docs/how-tos/configure_ai_moderation.rst +++ b/docs/how-tos/configure_ai_moderation.rst @@ -87,6 +87,21 @@ Optional: Spam verdicts are cached by content hash for 24 hours, so an identical repost costs no second API call. Clean verdicts are never cached. +``AI_MODERATION_TRUSTED_LINK_DOMAINS`` + Domains whose links, including links to their subdomains, are removed from + a post before it is classified, so links to sites your learners and course + teams legitimately share are never mistaken for link spam. A post whose + links are all trusted is not classified at all, unless it also contains a + link to another domain, an email address, a phone number or a messaging + app such as WhatsApp or Telegram; such a post is classified without its + trusted links. Empty by default, so links are classified like any other + text until you list domains here. Avoid sites anyone can publish arbitrary + pages on, such as ``github.io``: the classifier never sees links to a + trusted domain. Keep in mind that pages on a trusted domain, such as a + shared code snippet or a meeting registration page, are written by whoever + posts them, so a lure can sit on the trusted page itself, and a short post + that only links to a trusted domain is not classified at all. + 3. Turn it on ************* diff --git a/src/forum/ai_moderation/links.py b/src/forum/ai_moderation/links.py new file mode 100644 index 00000000..81dc95b8 --- /dev/null +++ b/src/forum/ai_moderation/links.py @@ -0,0 +1,180 @@ +""" +Handling of trusted links in content before it is classified. + +The classifier only ever sees the text of a post, never the course it was +posted in, so it cannot tell a learner sharing their CodePen in a CSS course +from someone dropping a link to advertise. Links to the domains a deployment +trusts are therefore taken out before classification. + +Taking the link out is not always enough: a short post such as "register here: +" reads as a lure to the classifier with or without its link. So a post +whose links are all trusted is not classified at all, unless it also gives +readers another way to be contacted or sent elsewhere -- a link to any other +domain, an email address, a phone number or a messaging app. Those are what a +scam needs, so a scam that adds a trusted link is still classified, without the +trusted link. +""" + +import bisect +import html +import re +import unicodedata +from functools import lru_cache +from typing import Iterable, Optional, Pattern + +# The tags of an HTML anchor, as written by the discussions editor. An anchor +# pointing at a trusted domain is replaced by its text, so the classifier is +# not left looking at an empty link. Opening and closing tags are found +# separately, and an opening tag cannot run past the next "<", so this stays +# linear however malformed the HTML of a post is: moderation runs inside the +# request that creates the post, and posts have no size limit. +_ANCHOR_OPEN_RE = re.compile(r"]*>", re.IGNORECASE) +_ANCHOR_CLOSE_RE = re.compile(r"", re.IGNORECASE) +_HREF_RE = re.compile(r"\bhref\s*=\s*(?:\"([^\"]*)\"|'([^']*)')", re.IGNORECASE) + +# Markup that carries nothing to classify: tags without attributes, such as the +#

and
wrapping a post. A tag with attributes, like an , +# is content in its own right. +_BARE_TAG_RE = re.compile(r"") + +# Top-level domains a bare domain written without "http" or "www." is +# recognised by. Not exhaustive: a bare domain under any other TLD goes +# unnoticed unless a path follows it, as in "codepen.oi/editor/...". +_COMMON_TLDS = ( + "com|net|org|info|biz|edu|gov|co|io|me|ly|us|uk|in|ru|cn|ai|app|dev|xyz" + "|online|site|shop|store|top|click|link|live|club|vip|tk|cc|tv|ws|pw" +) + +# Ways a post can send readers somewhere other than a trusted link: a link with +# a scheme or "www.", a bare domain under a common TLD or followed by a path, +# an anchor or embedded media (trusted anchors have been unwrapped by then), an +# email address, a messaging app, or a spelled-out "dot com". Every alternative +# that can match a long run of characters only starts at the beginning of a +# word, which keeps the search linear. +_RISK_SIGNAL_RE = re.compile( + r"https?://|www\.|<(?:a|img|iframe|video|embed|object)\b" + rf"|(? Optional[Pattern[str]]: + """ + Build a pattern matching a URL on any of ``domains``, or a subdomain of one. + + The host has to end exactly at the trusted domain, so ``codepen.io`` matches + neither ``codepen.io.example.com`` nor ``notcodepen.io``. The scheme is + optional because learners often type ``codepen.io/...`` without one. + """ + names = sorted( + {domain.strip().strip(".").lower() for domain in domains if domain.strip()}, + key=len, + reverse=True, + ) + if not names: + return None + + hosts = "|".join(re.escape(name) for name in names) + return re.compile( + rf"(?\"']*)?", + re.IGNORECASE, + ) + + +def strip_trusted_links(content: str, trusted_domains: Optional[Iterable[str]]) -> str: + """ + Remove every link to a trusted domain from ``content``. + + Args: + content: The text about to be classified. May contain HTML. + trusted_domains: Domains whose links, including links to their + subdomains, are removed. + + Returns: + ``content`` without the trusted links. Links to any other domain, and + all other text, are left exactly as they were. + """ + if not content or not trusted_domains: + return content + + pattern = _trusted_link_pattern(tuple(trusted_domains)) + if pattern is None: + return content + + return pattern.sub("", _unwrap_trusted_anchors(content, pattern)) + + +def _unwrap_trusted_anchors(content: str, pattern: Pattern[str]) -> str: + """ + Replace every anchor whose href matches ``pattern`` by the anchor's text. + + An anchor runs from its opening tag to the first closing tag after it. One + that is never closed is left as it is. + """ + closings = [match.span() for match in _ANCHOR_CLOSE_RE.finditer(content)] + closing_starts = [start for start, _ in closings] + parts = [] + position = 0 + for opening in _ANCHOR_OPEN_RE.finditer(content): + opening_start, opening_end = opening.span() + if opening_start < position: + # Inside an anchor that has already been replaced by its text. + continue + href = _HREF_RE.search(opening.group(0)) + if href is None: + continue + url = href.group(1) if href.group(1) is not None else href.group(2) + if not pattern.fullmatch(url.strip()): + continue + index = bisect.bisect_left(closing_starts, opening_end) + if index == len(closings): + continue + closing_start, closing_end = closings[index] + parts.append(content[position:opening_start]) + parts.append(content[opening_end:closing_start]) + position = closing_end + parts.append(content[position:]) + return "".join(parts) + + +def is_blank(content: str) -> bool: + """Return whether ``content`` holds nothing but whitespace and bare tags.""" + text = _BARE_TAG_RE.sub("", content or "").replace(" ", " ") + return not text.strip() + + +def has_risk_signals(content: str) -> bool: + """ + Return whether ``content`` gives readers a way to be contacted or redirected. + + Meant for content that trusted links have already been removed from: any + link left in it, an email address, a phone number or a messaging app + mention counts. A false match is harmless, since it only means the content + is classified as usual, whereas a miss means it is not classified at all. + """ + if not content: + return False + # Undo the usual ways of hiding a link or contact from a pattern: HTML + # entities, full-width letters and invisible characters. + content = unicodedata.normalize("NFKC", html.unescape(content)) + content = _INVISIBLE_RE.sub("", content) + if _RISK_SIGNAL_RE.search(content): + return True + return any( + sum(char.isdigit() for char in candidate) >= _PHONE_MIN_DIGITS + for candidate in _PHONE_CANDIDATE_RE.findall(content) + ) diff --git a/src/forum/ai_moderation/service.py b/src/forum/ai_moderation/service.py index 3f5f6662..84c06e3b 100644 --- a/src/forum/ai_moderation/service.py +++ b/src/forum/ai_moderation/service.py @@ -4,7 +4,7 @@ import hashlib import logging -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, Sequence from django.conf import settings from django.contrib.auth import get_user_model @@ -25,6 +25,11 @@ DEFAULT_FLAGGED_CACHE_TTL, DEFAULT_REASONING, ) +from forum.ai_moderation.links import ( + has_risk_signals, + is_blank, + strip_trusted_links, +) from forum.backends.mysql.models import ModerationAuditLog from forum.utils import ForumV2RequestError @@ -146,6 +151,11 @@ def flagged_cache_prefix(self) -> str: settings, "AI_MODERATION_FLAGGED_CACHE_PREFIX", DEFAULT_FLAGGED_CACHE_PREFIX ) + @property + def trusted_link_domains(self) -> Sequence[str]: + """Domains whose links are removed from content before it is classified.""" + return getattr(settings, "AI_MODERATION_TRUSTED_LINK_DOMAINS", None) or () + @property def moderation_backend_path(self) -> Optional[str]: """Dotted path of the configured moderation backend, if one is configured.""" @@ -284,6 +294,24 @@ def moderate_and_flag_content( if not is_ai_moderation_enabled(course_key): # type: ignore[no-untyped-call] return result + # The classifier cannot tell a link to a trusted domain from link spam, + # so it only gets to see the rest of the post. + stripped = strip_trusted_links(content, self.trusted_link_domains) + if is_blank(stripped): + result["reasoning"] = "Nothing to classify once trusted links are removed" + return result + # A post sharing trusted links that offers no other way to be contacted + # or redirected is not spam, however its wording reads to the classifier. + if stripped != content and not has_risk_signals(stripped): + # Skipped posts get no audit log entry, so this is the only record + # of how often the trusted-link exemption is used, or abused. + log.info( + f"AI moderation skipped, only trusted links: {content_instance.get('_id')}" + ) + result["reasoning"] = "Only trusted links, and no other contact or link" + return result + content = stripped + # If we've already flagged this exact content before, reuse the cached result moderation_result = self._get_cached_flagged_result(content) if moderation_result is None: diff --git a/src/forum/settings/common.py b/src/forum/settings/common.py index a2649fa3..b696a761 100644 --- a/src/forum/settings/common.py +++ b/src/forum/settings/common.py @@ -85,12 +85,17 @@ def plugin_settings(settings: Any) -> None: # interface and ships no provider, so a deployment must name the backend it # wants. Neither does AI_MODERATION_API_URL, nor AI_MODERATION_USER_ID, which # decides who flagging and deletion are attributed to -- no user is created for - # you. Whatever else a backend needs is read by that backend and deliberately - # not declared here: the model name, the credential setting, or whatever else - # the provider you wrap happens to want. + # you. Nor does AI_MODERATION_TRUSTED_LINK_DOMAINS: which links are kept from + # the classifier is the deployment's call, so no domain is trusted until it + # lists one. Whatever else a backend needs is read by that backend and + # deliberately not declared here: the model name, the credential setting, or + # whatever else the provider you wrap happens to want. settings.AI_MODERATION_BACKEND = getattr(settings, "AI_MODERATION_BACKEND", None) settings.AI_MODERATION_API_URL = getattr(settings, "AI_MODERATION_API_URL", None) settings.AI_MODERATION_USER_ID = getattr(settings, "AI_MODERATION_USER_ID", None) + settings.AI_MODERATION_TRUSTED_LINK_DOMAINS = getattr( + settings, "AI_MODERATION_TRUSTED_LINK_DOMAINS", [] + ) settings.AI_MODERATION_SYSTEM_MESSAGE = getattr( settings, "AI_MODERATION_SYSTEM_MESSAGE", DEFAULT_SYSTEM_MESSAGE ) diff --git a/tests/test_ai_moderation.py b/tests/test_ai_moderation.py index c5f6f8ec..b0258c27 100644 --- a/tests/test_ai_moderation.py +++ b/tests/test_ai_moderation.py @@ -5,6 +5,7 @@ from unittest.mock import Mock, MagicMock, patch import pytest +from django.conf import settings as django_settings from django.contrib.auth import get_user_model from django.core.cache import cache from django.test import override_settings @@ -459,6 +460,236 @@ def test_flagged_result_is_cached_and_reused( assert mock_post.call_count == 1 +class TestAIModerationTrustedLinks: # pylint: disable=redefined-outer-name,unused-argument + """Tests for removing trusted links before content is classified.""" + + @pytest.fixture(autouse=True) + def trusted_link_domains(self) -> Generator[None, None, None]: + """Trust domains the way a deployment's configuration lists them.""" + with override_settings( + AI_MODERATION_TRUSTED_LINK_DOMAINS=["codepen.io", "github.com"] + ): + yield + + def test_trusted_links_are_removed_before_classification( + self, + ai_service: AIModerationService, + mock_waffle_flags: dict[str, Mock], + sample_comment_content: dict[str, Any], + ) -> None: + """The classifier never sees a link to a trusted domain.""" + classify = Mock( + return_value={"classification": "not_spam", "reasoning": "Legitimate"} + ) + backend = Mock() + + with patch.object(ai_service.moderation_backend, "classify", classify): + result = ai_service.moderate_and_flag_content( + '

My pen: ' + "https://codepen.io/learner/pen/abc - notes at " + "https://example.com/notes

", + sample_comment_content, + course_id="course-v1:edX+DemoX+Demo", + backend=backend, + ) + + classify.assert_called_once_with( + "

My pen: - notes at https://example.com/notes

" + ) + assert result["is_spam"] is False + backend.flag_content_as_spam.assert_not_called() + + def test_trusted_links_without_other_contact_are_not_classified( + self, + ai_service: AIModerationService, + mock_waffle_flags: dict[str, Mock], + sample_comment_content: dict[str, Any], + caplog: pytest.LogCaptureFixture, + ) -> None: + """ + A post whose links are all trusted, and that offers no other way to be + contacted or redirected, is not spam however vague its wording. + """ + classify = Mock( + return_value={"classification": "spam_or_scam", "reasoning": "Vague"} + ) + backend = Mock() + + caplog.set_level("INFO", logger="forum.ai_moderation.service") + + with patch.object(ai_service.moderation_backend, "classify", classify): + result = ai_service.moderate_and_flag_content( + "

Use this like fo registation : " + 'URL

', + sample_comment_content, + course_id="course-v1:edX+DemoX+Demo", + backend=backend, + ) + + classify.assert_not_called() + assert result["is_spam"] is False + assert result["actions_taken"] == ["no_action"] + backend.flag_content_as_spam.assert_not_called() + backend.flag_as_abuse.assert_not_called() + assert "AI moderation skipped, only trusted links: comment456" in caplog.text + + @pytest.mark.parametrize( + "other_contact", + [ + "WhatsApp +1-555-0199", + "mail me at tutor@example.com", + "or see https://example.com/offer", + "and codepen.oi/editor/learner/pen/019f", + ], + ) + def test_trusted_links_with_other_contact_are_classified( + self, + ai_service: AIModerationService, + mock_waffle_flags: dict[str, Mock], + sample_comment_content: dict[str, Any], + other_contact: str, + ) -> None: + """Any other way to reach or redirect readers sends the post to the classifier.""" + classify = Mock( + return_value={"classification": "not_spam", "reasoning": "Legitimate"} + ) + + with patch.object(ai_service.moderation_backend, "classify", classify): + ai_service.moderate_and_flag_content( + f"Register here: https://codepen.io/pen/1 {other_contact}", + sample_comment_content, + course_id="course-v1:edX+DemoX+Demo", + backend=Mock(), + ) + + classify.assert_called_once_with(f"Register here: {other_contact}") + + def test_spam_with_a_trusted_link_is_still_flagged( + self, + ai_service: AIModerationService, + mock_waffle_flags: dict[str, Mock], + sample_comment_content: dict[str, Any], + ) -> None: + """Adding a trusted link to spam does not get the spam through.""" + mock_waffle_flags["auto_delete"].return_value = False + backend = Mock() + + with patch( + "requests.post", return_value=stub_provider_response(SPAM_RESPONSE) + ) as mock_post: + result = ai_service.moderate_and_flag_content( + "Guaranteed A grades! WhatsApp +1-555-0199 https://codepen.io/pen/1", + sample_comment_content, + course_id="course-v1:edX+DemoX+Demo", + backend=backend, + ) + + sent = mock_post.call_args.kwargs["json"]["input"] + assert sent == "Guaranteed A grades! WhatsApp +1-555-0199 " + assert result["is_spam"] is True + assert result["actions_taken"] == ["flagged"] + backend.flag_content_as_spam.assert_called_once() + + def test_content_that_is_only_trusted_links_is_not_classified( + self, + ai_service: AIModerationService, + mock_waffle_flags: dict[str, Mock], + sample_comment_content: dict[str, Any], + ) -> None: + """With nothing left to classify, no classifier call is made.""" + classify = Mock() + backend = Mock() + + with patch.object(ai_service.moderation_backend, "classify", classify): + result = ai_service.moderate_and_flag_content( + '

' + "https://codepen.io/learner/pen/abc

", + sample_comment_content, + course_id="course-v1:edX+DemoX+Demo", + backend=backend, + ) + + classify.assert_not_called() + assert result["is_spam"] is False + assert result["actions_taken"] == ["no_action"] + backend.flag_content_as_spam.assert_not_called() + backend.flag_as_abuse.assert_not_called() + + def test_untrusted_links_are_still_classified( + self, + ai_service: AIModerationService, + mock_waffle_flags: dict[str, Mock], + sample_comment_content: dict[str, Any], + ) -> None: + """Links to domains that are not trusted reach the classifier unchanged.""" + content = "Order now at https://top-grade-helpers.example" + classify = Mock( + return_value={"classification": "spam_or_scam", "reasoning": "Spam"} + ) + mock_waffle_flags["auto_delete"].return_value = False + + with patch.object(ai_service.moderation_backend, "classify", classify): + result = ai_service.moderate_and_flag_content( + content, + sample_comment_content, + course_id="course-v1:edX+DemoX+Demo", + backend=Mock(), + ) + + classify.assert_called_once_with(content) + assert result["is_spam"] is True + + def test_trusted_links_can_be_turned_off( + self, + ai_service: AIModerationService, + mock_waffle_flags: dict[str, Mock], + sample_comment_content: dict[str, Any], + ) -> None: + """An empty AI_MODERATION_TRUSTED_LINK_DOMAINS classifies links as before.""" + content = "My pen: https://codepen.io/learner/pen/abc" + classify = Mock( + return_value={"classification": "not_spam", "reasoning": "Legitimate"} + ) + + with override_settings(AI_MODERATION_TRUSTED_LINK_DOMAINS=[]), patch.object( + ai_service.moderation_backend, "classify", classify + ): + ai_service.moderate_and_flag_content( + content, + sample_comment_content, + course_id="course-v1:edX+DemoX+Demo", + backend=Mock(), + ) + + classify.assert_called_once_with(content) + + def test_nothing_is_trusted_unless_configured( + self, + ai_service: AIModerationService, + mock_waffle_flags: dict[str, Mock], + sample_comment_content: dict[str, Any], + ) -> None: + """Forum ships no trusted domains; the list comes from configuration.""" + content = "My pen: https://codepen.io/learner/pen/abc" + classify = Mock( + return_value={"classification": "not_spam", "reasoning": "Legitimate"} + ) + + with override_settings(), patch.object( + ai_service.moderation_backend, "classify", classify + ): + del django_settings.AI_MODERATION_TRUSTED_LINK_DOMAINS + ai_service.moderate_and_flag_content( + content, + sample_comment_content, + course_id="course-v1:edX+DemoX+Demo", + backend=Mock(), + ) + + classify.assert_called_once_with(content) + + class TestAIModerationErrorHandling: # pylint: disable=redefined-outer-name,unused-argument """Tests for error handling in AI moderation auto-delete.""" diff --git a/tests/test_ai_moderation_backends.py b/tests/test_ai_moderation_backends.py index 7d2683a3..d651c61f 100644 --- a/tests/test_ai_moderation_backends.py +++ b/tests/test_ai_moderation_backends.py @@ -127,6 +127,7 @@ def test_defaults_are_declared(self) -> None: assert site.AI_MODERATION_BACKEND is None assert site.AI_MODERATION_API_URL is None assert site.AI_MODERATION_USER_ID is None + assert site.AI_MODERATION_TRUSTED_LINK_DOMAINS == [] def test_no_provider_specific_settings_are_declared(self) -> None: """ @@ -154,6 +155,7 @@ def test_configured_values_are_never_overridden(self) -> None: AI_MODERATION_CONNECTION_TIMEOUT=0.5, AI_MODERATION_READ_TIMEOUT=20, AI_MODERATION_USER_ID=758316, + AI_MODERATION_TRUSTED_LINK_DOMAINS=["courses.example.edu"], ) plugin_settings(site) @@ -163,6 +165,7 @@ def test_configured_values_are_never_overridden(self) -> None: assert site.AI_MODERATION_CONNECTION_TIMEOUT == 0.5 assert site.AI_MODERATION_READ_TIMEOUT == 20 assert site.AI_MODERATION_USER_ID == 758316 + assert site.AI_MODERATION_TRUSTED_LINK_DOMAINS == ["courses.example.edu"] def test_settings_module_imports_before_apps_are_ready(self) -> None: """ diff --git a/tests/test_ai_moderation_links.py b/tests/test_ai_moderation_links.py new file mode 100644 index 00000000..87af299f --- /dev/null +++ b/tests/test_ai_moderation_links.py @@ -0,0 +1,288 @@ +"""Tests for handling trusted links in content before it is classified.""" + +import time + +import pytest + +from forum.ai_moderation.links import has_risk_signals, is_blank, strip_trusted_links + +TRUSTED = ("codepen.io", "github.com", "developer.mozilla.org") + + +class TestStripTrustedLinks: + """Tests for strip_trusted_links.""" + + @pytest.mark.parametrize( + "content, expected", + [ + ( + "My pen: https://codepen.io/learner/pen/abcXYZ can you help?", + "My pen: can you help?", + ), + ("http://codepen.io/learner/pen/abcXYZ", ""), + ("See codepen.io/learner/pen/abcXYZ please", "See please"), + ("Try https://www.codepen.io/pen/1.", "Try "), + ("Docs: https://developer.mozilla.org/en-US/docs/Web/CSS/flex", "Docs: "), + ("Fork https://gist.github.com/someone/123", "Fork "), + ("HTTPS://CODEPEN.IO/LEARNER/PEN/1 thanks", " thanks"), + ("https://codepen.io:443/pen/1?editors=1100#top done", " done"), + ("I love codepen.io.", "I love ."), + ], + ) + def test_trusted_urls_are_removed(self, content: str, expected: str) -> None: + """A trusted URL is removed whatever its scheme, case, subdomain or path.""" + assert strip_trusted_links(content, TRUSTED) == expected + + @pytest.mark.parametrize( + "content", + [ + "Visit https://top-grade-helpers.example/order now", + "https://codepen.io.evil.example/pen/1", + "https://notcodepen.io/pen/1", + "https://codepen-io.example/pen/1", + "mail me at someone@codepen.io", + "www.example.com and https://example.org/codepen", + ], + ) + def test_other_urls_are_left_alone(self, content: str) -> None: + """Lookalike and untrusted hosts are left for the classifier to judge.""" + assert strip_trusted_links(content, TRUSTED) == content + + def test_untrusted_host_stays_visible_when_a_trusted_url_is_embedded( + self, + ) -> None: + """ + Removing a trusted URL that sits inside another URL never hides the + untrusted host around it. + """ + content = "Go to https://evil.example/?next=https://codepen.io/pen/1" + + assert "https://evil.example/" in strip_trusted_links(content, TRUSTED) + + def test_trusted_anchor_is_replaced_by_its_text(self) -> None: + """The editor writes links as anchors; a trusted one becomes plain text.""" + content = ( + '

Here is my pen, what is wrong?

' + ) + + assert strip_trusted_links(content, TRUSTED) == ( + "

Here is my pen, what is wrong?

" + ) + + def test_autolinked_trusted_url_is_removed_entirely(self) -> None: + """An autolinked URL is both the href and the text; neither is left.""" + content = ( + '

' + "https://codepen.io/learner/pen/abc

" + ) + + assert strip_trusted_links(content, TRUSTED) == "

" + + def test_single_quoted_and_uppercase_anchors_are_unwrapped(self) -> None: + """Quote style and tag case do not matter.""" + content = "

See my pen

" + + assert strip_trusted_links(content, TRUSTED) == "

See my pen

" + + def test_every_trusted_anchor_is_unwrapped(self) -> None: + """Each trusted anchor is replaced by its own text.""" + content = ( + '

one and ' + 'two

' + ) + + assert strip_trusted_links(content, TRUSTED) == "

one and two

" + + def test_unclosed_trusted_anchor_is_left_in_place(self) -> None: + """ + An anchor that is never closed keeps its tag, so the post is still + classified; only the trusted URL is taken out of it. + """ + content = '

my pen

' + + assert strip_trusted_links(content, TRUSTED) == '

my pen

' + + def test_untrusted_anchor_is_kept(self) -> None: + """ + An anchor to an untrusted host is kept, even when its text is a trusted + URL: the href is what a reader would actually follow. + """ + content = ( + '

' + "https://codepen.io/learner/pen/abc

" + ) + + assert strip_trusted_links(content, TRUSTED) == ( + '

' + ) + + def test_rest_of_a_spam_post_is_kept(self) -> None: + """A trusted link does not take the spam around it out of the post.""" + content = "Guaranteed A grades! WhatsApp +1-555-0199 https://codepen.io/pen/1" + + assert strip_trusted_links(content, TRUSTED) == ( + "Guaranteed A grades! WhatsApp +1-555-0199 " + ) + + @pytest.mark.parametrize("domains", [(), [], None, ("", " ")]) + def test_no_trusted_domains_leaves_content_unchanged(self, domains: object) -> None: + """With nothing trusted, every link is classified like any other text.""" + content = "My pen: https://codepen.io/learner/pen/abc" + + assert strip_trusted_links(content, domains) == content # type: ignore[arg-type] + + def test_domains_are_normalized(self) -> None: + """Domains configured with odd case, padding or dots still match.""" + content = "My pen: https://codepen.io/learner/pen/abc" + + assert strip_trusted_links(content, [" .CodePen.IO. "]) == "My pen: " + + def test_reported_case(self) -> None: + """Pens a learner shares in a CSS course are removed, as on-topic links.""" + content = "

My solution: https://codepen.io/learner/pen/abc

" + + assert strip_trusted_links(content, TRUSTED) == "

My solution:

" + + +class TestIsBlank: + """Tests for is_blank.""" + + @pytest.mark.parametrize( + "content", ["", " ", "

", "

\n

 

", "

"] + ) + def test_blank_content(self, content: str) -> None: + """Whitespace and bare wrapping tags leave nothing to classify.""" + assert is_blank(content) is True + + @pytest.mark.parametrize( + "content", + [ + "hi", + "

hi

", + '

', + '

', + ], + ) + def test_content_to_classify(self, content: str) -> None: + """Text, or any tag carrying attributes, is still worth classifying.""" + assert is_blank(content) is False + + +class TestHasRiskSignals: + """Tests for has_risk_signals.""" + + @pytest.mark.parametrize( + "content", + [ + "Order now at https://top-grade-helpers.example/order", + "Cheap essays at www.essays.example", + "Best grades: bestessays.com", + "Message me: wa.me/15550199", + "Join t.me/scamgroup", + "Visit zoom.us.in for details", + "Fork it at codepen.oi/editor/learner/pen/019f1be8", + "Free grades: bit.ly/free-grades", + "See example.zz/offer for more", + '

register

', + '

this

', + '

', + '', + "Email tutor@example.com for answers", + "Call +1-555-0199 today", + "Reach me on +91 98765 43210", + "Contact (555) 123-4567", + "Add me on WhatsApp", + "Ping me on whats app", + "Join our Telegram group", + "I am on WeChat and Viber", + "Join discord.gg/abc123", + "Everything is on linktr.ee/helper", + "Sign up at forms.gle/abc", + "Shortened: shorturl.at/abc", + "Email tutor at gmail dot com", + "Visit evil.com", + "What\u200bsApp me", + "Whats-App me", + "Whatsapp me", + ], + ) + def test_ways_to_contact_or_redirect(self, content: str) -> None: + """Any other link, contact detail or messaging app is a risk signal.""" + assert has_risk_signals(content) is True + + @pytest.mark.parametrize( + "content", + [ + "", + "

Use this like fo registation : URL

", + "

My solution: why is the footer not sticky?

", + "

Join our study session on Monday at 5pm in room 101

", + "Edit style.css and index.html, then reload", + 'Use document.getElementById("nav") and node.js 18', + "body { margin: 0 auto; width: 960px; color: #333; }", + "e.g. flexbox, i.e. the 2024 spec, version 1.2.3", + "Week 3, question 5 of 10", + "Open src/app.js and ../css/style.css", + "width: calc(100% / 3);", + ], + ) + def test_ordinary_posts(self, content: str) -> None: + """Everyday post text, code included, carries no risk signal.""" + assert has_risk_signals(content) is False + + def test_long_post_is_checked_quickly(self) -> None: + """A long post without a risk signal does not stall the request.""" + start = time.perf_counter() + assert has_risk_signals("a" * 50_000) is False + assert time.perf_counter() - start < 1 + + def test_reported_case_passes_once_its_trusted_link_is_removed(self) -> None: + """A meeting registration link with a short note, a lure to the classifier.""" + content = ( + "

Use this like fo registation : " + 'URL

' + ) + stripped = strip_trusted_links(content, ["zoom.us"]) + + assert stripped == "

Use this like fo registation : URL

" + assert has_risk_signals(stripped) is False + + def test_scam_keeps_its_risk_once_its_trusted_link_is_removed(self) -> None: + """A scam that adds a trusted link still offers another way to reach it.""" + content = ( + "Guaranteed A grades! WhatsApp +1-555-0199 https://us02web.zoom.us/j/1" + ) + stripped = strip_trusted_links(content, ["zoom.us"]) + + assert stripped == "Guaranteed A grades! WhatsApp +1-555-0199 " + assert has_risk_signals(stripped) is True + + +class TestHostilePosts: + """ + Moderation runs inside the request that creates a post, and posts have no + size limit, so link handling has to stay linear on hostile input. + """ + + @pytest.mark.parametrize( + "content", + [ + "a" * 50_000, + "a." * 25_000, + "1 " * 25_000 + "x", + ("whats" + " " * 50) * 1_000, + 'y' * 2_000, + " None: + """Stripping, the blank check and the risk check all finish quickly.""" + start = time.perf_counter() + stripped = strip_trusted_links(content, TRUSTED) + is_blank(stripped) + has_risk_signals(stripped) + assert time.perf_counter() - start < 1