diff options
Diffstat (limited to 'tests/tasks/test_sender.py')
| -rw-r--r-- | tests/tasks/test_sender.py | 788 |
1 files changed, 788 insertions, 0 deletions
diff --git a/tests/tasks/test_sender.py b/tests/tasks/test_sender.py new file mode 100644 index 0000000..86799f7 --- /dev/null +++ b/tests/tasks/test_sender.py @@ -0,0 +1,788 @@ +import logging +import uuid +from datetime import datetime, timezone +from types import ModuleType +from unittest.mock import MagicMock, Mock + +import httpx +import pytest +from flask import Flask + +from webmentions_ssg import DATABASE as db +from webmentions_ssg.models import SentWebmention, SentWebmentionStatus, Source + +SOURCE_URL = "https://dennisfink.me/blog/example/" +TARGET_URL = "https://example.com/post" +ENDPOINT_URL = "https://example.com/webmention" +STATUS_URL = "https://example.com/webmention/status/123" + + +def create_sent_webmention( + app: Flask, + *, + active: bool = True, + desired_revision: int = 1, + processed_revision: int | None = None, + sent_revision: int | None = None, + status: SentWebmentionStatus | None = None, + failure_reason: str | None = None, + endpoint: str | None = None, + response_status: int | None = None, + status_url: str | None = None, +) -> uuid.UUID: + now = datetime.now(timezone.utc) + + with app.app_context(): + source = Source( + path="example/index.html", + url=SOURCE_URL, + content_hash="0" * 32, + revision=desired_revision, + last_seen_at=now, + revised_at=now, + ) + + webmention = SentWebmention( + target=TARGET_URL, + active=active, + desired_revision=desired_revision, + processed_revision=processed_revision, + sent_revision=sent_revision, + status=status, + failure_reason=failure_reason, + endpoint=endpoint, + response_status=response_status, + status_url=status_url, + ) + + source.sent_webmentions.append(webmention) + + db.session.add(source) + db.session.commit() + + return webmention.uuid + + +def mock_sender_requests( + sender_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, + *, + endpoint: str | None = ENDPOINT_URL, + result: tuple[int, str | None] = (202, None), +) -> tuple[Mock, Mock, Mock, Mock]: + httpx_client = MagicMock() + client = httpx_client.return_value.__enter__.return_value + + discover = Mock(return_value=endpoint) + post = Mock(return_value=result) + + monkeypatch.setattr(sender_module.httpx, "Client", httpx_client) + monkeypatch.setattr(sender_module, "discover_webmention_endpoint", discover) + monkeypatch.setattr(sender_module, "post_webmention", post) + + return httpx_client, client, discover, post + + +@pytest.fixture +def sender_module(app: Flask) -> ModuleType: + # Importing sender registers Huey tasks. The dependency on the + # app fixture guarantees that Huey has been initialized first. + _ = app + + from webmentions_ssg.tasks import sender + + return sender + + +@pytest.mark.parametrize( + ("value", "expected"), + ( + ( + '<https://example.com/webmention>; rel="webmention"', + ("https://example.com/webmention", {"webmention"}), + ), + ( + "<https://example.com/webmention>; rel=webmention", + ("https://example.com/webmention", {"webmention"}), + ), + ( + '<https://example.com/webmention>; rel="webmention alternate"', + ("https://example.com/webmention", {"webmention", "alternate"}), + ), + ( + '<https://example.com/webmention>; REL="WebMention Alternate"', + ("https://example.com/webmention", {"webmention", "alternate"}), + ), + ( + "<https://example.com/webmention>; " + 'type="text/html"; rel="webmention"; title="Endpoint"', + ("https://example.com/webmention", {"webmention"}), + ), + ( + '<https://example.com/stylesheet>; type="text/css"', + ("https://example.com/stylesheet", set()), + ), + ), +) +def test_parse_link_value( + sender_module: ModuleType, value: str, expected: tuple[str, set[str]] +) -> None: + assert sender_module.parse_link_value(value) == expected + + +@pytest.mark.parametrize( + "value", ("", "https://example.com/webmention", "<https://example.com/webmention") +) +def test_parse_link_value_rejects_malformed_value( + sender_module: ModuleType, value: str +) -> None: + assert sender_module.parse_link_value(value) is None + + +@pytest.mark.parametrize( + ("target", "headers", "html", "expected_endpoint"), + ( + pytest.param( + "https://example.com/test/1", + (("Link", "</test/1/webmention?head=true>; rel=webmention"),), + "", + "https://example.com/test/1/webmention?head=true", + id="1-http-link-unquoted-rel-relative-url", + ), + pytest.param( + "https://example.com/test/2", + ( + ( + "Link", + "<https://example.com/test/2/webmention?head=true>; rel=webmention", + ), + ), + "", + "https://example.com/test/2/webmention?head=true", + id="2-http-link-unquoted-rel-absolute-url", + ), + pytest.param( + "https://example.com/test/3", + (), + """ + <link + rel="webmention" + href="/test/3/webmention" + > + """, + "https://example.com/test/3/webmention", + id="3-html-link-relative-url", + ), + pytest.param( + "https://example.com/test/4", + (), + """ + <link + rel="webmention" + href="https://example.com/test/4/webmention" + > + """, + "https://example.com/test/4/webmention", + id="4-html-link-absolute-url", + ), + pytest.param( + "https://example.com/test/5", + (), + """ + <a + rel="webmention" + href="/test/5/webmention" + > + Endpoint + </a> + """, + "https://example.com/test/5/webmention", + id="5-html-a-relative-url", + ), + pytest.param( + "https://example.com/test/6", + (), + """ + <a + rel="webmention" + href="https://example.com/test/6/webmention" + > + Endpoint + </a> + """, + "https://example.com/test/6/webmention", + id="6-html-a-absolute-url", + ), + pytest.param( + "https://example.com/test/7", + ( + ( + "LinK", + "<https://example.com/test/7/webmention?head=true>; rel=webmention", + ), + ), + "", + "https://example.com/test/7/webmention?head=true", + id="7-http-link-strange-casing", + ), + pytest.param( + "https://example.com/test/8", + ( + ( + "Link", + '<https://example.com/test/8/webmention?head=true>; rel="webmention"', + ), + ), + "", + "https://example.com/test/8/webmention?head=true", + id="8-http-link-quoted-rel", + ), + pytest.param( + "https://example.com/test/9", + (), + """ + <link + rel="webmention somethingelse" + href="https://example.com/test/9/webmention" + > + """, + "https://example.com/test/9/webmention", + id="9-multiple-html-rel-values", + ), + pytest.param( + "https://example.com/test/10", + ( + ( + "Link", + "<https://example.com/test/10/webmention?head=true>; " + 'rel="webmention somethingelse"', + ), + ), + "", + "https://example.com/test/10/webmention?head=true", + id="10-multiple-link-header-rel-values", + ), + pytest.param( + "https://example.com/test/11", + (("Link", '</test/11/webmention>; rel="webmention"'),), + """ + <link + rel="webmention" + href="/test/11/webmention/error" + > + + <a + rel="webmention" + href="/test/11/webmention/error" + > + Wrong endpoint + </a> + """, + "https://example.com/test/11/webmention", + id="11-http-link-precedence", + ), + pytest.param( + "https://example.com/test/12", + (), + """ + <link + rel="not-webmention" + href="/test/12/webmention/error" + > + + <a + rel="webmention" + href="/test/12/webmention" + > + Correct endpoint + </a> + """, + "https://example.com/test/12/webmention", + id="12-exact-rel-match", + ), + pytest.param( + "https://example.com/test/13", + (), + """ + <!-- + <a + rel="webmention" + href="/test/13/webmention/error" + > + False endpoint + </a> + --> + + <a + rel="webmention" + href="/test/13/webmention" + > + Correct endpoint + </a> + """, + "https://example.com/test/13/webmention", + id="13-endpoint-inside-html-comment", + ), + pytest.param( + "https://example.com/test/14", + (), + """ + <code> + <a + rel="webmention" + href="/test/14/webmention/error" + > + False endpoint + </a> + </code> + + <a + rel="webmention" + href="/test/14/webmention" + > + Correct endpoint + </a> + """, + "https://example.com/test/14/webmention", + id="14-endpoint-in-escaped-html", + ), + pytest.param( + "https://example.com/test/15", + (), + """ + <link + rel="webmention" + href="" + > + """, + "https://example.com/test/15", + id="15-empty-href", + ), + pytest.param( + "https://example.com/test/16", + (), + """ + <a + rel="webmention" + href="/test/16/webmention" + > + First endpoint + </a> + + <link + rel="webmention" + href="/test/16/webmention/error" + > + """, + "https://example.com/test/16/webmention", + id="16-a-before-link", + ), + pytest.param( + "https://example.com/test/17", + (), + """ + <link + rel="webmention" + href="/test/17/webmention" + > + + <a + rel="webmention" + href="/test/17/webmention/error" + > + Second endpoint + </a> + """, + "https://example.com/test/17/webmention", + id="17-link-before-a", + ), + pytest.param( + "https://example.com/test/18", + ( + ("Link", '<https://example.com/test/18/webmention/error>; rel="other"'), + ( + "Link", + "<https://example.com/test/18/webmention?head=true>; " + 'rel="webmention"', + ), + ), + "", + "https://example.com/test/18/webmention?head=true", + id="18-multiple-http-link-headers", + ), + pytest.param( + "https://example.com/test/19", + ( + ( + "Link", + "<https://example.com/test/19/webmention/error>; " + 'rel="other", ' + "<https://example.com/test/19/webmention?head=true>; " + 'rel="webmention"', + ), + ), + "", + "https://example.com/test/19/webmention?head=true", + id="19-single-header-multiple-values", + ), + pytest.param( + "https://example.com/test/20", + (), + """ + <link rel="webmention"> + + <a + rel="webmention" + href="/test/20/webmention" + > + Correct endpoint + </a> + """, + "https://example.com/test/20/webmention", + id="20-link-without-href", + ), + pytest.param( + "https://example.com/test/21", + (), + """ + <link + rel="webmention" + href="/test/21/webmention?query=yes" + > + """, + "https://example.com/test/21/webmention?query=yes", + id="21-endpoint-query-string", + ), + pytest.param( + "https://example.com/test/22", + (), + """ + <link + rel="webmention" + href="22/webmention" + > + """, + "https://example.com/test/22/webmention", + id="22-endpoint-relative-to-path", + ), + ), +) +def test_discovery( + app: Flask, + sender_module: ModuleType, + target: str, + headers: tuple[tuple[str, str], ...], + html: str, + expected_endpoint: str, +) -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "HEAD": + return httpx.Response(200, headers=headers) + + return httpx.Response(200, headers={"Content-Type": "text/html"}, text=html) + + with ( + app.app_context(), + httpx.Client( + transport=httpx.MockTransport(handler), follow_redirects=True + ) as client, + ): + endpoint = sender_module.discover_webmention_endpoint(client, target) + + assert endpoint == expected_endpoint + + +@pytest.mark.parametrize( + ("target", "responses", "expected_endpoint"), + ( + pytest.param( + "https://example.com/test/23/page", + { + "/test/23/page": (302, {"Location": "page/redirect-key"}), + "/test/23/page/redirect-key": ( + 200, + {"Link": ("<webmention-endpoint/endpoint-key>; rel=webmention")}, + ), + }, + ("https://example.com/test/23/page/webmention-endpoint/endpoint-key"), + id="relative-endpoint-after-redirect", + ), + ), +) +def test_discovery_redirect( + app: Flask, + sender_module: ModuleType, + target: str, + responses: dict[str, tuple[int, dict[str, str]]], + expected_endpoint: str, +) -> None: + def handler(request: httpx.Request) -> httpx.Response: + try: + status, headers = responses[request.url.path] + except KeyError: + raise AssertionError(f"Unexpected request: {request.url}") from None + + return httpx.Response(status, headers=headers) + + with ( + app.app_context(), + httpx.Client( + transport=httpx.MockTransport(handler), follow_redirects=True + ) as client, + ): + endpoint = sender_module.discover_webmention_endpoint(client, target) + + assert endpoint == expected_endpoint + + +def test_discovery_stops_after_head(app: Flask, sender_module: ModuleType) -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + + return httpx.Response(200, headers={"Link": "</webmention>; rel=webmention"}) + + with ( + app.app_context(), + httpx.Client( + transport=httpx.MockTransport(handler), follow_redirects=True + ) as client, + ): + endpoint = sender_module.discover_webmention_endpoint( + client, "https://example.com/post" + ) + + assert endpoint == "https://example.com/webmention" + assert [request.method for request in requests] == ["HEAD"] + + +def test_discovery_falls_back_to_get(app: Flask, sender_module: ModuleType) -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + + if request.method == "HEAD": + return httpx.Response(200) + + return httpx.Response( + 200, + headers={"Content-Type": "text/html"}, + text=""" + <link + rel="webmention" + href="/webmention" + > + """, + ) + + with ( + app.app_context(), + httpx.Client( + transport=httpx.MockTransport(handler), follow_redirects=True + ) as client, + ): + endpoint = sender_module.discover_webmention_endpoint( + client, "https://example.com/post" + ) + + assert endpoint == "https://example.com/webmention" + assert [request.method for request in requests] == ["HEAD", "GET"] + + +def test_send_webmention_persists_success( + app: Flask, sender_module: ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + identifier = create_sent_webmention(app) + + _, client, discover, post = mock_sender_requests( + sender_module, monkeypatch, result=(201, STATUS_URL) + ) + + sender_module.send_webmention.call_local(identifier) + + discover.assert_called_once_with(client, TARGET_URL) + post.assert_called_once_with( + client, endpoint=ENDPOINT_URL, source=SOURCE_URL, target=TARGET_URL + ) + + with app.app_context(): + webmention = db.session.get(SentWebmention, identifier) + + assert webmention is not None + assert webmention.processed_revision == 1 + assert webmention.sent_revision == 1 + assert webmention.status == SentWebmentionStatus.SENT + assert webmention.failure_reason is None + assert webmention.endpoint == ENDPOINT_URL + assert webmention.response_status == 201 + assert webmention.status_url == STATUS_URL + assert webmention.last_attempted_at is not None + assert webmention.last_sent_at is not None + assert not webmention.pending + + +def test_send_webmention_marks_unsupported_target_processed( + app: Flask, sender_module: ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + identifier = create_sent_webmention( + app, + desired_revision=2, + processed_revision=1, + sent_revision=1, + status=SentWebmentionStatus.SENT, + endpoint="https://old.example/webmention", + response_status=201, + status_url="https://old.example/status/123", + ) + + _, client, discover, post = mock_sender_requests( + sender_module, monkeypatch, endpoint=None + ) + + sender_module.send_webmention.call_local(identifier) + + discover.assert_called_once_with(client, TARGET_URL) + post.assert_not_called() + + with app.app_context(): + webmention = db.session.get(SentWebmention, identifier) + + assert webmention is not None + assert webmention.processed_revision == 2 + assert webmention.sent_revision == 1 + assert webmention.status == SentWebmentionStatus.UNSUPPORTED + assert webmention.failure_reason == "No Webmention endpoint discovered" + assert webmention.endpoint is None + assert webmention.response_status is None + assert webmention.status_url is None + assert webmention.last_attempted_at is not None + assert not webmention.pending + + +def test_send_webmention_persists_permanent_failure( + app: Flask, sender_module: ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + identifier = create_sent_webmention( + app, + desired_revision=2, + processed_revision=1, + sent_revision=1, + status=SentWebmentionStatus.SENT, + status_url="https://old.example/status/123", + ) + + mock_sender_requests(sender_module, monkeypatch, result=(400, None)) + + sender_module.send_webmention.call_local(identifier) + + with app.app_context(): + webmention = db.session.get(SentWebmention, identifier) + + assert webmention is not None + assert webmention.processed_revision == 2 + assert webmention.sent_revision == 1 + assert webmention.status == SentWebmentionStatus.FAILED + assert webmention.failure_reason == "Webmention endpoint returned HTTP 400" + assert webmention.endpoint == ENDPOINT_URL + assert webmention.response_status == 400 + assert webmention.status_url is None + assert not webmention.pending + + +def test_send_webmention_persists_temporary_failure_and_reraises( + app: Flask, sender_module: ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + identifier = create_sent_webmention( + app, + desired_revision=2, + processed_revision=1, + sent_revision=1, + status=SentWebmentionStatus.SENT, + status_url="https://old.example/status/123", + ) + + mock_sender_requests(sender_module, monkeypatch, result=(503, None)) + + with pytest.raises( + sender_module.TemporarySenderError, + match="Webmention endpoint returned HTTP 503", + ): + sender_module.send_webmention.call_local(identifier) + + with app.app_context(): + webmention = db.session.get(SentWebmention, identifier) + + assert webmention is not None + assert webmention.processed_revision == 1 + assert webmention.sent_revision == 1 + assert webmention.status == SentWebmentionStatus.FAILED + assert webmention.failure_reason == "Webmention endpoint returned HTTP 503" + assert webmention.endpoint == ENDPOINT_URL + assert webmention.response_status == 503 + assert webmention.status_url is None + assert webmention.pending + + +def test_send_webmention_ignores_processed_revision( + app: Flask, sender_module: ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + identifier = create_sent_webmention( + app, + desired_revision=2, + processed_revision=2, + sent_revision=2, + status=SentWebmentionStatus.SENT, + ) + + httpx_client, _, discover, post = mock_sender_requests(sender_module, monkeypatch) + + sender_module.send_webmention.call_local(identifier) + + httpx_client.assert_not_called() + discover.assert_not_called() + post.assert_not_called() + + +def test_send_webmention_ignores_result_for_superseded_revision( + app: Flask, sender_module: ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + identifier = create_sent_webmention(app) + + _, _, _, post = mock_sender_requests(sender_module, monkeypatch) + + def supersede_revision(*args, **kwargs) -> tuple[int, None]: + webmention = db.session.get(SentWebmention, identifier) + + assert webmention is not None + + webmention.desired_revision = 2 + db.session.commit() + + return (202, None) + + post.side_effect = supersede_revision + + sender_module.send_webmention.call_local(identifier) + + with app.app_context(): + webmention = db.session.get(SentWebmention, identifier) + + assert webmention is not None + assert webmention.desired_revision == 2 + assert webmention.processed_revision is None + assert webmention.sent_revision is None + assert webmention.status is None + assert webmention.pending + + +def test_send_webmention_ignores_unknown_identifier( + sender_module: ModuleType, caplog: pytest.LogCaptureFixture +) -> None: + identifier = uuid.uuid7() + + with caplog.at_level(logging.WARNING): + sender_module.send_webmention.call_local(identifier) + + assert f"Cannot send unknown SentWebmention {identifier}" in caplog.text |
