diff options
Diffstat (limited to 'tests/tasks')
| -rw-r--r-- | tests/tasks/test_receiver.py | 625 |
1 files changed, 241 insertions, 384 deletions
diff --git a/tests/tasks/test_receiver.py b/tests/tasks/test_receiver.py index 4545121..19630c3 100644 --- a/tests/tasks/test_receiver.py +++ b/tests/tasks/test_receiver.py @@ -1,7 +1,7 @@ import logging import uuid -from collections.abc import Callable from types import ModuleType +from unittest.mock import Mock, patch import httpx import pytest @@ -9,15 +9,45 @@ from flask import Flask from webmentions_ssg import DATABASE as db from webmentions_ssg.models import ReceivedWebmention +from webmentions_ssg.url_security import ( + AddressResolutionError, + NonPublicAddressError, +) SOURCE_URL = "https://source.example/article" TARGET_URL = "https://dennisfink.me/blog/example/" -ReceivedWebmentionFactory = Callable[..., uuid.UUID] -HTTPXMockInstaller = Callable[ - [Callable[[httpx.Request], httpx.Response]], - None, -] + +@pytest.fixture +def receiver(app: Flask) -> ModuleType: + from webmentions_ssg.tasks import receiver + + return receiver + + +def create_webmention( + app: Flask, + *, + source: str = SOURCE_URL, + target: str = TARGET_URL, + status: str = "received", + failure_reason: str | None = None, +) -> uuid.UUID: + identifier = uuid.uuid7() + + with app.app_context(): + webmention = ReceivedWebmention( + uuid=identifier, + source=source, + target=target, + status=status, + failure_reason=failure_reason, + ) + + db.session.add(webmention) + db.session.commit() + + return identifier def get_webmention_state( @@ -38,257 +68,200 @@ def get_webmention_state( ) +def set_stream_response( + httpx_client: Mock, + response: httpx.Response, +) -> Mock: + client = httpx_client.return_value.__enter__.return_value + client.stream.return_value.__enter__.return_value = response + return client + + @pytest.mark.parametrize( - ( - "body", - "source_url", - "target_url", - "expected", - ), + ("body", "expected"), [ pytest.param( f'<a href="{TARGET_URL}">Reply</a>', - SOURCE_URL, - TARGET_URL, True, id="a-href", ), pytest.param( f'<area href="{TARGET_URL}" alt="Target">', - SOURCE_URL, - TARGET_URL, True, id="area-href", ), pytest.param( f'<link href="{TARGET_URL}" rel="alternate">', - SOURCE_URL, - TARGET_URL, True, id="link-href", ), pytest.param( f'<img src="{TARGET_URL}" alt="">', - SOURCE_URL, - TARGET_URL, True, id="img-src", ), pytest.param( f'<audio src="{TARGET_URL}"></audio>', - SOURCE_URL, - TARGET_URL, True, id="audio-src", ), pytest.param( f'<video src="{TARGET_URL}"></video>', - SOURCE_URL, - TARGET_URL, True, id="video-src", ), pytest.param( - (f'<audio><source src="{TARGET_URL}"></audio>'), - SOURCE_URL, - TARGET_URL, + f'<audio><source src="{TARGET_URL}"></audio>', True, id="audio-source-src", ), pytest.param( - (f'<video><source src="{TARGET_URL}"></video>'), - SOURCE_URL, - TARGET_URL, + f'<video><source src="{TARGET_URL}"></video>', True, id="video-source-src", ), pytest.param( f'<iframe src="{TARGET_URL}"></iframe>', - SOURCE_URL, - TARGET_URL, True, id="iframe-src", ), pytest.param( f'<embed src="{TARGET_URL}">', - SOURCE_URL, - TARGET_URL, True, id="embed-src", ), pytest.param( f'<script src="{TARGET_URL}"></script>', - SOURCE_URL, - TARGET_URL, True, id="script-src", ), pytest.param( - (f'<video><track src="{TARGET_URL}"></video>'), - SOURCE_URL, - TARGET_URL, + f'<video><track src="{TARGET_URL}"></video>', True, id="track-src", ), pytest.param( f'<input type="image" src="{TARGET_URL}" alt="">', - SOURCE_URL, - TARGET_URL, True, id="image-input-src", ), pytest.param( f'<input type="IMAGE" src="{TARGET_URL}" alt="">', - SOURCE_URL, - TARGET_URL, True, id="image-input-case-insensitive", ), pytest.param( f'<blockquote cite="{TARGET_URL}">Quotation</blockquote>', - SOURCE_URL, - TARGET_URL, True, id="blockquote-cite", ), pytest.param( f'<q cite="{TARGET_URL}">Quotation</q>', - SOURCE_URL, - TARGET_URL, True, id="q-cite", ), pytest.param( f'<ins cite="{TARGET_URL}">Addition</ins>', - SOURCE_URL, - TARGET_URL, True, id="ins-cite", ), pytest.param( f'<del cite="{TARGET_URL}">Removal</del>', - SOURCE_URL, - TARGET_URL, True, id="del-cite", ), pytest.param( - '<a href="../target/">Reply</a>', - "https://source.example/posts/article/", - "https://source.example/posts/target/", - True, - id="relative-href", - ), - pytest.param( '<base href="https://dennisfink.me/blog/"><a href="example/">Reply</a>', - SOURCE_URL, - TARGET_URL, True, id="base-url", ), pytest.param( f'<img cite="{TARGET_URL}" alt="">', - SOURCE_URL, - TARGET_URL, False, id="img-cite-invalid", ), pytest.param( f'<blockquote src="{TARGET_URL}">Quote</blockquote>', - SOURCE_URL, - TARGET_URL, False, id="blockquote-src-invalid", ), pytest.param( f'<a src="{TARGET_URL}">Reply</a>', - SOURCE_URL, - TARGET_URL, False, id="a-src-invalid", ), pytest.param( f'<div href="{TARGET_URL}"></div>', - SOURCE_URL, - TARGET_URL, False, id="div-href-invalid", ), pytest.param( f'<link src="{TARGET_URL}">', - SOURCE_URL, - TARGET_URL, False, id="link-src-invalid", ), pytest.param( f'<input type="text" src="{TARGET_URL}">', - SOURCE_URL, - TARGET_URL, False, id="text-input-src-invalid", ), pytest.param( - (f'<picture><source src="{TARGET_URL}"></picture>'), - SOURCE_URL, - TARGET_URL, + f'<picture><source src="{TARGET_URL}"></picture>', False, id="picture-source-src-invalid", ), pytest.param( f'<base href="{TARGET_URL}">', - SOURCE_URL, - TARGET_URL, False, id="base-is-not-mention", ), pytest.param( - (f'<a href="{TARGET_URL}more">Different page</a>'), - SOURCE_URL, - TARGET_URL, + f'<a href="{TARGET_URL}more">Different page</a>', False, id="longer-url", ), pytest.param( - (f'<img cite="{TARGET_URL}" src="https://example.com/image.jpg" alt="">'), - SOURCE_URL, - TARGET_URL, + f'<img cite="{TARGET_URL}" src="https://example.com/image.jpg" alt="">', False, id="invalid-cite-does-not-override-valid-src", ), pytest.param( f"<p>{TARGET_URL}</p>", - SOURCE_URL, - TARGET_URL, False, id="text-content", ), pytest.param( '<a href="https://example.com/">Other site</a>', - SOURCE_URL, - TARGET_URL, False, id="missing-target", ), ], ) def test_html_mentions_target( - receiver_module: ModuleType, + receiver: ModuleType, body: str, - source_url: str, - target_url: str, expected: bool, ) -> None: assert ( - receiver_module.html_mentions_target( + receiver.html_mentions_target( body.encode(), - source_url, - target_url, + SOURCE_URL, + TARGET_URL, ) is expected ) +def test_html_mentions_relative_target( + receiver: ModuleType, +) -> None: + assert receiver.html_mentions_target( + b'<a href="../target/">Reply</a>', + "https://source.example/posts/article/", + "https://source.example/posts/target/", + ) + + @pytest.mark.parametrize( ("body", "target_url", "expected"), [ @@ -306,63 +279,97 @@ def test_html_mentions_target( ], ) def test_text_mentions_target( - receiver_module: ModuleType, + receiver: ModuleType, body: str, target_url: str, expected: bool, ) -> None: - assert ( - receiver_module.text_mentions_target( - body, - target_url, - ) - is expected - ) + assert receiver.text_mentions_target(body, target_url) is expected -def test_fetch_source_returns_response_and_body( - app: Flask, - receiver_module: ModuleType, - install_httpx_mock: HTTPXMockInstaller, +@patch("webmentions_ssg.tasks.receiver.ensure_public_url") +def test_ensure_public_request_accepts_public_url( + ensure_public_url: Mock, + receiver: ModuleType, ) -> None: - captured_request: httpx.Request | None = None + receiver.ensure_public_request(httpx.Request("GET", SOURCE_URL)) - def handler( - request: httpx.Request, - ) -> httpx.Response: - nonlocal captured_request - captured_request = request + ensure_public_url.assert_called_once_with(SOURCE_URL) - return httpx.Response( - 200, - headers={ - "Content-Type": "text/html", - }, - content=b"<p>Document</p>", - ) - install_httpx_mock(handler) +@patch( + "webmentions_ssg.tasks.receiver.ensure_public_url", + side_effect=NonPublicAddressError("Non-public address"), +) +def test_ensure_public_request_rejects_non_public_address( + ensure_public_url: Mock, + receiver: ModuleType, +) -> None: + with pytest.raises( + receiver.VerificationError, + match="Source resolves to a non-public address", + ): + receiver.ensure_public_request(httpx.Request("GET", SOURCE_URL)) - with app.app_context(): - response, body = receiver_module.fetch_source(SOURCE_URL) + ensure_public_url.assert_called_once_with(SOURCE_URL) - assert response.status_code == 200 - assert body == b"<p>Document</p>" - assert captured_request is not None - assert captured_request.url == SOURCE_URL +@patch( + "webmentions_ssg.tasks.receiver.ensure_public_url", + side_effect=AddressResolutionError("Could not resolve hostname"), +) +def test_ensure_public_request_maps_dns_failure_to_temporary_error( + ensure_public_url: Mock, + receiver: ModuleType, +) -> None: + with pytest.raises( + receiver.TemporaryFetchError, + match="Source hostname could not be resolved", + ): + receiver.ensure_public_request(httpx.Request("GET", SOURCE_URL)) + + ensure_public_url.assert_called_once_with(SOURCE_URL) + + +@patch("webmentions_ssg.tasks.receiver.httpx.Client") +def test_fetch_source_returns_response_and_body( + httpx_client: Mock, + app: Flask, + receiver: ModuleType, +) -> None: + response = httpx.Response( + 200, + headers={"Content-Type": "text/html"}, + content=b"<p>Document</p>", + request=httpx.Request("GET", SOURCE_URL), + ) + client = set_stream_response(httpx_client, response) + + with app.app_context(): + fetched_response, body = receiver.fetch_source(SOURCE_URL) - accept = captured_request.headers["Accept"] + assert fetched_response is response + assert body == b"<p>Document</p>" + client.stream.assert_called_once_with("GET", SOURCE_URL) - assert "text/html" in accept - assert "application/xhtml+xml" in accept - assert "text/plain" in accept + options = httpx_client.call_args.kwargs - assert captured_request.headers["User-Agent"] == ( - f"{receiver_module.APP_NAME}/{receiver_module.VERSION} ReceivedWebmention" + assert options["headers"] == { + "Accept": "text/html, application/xhtml+xml;q=0.9, text/plain;q=0.8", + "User-Agent": f"{receiver.APP_NAME}/{receiver.VERSION} ReceivedWebmention", + } + assert options["follow_redirects"] is True + assert options["max_redirects"] == app.config.get( + "WEBMENTIONS_SSG_MAX_REDIRECTS", + 20, ) + assert options["trust_env"] is False + assert options["event_hooks"] == { + "request": [receiver.ensure_public_request], + } +@patch("webmentions_ssg.tasks.receiver.httpx.Client") @pytest.mark.parametrize( ("status_code", "exception_name"), [ @@ -377,19 +384,22 @@ def test_fetch_source_returns_response_and_body( ], ) def test_fetch_source_maps_http_status_to_exception( + httpx_client: Mock, app: Flask, - receiver_module: ModuleType, - install_httpx_mock: HTTPXMockInstaller, + receiver: ModuleType, status_code: int, exception_name: str, ) -> None: - install_httpx_mock(lambda request: httpx.Response(status_code)) - - exception_type = getattr( - receiver_module, - exception_name, + set_stream_response( + httpx_client, + httpx.Response( + status_code, + request=httpx.Request("GET", SOURCE_URL), + ), ) + exception_type = getattr(receiver, exception_name) + with ( app.app_context(), pytest.raises( @@ -397,23 +407,20 @@ def test_fetch_source_maps_http_status_to_exception( match=f"HTTP {status_code}", ), ): - receiver_module.fetch_source(SOURCE_URL) + receiver.fetch_source(SOURCE_URL) +@patch("webmentions_ssg.tasks.receiver.httpx.Client") def test_fetch_source_propagates_network_error( + httpx_client: Mock, app: Flask, - receiver_module: ModuleType, - install_httpx_mock: HTTPXMockInstaller, + receiver: ModuleType, ) -> None: - def handler( - request: httpx.Request, - ) -> httpx.Response: - raise httpx.ConnectError( - "Connection refused", - request=request, - ) - - install_httpx_mock(handler) + client = httpx_client.return_value.__enter__.return_value + client.stream.side_effect = httpx.ConnectError( + "Connection refused", + request=httpx.Request("GET", SOURCE_URL), + ) with ( app.app_context(), @@ -422,153 +429,74 @@ def test_fetch_source_propagates_network_error( match="Connection refused", ), ): - receiver_module.fetch_source(SOURCE_URL) - - -def test_fetch_source_follows_redirect( - app: Flask, - receiver_module: ModuleType, - install_httpx_mock: HTTPXMockInstaller, -) -> None: - requested_paths: list[str] = [] - - def handler( - request: httpx.Request, - ) -> httpx.Response: - requested_paths.append(request.url.path) - - match request.url.path: - case "/start": - return httpx.Response( - 302, - headers={ - "Location": "/final", - }, - ) - - case "/final": - return httpx.Response( - 200, - headers={ - "Content-Type": "text/html", - }, - content=b"Final document", - ) - - case _: - raise AssertionError(f"Unexpected URL: {request.url}") - - install_httpx_mock(handler) - - with app.app_context(): - response, body = receiver_module.fetch_source("https://source.example/start") - - assert requested_paths == [ - "/start", - "/final", - ] - assert response.url.path == "/final" - assert body == b"Final document" - - -def test_fetch_source_enforces_redirect_limit( - app: Flask, - receiver_module: ModuleType, - install_httpx_mock: HTTPXMockInstaller, - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setitem( - app.config, - "WEBMENTIONS_SSG_MAX_REDIRECTS", - 1, - ) - - install_httpx_mock( - lambda request: httpx.Response( - 302, - headers={ - "Location": "/another", - }, - ) - ) - - with ( - app.app_context(), - pytest.raises(httpx.TooManyRedirects), - ): - receiver_module.fetch_source("https://source.example/start") + receiver.fetch_source(SOURCE_URL) +@patch("webmentions_ssg.tasks.receiver.httpx.Client") def test_fetch_source_rejects_declared_oversized_body( + httpx_client: Mock, app: Flask, - receiver_module: ModuleType, - install_httpx_mock: HTTPXMockInstaller, - monkeypatch: pytest.MonkeyPatch, + receiver: ModuleType, ) -> None: - monkeypatch.setitem( - app.config, - "WEBMENTIONS_SSG_MAX_SOURCE_BYTES", - 10, - ) + app.config["WEBMENTIONS_SSG_MAX_SOURCE_BYTES"] = 10 - install_httpx_mock( - lambda request: httpx.Response( + set_stream_response( + httpx_client, + httpx.Response( 200, headers={ "Content-Type": "text/html", "Content-Length": "11", }, content=b"x" * 11, - ) + request=httpx.Request("GET", SOURCE_URL), + ), ) with ( app.app_context(), pytest.raises( - receiver_module.VerificationError, + receiver.VerificationError, match="Source document is too large", ), ): - receiver_module.fetch_source(SOURCE_URL) + receiver.fetch_source(SOURCE_URL) +@patch("webmentions_ssg.tasks.receiver.httpx.Client") def test_fetch_source_rejects_streamed_oversized_body( + httpx_client: Mock, app: Flask, - receiver_module: ModuleType, - install_httpx_mock: HTTPXMockInstaller, - monkeypatch: pytest.MonkeyPatch, + receiver: ModuleType, ) -> None: - monkeypatch.setitem( - app.config, - "WEBMENTIONS_SSG_MAX_SOURCE_BYTES", - 10, - ) + app.config["WEBMENTIONS_SSG_MAX_SOURCE_BYTES"] = 10 class BodyStream(httpx.SyncByteStream): def __iter__(self): yield b"x" * 6 yield b"x" * 6 - install_httpx_mock( - lambda request: httpx.Response( + set_stream_response( + httpx_client, + httpx.Response( 200, - headers={ - "Content-Type": "text/html", - }, + headers={"Content-Type": "text/html"}, stream=BodyStream(), - ) + request=httpx.Request("GET", SOURCE_URL), + ), ) with ( app.app_context(), pytest.raises( - receiver_module.VerificationError, + receiver.VerificationError, match="Source document is too large", ), ): - receiver_module.fetch_source(SOURCE_URL) + receiver.fetch_source(SOURCE_URL) +@patch("webmentions_ssg.tasks.receiver.fetch_source") @pytest.mark.parametrize( ("content_type", "body", "expected"), [ @@ -617,42 +545,25 @@ def test_fetch_source_rejects_streamed_oversized_body( ], ) def test_source_mentions_target_by_media_type( - receiver_module: ModuleType, - monkeypatch: pytest.MonkeyPatch, + fetch_source: Mock, + receiver: ModuleType, content_type: str, body: bytes, expected: bool, ) -> None: response = httpx.Response( 200, - headers={ - "Content-Type": content_type, - }, + headers={"Content-Type": content_type}, content=body, - request=httpx.Request( - "GET", - SOURCE_URL, - ), - ) - - monkeypatch.setattr( - receiver_module, - "fetch_source", - lambda source_url: ( - response, - body, - ), + request=httpx.Request("GET", SOURCE_URL), ) + fetch_source.return_value = response, body - assert ( - receiver_module.source_mentions_target( - SOURCE_URL, - TARGET_URL, - ) - is expected - ) + assert receiver.source_mentions_target(SOURCE_URL, TARGET_URL) is expected + fetch_source.assert_called_once_with(SOURCE_URL) +@patch("webmentions_ssg.tasks.receiver.fetch_source") @pytest.mark.parametrize( ("content_type", "expected_media_type"), [ @@ -662,8 +573,8 @@ def test_source_mentions_target_by_media_type( ], ) def test_source_mentions_target_rejects_unsupported_media_type( - receiver_module: ModuleType, - monkeypatch: pytest.MonkeyPatch, + fetch_source: Mock, + receiver: ModuleType, content_type: str, expected_media_type: str, ) -> None: @@ -676,38 +587,25 @@ def test_source_mentions_target_rejects_unsupported_media_type( 200, headers=headers, content=b"Document", - request=httpx.Request( - "GET", - SOURCE_URL, - ), - ) - - monkeypatch.setattr( - receiver_module, - "fetch_source", - lambda source_url: ( - response, - b"Document", - ), + request=httpx.Request("GET", SOURCE_URL), ) + fetch_source.return_value = response, b"Document" with pytest.raises( - receiver_module.VerificationError, - match=(f"Unsupported source content type: {expected_media_type}"), + receiver.VerificationError, + match=f"Unsupported source content type: {expected_media_type}", ): - receiver_module.source_mentions_target( - SOURCE_URL, - TARGET_URL, - ) + receiver.source_mentions_target(SOURCE_URL, TARGET_URL) +@patch("webmentions_ssg.tasks.receiver.source_mentions_target") def test_verify_webmention_marks_row_verifying_before_check( + source_mentions_target: Mock, app: Flask, - receiver_module: ModuleType, - make_webmention: ReceivedWebmentionFactory, - monkeypatch: pytest.MonkeyPatch, + receiver: ModuleType, ) -> None: - identifier = make_webmention( + identifier = create_webmention( + app, status="failed", failure_reason="Earlier failure", ) @@ -729,13 +627,9 @@ def test_verify_webmention_marks_row_verifying_before_check( return True - monkeypatch.setattr( - receiver_module, - "source_mentions_target", - verify_source, - ) + source_mentions_target.side_effect = verify_source - receiver_module.verify_webmention.call_local(identifier) + receiver.verify_webmention.call_local(identifier) assert get_webmention_state( app, @@ -746,6 +640,7 @@ def test_verify_webmention_marks_row_verifying_before_check( ) +@patch("webmentions_ssg.tasks.receiver.source_mentions_target") @pytest.mark.parametrize( ("outcome", "expected_status", "expected_reason"), [ @@ -756,45 +651,32 @@ def test_verify_webmention_marks_row_verifying_before_check( ], ) def test_verify_webmention_persists_final_state( + source_mentions_target: Mock, app: Flask, - receiver_module: ModuleType, - make_webmention: ReceivedWebmentionFactory, - monkeypatch: pytest.MonkeyPatch, + receiver: ModuleType, outcome: str, expected_status: str, expected_reason: str | None, ) -> None: - identifier = make_webmention( - status="received", - ) - - def verify_source( - source_url: str, - target_url: str, - ) -> bool: - match outcome: - case "verified": - return True + identifier = create_webmention(app) - case "missing": - return False - - case "gone": - raise receiver_module.SourceGoneError("Source returned HTTP 410") - - case "permanent-failure": - raise receiver_module.VerificationError("Source returned HTTP 404") - - case _: - raise AssertionError(f"Unexpected outcome: {outcome}") - - monkeypatch.setattr( - receiver_module, - "source_mentions_target", - verify_source, - ) + match outcome: + case "verified": + source_mentions_target.return_value = True + case "missing": + source_mentions_target.return_value = False + case "gone": + source_mentions_target.side_effect = receiver.SourceGoneError( + "Source returned HTTP 410" + ) + case "permanent-failure": + source_mentions_target.side_effect = receiver.VerificationError( + "Source returned HTTP 404" + ) + case _: + raise AssertionError(f"Unexpected outcome: {outcome}") - receiver_module.verify_webmention.call_local(identifier) + receiver.verify_webmention.call_local(identifier) assert get_webmention_state( app, @@ -805,6 +687,7 @@ def test_verify_webmention_persists_final_state( ) +@patch("webmentions_ssg.tasks.receiver.source_mentions_target") @pytest.mark.parametrize( "failure", [ @@ -813,74 +696,48 @@ def test_verify_webmention_persists_final_state( ], ) def test_verify_webmention_persists_retryable_failure_and_reraises( + source_mentions_target: Mock, app: Flask, - receiver_module: ModuleType, - make_webmention: ReceivedWebmentionFactory, - monkeypatch: pytest.MonkeyPatch, + receiver: ModuleType, failure: str, ) -> None: - identifier = make_webmention() - - def verify_source( - source_url: str, - target_url: str, - ) -> bool: - match failure: - case "temporary-http": - raise receiver_module.TemporaryFetchError("Source returned HTTP 503") - - case "network": - raise httpx.ConnectError( - "Connection refused", - request=httpx.Request( - "GET", - source_url, - ), - ) - - case _: - raise AssertionError(f"Unexpected failure: {failure}") - - monkeypatch.setattr( - receiver_module, - "source_mentions_target", - verify_source, - ) + identifier = create_webmention(app) match failure: case "temporary-http": - expected_exception = receiver_module.TemporaryFetchError - expected_reason = "Source returned HTTP 503" - + exception = receiver.TemporaryFetchError("Source returned HTTP 503") case "network": - expected_exception = httpx.ConnectError - expected_reason = "Connection refused" - + exception = httpx.ConnectError( + "Connection refused", + request=httpx.Request("GET", SOURCE_URL), + ) case _: raise AssertionError(f"Unexpected failure: {failure}") + source_mentions_target.side_effect = exception + with pytest.raises( - expected_exception, - match=expected_reason, + type(exception), + match=str(exception), ): - receiver_module.verify_webmention.call_local(identifier) + receiver.verify_webmention.call_local(identifier) assert get_webmention_state( app, identifier, ) == ( "failed", - expected_reason, + str(exception), ) def test_verify_webmention_ignores_unknown_identifier( - receiver_module: ModuleType, + receiver: ModuleType, caplog: pytest.LogCaptureFixture, ) -> None: identifier = uuid.uuid7() with caplog.at_level(logging.WARNING): - receiver_module.verify_webmention.call_local(identifier) + receiver.verify_webmention.call_local(identifier) assert f"Cannot verify unknown ReceivedWebmention {identifier}" in caplog.text |
