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"?[a-zA-Z][a-zA-Z0-9]*\s*/?>")
+
+# 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 = ( + '' + ) + + 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 = '' + + assert strip_trusted_links(content, TRUSTED) == '' + + 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
", "
hi
", + '

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