- {{ post.safe_content_local }}
+ {% include "activities/_preview_card.html" %}
+
+ {% if post.type == "Question" %}
+ {% include "activities/_type_question.html" with sanitized_content=post.safe_content_note_local interactive=True %}
+ {% else %}
+ {{ post.safe_content_local }}
+ {% endif %}
{% if post.attachments.exists %}
diff --git a/templates/activities/_preview_card.html b/templates/activities/_preview_card.html
new file mode 100644
index 00000000..8b19b446
--- /dev/null
+++ b/templates/activities/_preview_card.html
@@ -0,0 +1,22 @@
+{% with card=post.converted_preview_card %}
+ {% if card %}
+
+ {% if card.image_url %}
+
+ {% endif %}
+
+ {{ card.title|default:post.type }}
+ {% if card.description %}
+ {{ card.description }}
+ {% endif %}
+
+ {{ post.type }}
+ {% if card.provider_name %}· {{ card.provider_name }}{% endif %}
+
+
+
+ {% endif %}
+{% endwith %}
diff --git a/templates/activities/_type_article.html b/templates/activities/_type_article.html
new file mode 100644
index 00000000..22c9187e
--- /dev/null
+++ b/templates/activities/_type_article.html
@@ -0,0 +1,32 @@
+{% load activity_tags %}
+
+{% if post.type_data and post.type_data.object %}
+ {% if post.type_data.object.name and not post.in_reply_to %}
+
+ {% endif %}
+ {% if local_display and post.article_cover_url %}
+
+
+
+ {% endif %}
+{% endif %}
+
+
+ {{ sanitized_content }}
+
+
+{% if post.type_data and post.type_data.object %}
+ {% with tags=post.type_data.object.tag %}
+ {% if tags %}
+
+ {% for t in tags %}
+ {% if t.type == 'Hashtag' and t.name %}
+
{% if t.href %}{{ t.name }} {% else %}{{ t.name }}{% endif %}
+ {% endif %}
+ {% endfor %}
+
+ {% endif %}
+ {% endwith %}
+{% endif %}
diff --git a/templates/activities/_type_question.html b/templates/activities/_type_question.html
index 111c24e0..f724e05b 100644
--- a/templates/activities/_type_question.html
+++ b/templates/activities/_type_question.html
@@ -1,24 +1,79 @@
{% load activity_tags %}
-
{{ sanitized_content }}
-
+{% poll_vote_context post as vote %}
+{# vote is None when the post has no usable poll data; render the body only #}
+{% if vote %}
-
Options: {% if post.type_data.mode == "oneOf" %}(choose one) {% endif %}
- {% for item in post.type_data.options %}
- {% if forloop.first %}
+{% endif %}
diff --git a/tests/activities/models/test_announced_activities.py b/tests/activities/models/test_announced_activities.py
new file mode 100644
index 00000000..09f39497
--- /dev/null
+++ b/tests/activities/models/test_announced_activities.py
@@ -0,0 +1,309 @@
+import pytest
+from pytest_httpx import HTTPXMock
+
+from activities.models import Post, PostInteraction, PostInteractionStates
+from users.models import InboxMessage
+from users.models.inbox_message import InboxMessageStates
+
+GROUP_URI = "https://lemmy.test/c/books"
+AUTHOR_URI = "https://lemmy.test/u/alice"
+PAGE_URI = "https://lemmy.test/post/1"
+COMMENT_URI = "https://lemmy.test/comment/1"
+
+
+def _mock_author(httpx_mock: HTTPXMock):
+ httpx_mock.add_response(
+ url=AUTHOR_URI,
+ headers={"Content-Type": "application/activity+json"},
+ json={
+ "@context": ["https://www.w3.org/ns/activitystreams"],
+ "id": AUTHOR_URI,
+ "type": "Person",
+ "preferredUsername": "alice",
+ },
+ )
+
+
+def _page_json(content="
The real body from origin
", name="Book thread"):
+ return {
+ "@context": ["https://www.w3.org/ns/activitystreams"],
+ "id": PAGE_URI,
+ "type": "Page",
+ "attributedTo": AUTHOR_URI,
+ "to": ["https://www.w3.org/ns/activitystreams#Public"],
+ "audience": GROUP_URI,
+ "name": name,
+ "content": content,
+ "published": "2026-07-01T10:00:00Z",
+ }
+
+
+def _comment_json():
+ return {
+ "@context": ["https://www.w3.org/ns/activitystreams"],
+ "id": COMMENT_URI,
+ "type": "Note",
+ "attributedTo": AUTHOR_URI,
+ "to": ["https://www.w3.org/ns/activitystreams#Public"],
+ "inReplyTo": PAGE_URI,
+ "content": "
A comment
",
+ "published": "2026-07-01T11:00:00Z",
+ }
+
+
+def _announce(inner, announce_id="https://lemmy.test/activities/announce/1"):
+ return {
+ "id": announce_id,
+ "type": "Announce",
+ "actor": GROUP_URI,
+ "to": ["https://www.w3.org/ns/activitystreams#Public"],
+ "object": inner,
+ }
+
+
+def _create(obj):
+ return {
+ "id": "https://lemmy.test/activities/create/1",
+ "type": "Create",
+ "actor": AUTHOR_URI,
+ "object": obj,
+ }
+
+
+@pytest.mark.django_db
+@pytest.mark.httpx_mock(
+ assert_all_requests_were_expected=False, can_send_already_matched_responses=True
+)
+def test_announced_create_ingests_from_origin_and_boosts(
+ httpx_mock: HTTPXMock, config_system
+):
+ """
+ Announce(Create(Page)) from a group actor must ingest the page from its
+ origin server (never the embedded copy) and boost it as the group.
+ """
+ _mock_author(httpx_mock)
+ httpx_mock.add_response(
+ url=PAGE_URI,
+ headers={"Content-Type": "application/activity+json"},
+ json=_page_json(),
+ )
+ embedded = _page_json(content="
EMBEDDED FORGERY
")
+ Post.handle_announced_activity_ap(_announce(_create(embedded)))
+
+ post = Post.objects.get(object_uri=PAGE_URI)
+ assert "The real body from origin" in post.content
+ assert "EMBEDDED FORGERY" not in post.content
+ # Page title is preserved in the converted status content
+ assert "Book thread" in post.content
+ boost = PostInteraction.objects.get(post=post, type=PostInteraction.Types.boost)
+ assert boost.identity.actor_uri == GROUP_URI
+
+
+@pytest.mark.django_db
+@pytest.mark.httpx_mock(
+ assert_all_requests_were_expected=False, can_send_already_matched_responses=True
+)
+def test_double_announce_creates_single_boost(httpx_mock: HTTPXMock, config_system):
+ """
+ Lemmy announces each post twice (Announce(Create(Page)) plus a bare
+ Announce(Page) for Mastodon compatibility); only one boost may result.
+ """
+ _mock_author(httpx_mock)
+ httpx_mock.add_response(
+ url=PAGE_URI,
+ headers={"Content-Type": "application/activity+json"},
+ json=_page_json(),
+ )
+ Post.handle_announced_activity_ap(
+ _announce(_create(_page_json()), "https://lemmy.test/activities/announce/1")
+ )
+ # The compat bare announce takes the regular boost path
+ PostInteraction.handle_ap(
+ _announce(PAGE_URI, "https://lemmy.test/activities/announce/2")
+ )
+
+ post = Post.objects.get(object_uri=PAGE_URI)
+ assert (
+ PostInteraction.objects.filter(
+ post=post,
+ type=PostInteraction.Types.boost,
+ state__in=PostInteractionStates.group_active(),
+ ).count()
+ == 1
+ )
+
+
+@pytest.mark.django_db
+@pytest.mark.httpx_mock(
+ assert_all_requests_were_expected=False, can_send_already_matched_responses=True
+)
+def test_announced_comment_ingests_without_boost(httpx_mock: HTTPXMock, config_system):
+ """
+ Announced replies (Lemmy comments) are ingested so they thread under
+ their parent, but must not be boosted onto follower timelines.
+ """
+ _mock_author(httpx_mock)
+ httpx_mock.add_response(
+ url=PAGE_URI,
+ headers={"Content-Type": "application/activity+json"},
+ json=_page_json(),
+ )
+ httpx_mock.add_response(
+ url=COMMENT_URI,
+ headers={"Content-Type": "application/activity+json"},
+ json=_comment_json(),
+ )
+ Post.handle_announced_activity_ap(
+ _announce(_create(_page_json()), "https://lemmy.test/activities/announce/1")
+ )
+ Post.handle_announced_activity_ap(
+ _announce(_create(_comment_json()), "https://lemmy.test/activities/announce/2")
+ )
+
+ comment = Post.objects.get(object_uri=COMMENT_URI)
+ assert comment.in_reply_to == PAGE_URI
+ assert not PostInteraction.objects.filter(
+ post=comment, type=PostInteraction.Types.boost
+ ).exists()
+
+
+@pytest.mark.django_db
+@pytest.mark.httpx_mock(
+ assert_all_requests_were_expected=False, can_send_already_matched_responses=True
+)
+def test_announced_update_refreshes_from_origin(httpx_mock: HTTPXMock, config_system):
+ _mock_author(httpx_mock)
+ httpx_mock.add_response(
+ url=PAGE_URI,
+ headers={"Content-Type": "application/activity+json"},
+ json=_page_json(),
+ )
+ Post.handle_announced_activity_ap(_announce(_create(_page_json())))
+ assert "The real body from origin" in Post.objects.get(object_uri=PAGE_URI).content
+
+ httpx_mock.add_response(
+ url=PAGE_URI,
+ headers={"Content-Type": "application/activity+json"},
+ json=_page_json(content="
Edited on origin
"),
+ )
+ Post.handle_announced_activity_ap(
+ _announce(
+ {
+ "id": "https://lemmy.test/activities/update/1",
+ "type": "Update",
+ "actor": AUTHOR_URI,
+ "object": _page_json(content="
EMBEDDED EDIT FORGERY
"),
+ },
+ "https://lemmy.test/activities/announce/3",
+ )
+ )
+ post = Post.objects.get(object_uri=PAGE_URI)
+ assert "Edited on origin" in post.content
+ assert "FORGERY" not in post.content
+
+
+@pytest.mark.django_db
+@pytest.mark.httpx_mock(
+ assert_all_requests_were_expected=False, can_send_already_matched_responses=True
+)
+def test_announced_delete_verified_against_origin(httpx_mock: HTTPXMock, config_system):
+ _mock_author(httpx_mock)
+ httpx_mock.add_response(
+ url=PAGE_URI,
+ headers={"Content-Type": "application/activity+json"},
+ json=_page_json(),
+ )
+ Post.handle_announced_activity_ap(_announce(_create(_page_json())))
+ assert Post.objects.filter(object_uri=PAGE_URI).exists()
+
+ httpx_mock.add_response(url=PAGE_URI, status_code=410)
+ Post.handle_announced_activity_ap(
+ _announce(
+ {
+ "id": "https://lemmy.test/activities/delete/1",
+ "type": "Delete",
+ "actor": AUTHOR_URI,
+ "object": PAGE_URI,
+ },
+ "https://lemmy.test/activities/announce/4",
+ )
+ )
+ assert not Post.objects.filter(object_uri=PAGE_URI).exists()
+
+
+@pytest.mark.django_db
+@pytest.mark.httpx_mock(
+ assert_all_requests_were_expected=False, can_send_already_matched_responses=True
+)
+def test_announced_update_of_unknown_post_is_ignored(
+ httpx_mock: HTTPXMock, config_system
+):
+ Post.handle_announced_activity_ap(
+ _announce(
+ {
+ "id": "https://lemmy.test/activities/update/9",
+ "type": "Update",
+ "actor": AUTHOR_URI,
+ "object": _page_json(),
+ }
+ )
+ )
+ assert not Post.objects.filter(object_uri=PAGE_URI).exists()
+ assert not any(str(r.url) == PAGE_URI for r in httpx_mock.get_requests())
+
+
+@pytest.mark.django_db
+@pytest.mark.httpx_mock(
+ assert_all_requests_were_expected=False, can_send_already_matched_responses=True
+)
+def test_announced_multi_typed_activity_processed(httpx_mock: HTTPXMock, config_system):
+ """
+ JSON-LD allows the inner activity to carry multiple types, e.g.
+ ["Activity", "Create"]; the concrete type must be used.
+ """
+ _mock_author(httpx_mock)
+ httpx_mock.add_response(
+ url=PAGE_URI,
+ headers={"Content-Type": "application/activity+json"},
+ json=_page_json(),
+ )
+ create = _create(_page_json())
+ create["type"] = ["Activity", "Create"]
+ Post.handle_announced_activity_ap(_announce(create))
+ assert Post.objects.filter(object_uri=PAGE_URI).exists()
+
+
+@pytest.mark.django_db
+@pytest.mark.httpx_mock(
+ assert_all_requests_were_expected=False, can_send_already_matched_responses=True
+)
+def test_inbox_dispatch_routes_announced_activities(
+ httpx_mock: HTTPXMock, config_system
+):
+ """
+ InboxMessage processing routes Announce(Create) to the announced
+ activity handler and still drops announced votes.
+ """
+ _mock_author(httpx_mock)
+ httpx_mock.add_response(
+ url=PAGE_URI,
+ headers={"Content-Type": "application/activity+json"},
+ json=_page_json(),
+ )
+ message = InboxMessage.objects.create(message=_announce(_create(_page_json())))
+ assert InboxMessageStates.handle_received(message) == InboxMessageStates.processed
+ assert Post.objects.filter(object_uri=PAGE_URI).exists()
+
+ vote = InboxMessage.objects.create(
+ message=_announce(
+ {
+ "id": "https://lemmy.test/activities/like/1",
+ "type": "Like",
+ "actor": AUTHOR_URI,
+ "object": PAGE_URI,
+ },
+ "https://lemmy.test/activities/announce/5",
+ )
+ )
+ assert InboxMessageStates.handle_received(vote) == InboxMessageStates.processed
+ assert not PostInteraction.objects.filter(type=PostInteraction.Types.like).exists()
diff --git a/tests/activities/models/test_converted_post_types.py b/tests/activities/models/test_converted_post_types.py
new file mode 100644
index 00000000..223b94b0
--- /dev/null
+++ b/tests/activities/models/test_converted_post_types.py
@@ -0,0 +1,296 @@
+"""Inbound support for Mastodon-compatible converted ActivityStreams objects."""
+
+from datetime import timedelta
+from unittest.mock import patch
+
+import pytest
+from django.utils import timezone
+
+from activities.models import Post
+from activities.models.post import PostStates
+from core.ld import format_ld_date
+from users.models import InboxMessage
+from users.models.inbox_message import InboxMessageStates
+
+
+CONVERTED_TYPES = [
+ Post.Types.page,
+ Post.Types.image,
+ Post.Types.audio,
+ Post.Types.video,
+ Post.Types.event,
+]
+
+
+@pytest.mark.parametrize("post_type", CONVERTED_TYPES)
+@pytest.mark.parametrize(
+ ("activity_type", "handler_name"),
+ [("Create", "handle_create_ap"), ("Update", "handle_update_ap")],
+)
+def test_inbox_routes_converted_object_type_arrays(
+ post_type,
+ activity_type,
+ handler_name,
+):
+ actor = "https://remote.test/users/alice"
+ message = {
+ "type": activity_type,
+ "actor": actor,
+ "object": {
+ "id": f"https://remote.test/objects/{post_type.lower()}",
+ "type": ["Document", post_type],
+ "attributedTo": actor,
+ },
+ }
+ inbox_message = InboxMessage(message=message)
+
+ with patch.object(Post, handler_name) as handler:
+ result = InboxMessageStates.handle_received(inbox_message)
+
+ assert result == InboxMessageStates.processed
+ handler.assert_called_once_with(message)
+
+
+def converted_object(remote_identity, post_type):
+ slug = post_type.lower()
+ return {
+ "id": f"https://remote.test/objects/{slug}",
+ "type": [post_type, "Document"],
+ "attributedTo": remote_identity.actor_uri,
+ "to": "as:Public",
+ "name": f"{post_type} title",
+ "content": f"
{post_type} body
",
+ "summary": f"
{post_type} summary
",
+ "url": [
+ {
+ "type": "Link",
+ "href": f"https://remote.test/media/{slug}",
+ "mediaType": f"{slug}/example",
+ },
+ {
+ "type": "Link",
+ "href": f"https://remote.test/view/{slug}",
+ "mediaType": "text/html; charset=utf-8",
+ },
+ ],
+ "icon": {
+ "type": "Image",
+ "url": f"https://remote.test/icons/{slug}.png",
+ "mediaType": "image/png",
+ "width": 640,
+ "height": 360,
+ },
+ "published": "2026-07-18T12:00:00Z",
+ "startTime": "2026-07-20T18:00:00Z",
+ "endTime": "2026-07-20T20:00:00Z",
+ "location": {"type": "Place", "name": "Remote Hall"},
+ }
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("post_type", CONVERTED_TYPES)
+def test_by_ap_converts_supported_object_to_mastodon_status(
+ config_system,
+ remote_identity,
+ post_type,
+ api_client,
+ client,
+):
+ data = converted_object(remote_identity, post_type)
+
+ post = Post.by_ap(data=data, create=True)
+
+ assert post.type == post_type
+ assert post.url == f"https://remote.test/view/{post_type.lower()}"
+ assert post.summary is None
+ assert f"
{post_type} body
" in post.content
+ assert f"
{post_type} title
" in post.content
+ assert f"
{post_type} summary
" in post.content
+ assert post.url in post.content
+ assert "limited support" not in post.safe_content_local()
+
+ # The complete normalized object remains available for Event fields,
+ # media metadata and round-tripping.
+ assert post.type_data["object"]["type"] == post_type
+ assert post.type_data["object"]["startTime"] == "2026-07-20T18:00:00Z"
+ assert post.type_data["object"]["location"]["name"] == "Remote Hall"
+
+ status = post.to_mastodon_json()
+ assert f"{post_type} body" in status["content"]
+ assert f"{post_type} title" in status["content"]
+ assert f"{post_type} summary" in status["content"]
+ assert post.url in status["content"]
+ assert status["spoiler_text"] == ""
+ assert status["media_attachments"] == []
+
+ assert status["card"]["url"] == post.url
+ assert status["card"]["title"] == f"{post_type} title"
+ assert status["card"]["description"] == f"{post_type} summary"
+ assert status["card"]["width"] == 640
+ assert status["card"]["height"] == 360
+ assert post.preview_card.image_url.endswith(f"/icons/{post_type.lower()}.png")
+ assert f"/proxy/preview_card/{post.preview_card_id}/" in status["card"]["image"]
+ expected_card_type = (
+ "photo"
+ if post_type == Post.Types.image
+ else "video"
+ if post_type == Post.Types.video
+ else "link"
+ )
+ assert status["card"]["type"] == expected_card_type
+
+ api_response = api_client.get(f"/api/v1/statuses/{post.pk}")
+ assert api_response.status_code == 200
+ api_status = api_response.json()
+ assert api_status["card"]["title"] == f"{post_type} title"
+ assert api_status["poll"] is None
+
+ page = client.get(f"/@{remote_identity.handle}/posts/{post.pk}/")
+ assert page.status_code == 200
+ html = page.content.decode()
+ assert 'class="converted-object-card ' in html
+ assert f"{post_type} title" in html
+ assert f"{post_type} summary" in html
+ assert f"/proxy/preview_card/{post.preview_card_id}/" in html
+
+
+@pytest.mark.django_db
+def test_question_renders_in_api_and_takahe_page(
+ config_system,
+ identity,
+ api_client,
+ client,
+):
+ post = Post.create_local(
+ author=identity,
+ content="
Choose a migration route
",
+ question={
+ "type": "Question",
+ "mode": "oneOf",
+ "options": [
+ {"name": "Route A", "type": "Note", "votes": 2},
+ {"name": "Route B", "type": "Note", "votes": 1},
+ ],
+ "voter_count": 3,
+ "end_time": format_ld_date(timezone.now() + timedelta(hours=1)),
+ },
+ )
+
+ api_response = api_client.get(f"/api/v1/statuses/{post.pk}")
+ assert api_response.status_code == 200
+ poll = api_response.json()["poll"]
+ assert poll["id"] == str(post.pk)
+ assert [option["title"] for option in poll["options"]] == ["Route A", "Route B"]
+ assert poll["votes_count"] == 3
+
+ page = client.get(f"/@{identity.handle}/posts/{post.pk}/")
+ assert page.status_code == 200
+ html = page.content.decode()
+ assert "Choose a migration route" in html
+ assert "Route A" in html
+ assert "Route B" in html
+ assert "Sign in to vote" in html
+ assert 'name="choices"' in html
+ assert "disabled" in html
+
+
+@pytest.mark.django_db
+def test_converted_object_falls_back_to_name_and_object_id(
+ config_system,
+ remote_identity,
+):
+ data = {
+ "id": "https://remote.test/objects/page-name-only",
+ "type": "Page",
+ "attributedTo": remote_identity.actor_uri,
+ "nameMap": {"en": "Name-only page"},
+ "to": "as:Public",
+ }
+
+ post = Post.by_ap(data=data, create=True)
+
+ assert post.url == data["id"]
+ assert "Name-only page" in post.content
+ assert data["id"] in post.content
+ assert post.language == "en"
+
+
+@pytest.mark.django_db
+def test_converted_object_normalizes_link_shaped_attachment(
+ config_system,
+ remote_identity,
+):
+ data = converted_object(remote_identity, Post.Types.video)
+ data["attachment"] = {
+ "type": "Image",
+ "url": {
+ "type": "Link",
+ "href": "https://remote.test/media/preview.png",
+ "mediaType": "image/png",
+ },
+ "summary": "Video preview",
+ "width": "800",
+ "height": "450",
+ }
+
+ post = Post.by_ap(data=data, create=True)
+ attachment = post.attachments.get()
+
+ assert attachment.remote_url == "https://remote.test/media/preview.png"
+ assert attachment.mimetype == "image/png"
+ assert attachment.name == "Video preview"
+ assert attachment.width == 800
+ assert attachment.height == 450
+
+
+@pytest.mark.django_db
+def test_converted_object_update_replaces_preserved_data(
+ config_system,
+ remote_identity,
+):
+ data = converted_object(remote_identity, Post.Types.event)
+ post = Post.by_ap(data=data, create=True)
+
+ data["content"] = "
Updated event body
"
+ data["name"] = "Updated event title"
+ data["updated"] = "2026-07-19T12:00:00Z"
+ updated = Post.by_ap(data=data, update=True)
+
+ assert updated.pk == post.pk
+ assert "Updated event body" in updated.content
+ assert updated.type_data["object"]["name"] == "Updated event title"
+ assert updated.edited.isoformat() == "2026-07-19T12:00:00+00:00"
+
+
+@pytest.mark.django_db
+def test_converted_card_survives_new_and_edited_stator_handlers(
+ config_system,
+ remote_identity,
+):
+ data = converted_object(remote_identity, Post.Types.page)
+ tracked_url = "https://remote.test/view/page?utm_source=fediverse"
+ data["url"] = {
+ "type": "Link",
+ "href": tracked_url,
+ "mediaType": "text/html",
+ }
+
+ post = Post.by_ap(data=data, create=True)
+ original_card_id = post.preview_card_id
+
+ assert original_card_id is not None
+ assert post.preview_card.url == tracked_url
+ assert post.preview_card.state == "fetched"
+
+ with (
+ patch.object(PostStates, "targets_fan_out"),
+ patch.object(Post, "ensure_hashtags"),
+ ):
+ assert PostStates.handle_new(post) == PostStates.fanned_out
+ assert PostStates.handle_edited(post) == PostStates.edited_fanned_out
+
+ post.refresh_from_db()
+ clean_url = type(post.preview_card).strip_tracking_params(tracked_url)
+ assert post.preview_card_id == original_card_id
+ assert post.preview_card.image_url.endswith("/icons/page.png")
+ assert not type(post.preview_card).objects.filter(url=clean_url).exists()
diff --git a/tests/activities/models/test_fan_out_signing.py b/tests/activities/models/test_fan_out_signing.py
new file mode 100644
index 00000000..4e86b0df
--- /dev/null
+++ b/tests/activities/models/test_fan_out_signing.py
@@ -0,0 +1,74 @@
+import pytest
+
+from activities.models import FanOut, Post
+from core.signatures import LDSignature, VerificationError
+
+
+def _create_local_post(author, content, **kwargs) -> Post:
+ """
+ Creates a local post and reloads it from the database, as Stator does
+ before fanning out (fresh instances carry lazy urlman values that pyld
+ cannot deepcopy; delivery only ever happens on DB-loaded rows).
+ """
+ post = Post.create_local(author=author, content=content, **kwargs)
+ return Post.objects.get(pk=post.pk)
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize(
+ "fan_out_type",
+ [FanOut.Types.post, FanOut.Types.post_edited, FanOut.Types.post_deleted],
+)
+def test_public_post_fan_out_carries_ld_signature(
+ identity, keypair, config_system, fan_out_type
+):
+ """
+ Public local posts must carry a verifiable LD signature on their
+ Create/Update/Delete fan-out documents so receivers can forward them.
+ """
+ post = _create_local_post(identity, "
Hello
")
+ document = post.to_fan_out_ap(fan_out_type)
+ assert document is not None
+ assert document["signature"]["type"] == "RsaSignature2017"
+ assert document["signature"]["creator"] == identity.public_key_id
+ # Must verify against the author's public key
+ LDSignature.verify_signature(document, keypair["public_key"])
+
+
+@pytest.mark.django_db
+def test_ld_signature_tampering_detected(identity, keypair, config_system):
+ post = _create_local_post(identity, "
Hello
")
+ document = post.to_fan_out_ap(FanOut.Types.post)
+ document["object"]["content"] = "
Tampered
"
+ with pytest.raises(VerificationError):
+ LDSignature.verify_signature(document, keypair["public_key"])
+
+
+@pytest.mark.django_db
+def test_unlisted_post_fan_out_carries_ld_signature(identity, config_system):
+ post = _create_local_post(
+ identity, "
Hello
", visibility=Post.Visibilities.unlisted
+ )
+ document = post.to_fan_out_ap(FanOut.Types.post)
+ assert "signature" in document
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize(
+ "visibility",
+ [Post.Visibilities.followers, Post.Visibilities.mentioned],
+)
+def test_private_post_fan_out_is_not_ld_signed(identity, config_system, visibility):
+ """
+ Followers-only and mentioned-only posts stay unsigned so they cannot be
+ proven authentic by third parties (matching Mastodon's behaviour).
+ """
+ post = _create_local_post(identity, "
Secret
", visibility=visibility)
+ document = post.to_fan_out_ap(FanOut.Types.post)
+ assert "signature" not in document
+
+
+@pytest.mark.django_db
+def test_unknown_fan_out_type_returns_none(identity, config_system):
+ post = _create_local_post(identity, "
Hello
")
+ assert post.to_fan_out_ap(FanOut.Types.interaction) is None
diff --git a/tests/activities/models/test_inbox_forwarding.py b/tests/activities/models/test_inbox_forwarding.py
new file mode 100644
index 00000000..dff89fb4
--- /dev/null
+++ b/tests/activities/models/test_inbox_forwarding.py
@@ -0,0 +1,244 @@
+import json
+
+import pytest
+from pytest_httpx import HTTPXMock
+
+from activities.models import FanOut, Post
+from activities.models.fan_out import FanOutStates
+from users.models import Domain, Follow, Identity, InboxMessage
+from users.models.inbox_message import InboxMessageStates
+
+REPLY_URI = "https://remote.test/posts/reply/1"
+
+
+def _remote_follower(local_target, domain_name, shared_inbox=None) -> Identity:
+ domain = Domain.objects.create(domain=domain_name, local=False, state="updated")
+ follower = Identity.objects.create(
+ actor_uri=f"https://{domain_name}/users/f/",
+ inbox_uri=f"https://{domain_name}/users/f/inbox/",
+ shared_inbox_uri=shared_inbox,
+ username="f",
+ domain=domain,
+ local=False,
+ state="updated",
+ )
+ Follow.objects.create(source=follower, target=local_target, state="accepted")
+ return follower
+
+
+def _reply_from(author, local_post, visibility=Post.Visibilities.public) -> Post:
+ return Post.objects.create(
+ content="
Reply
",
+ author=author,
+ local=False,
+ visibility=visibility,
+ object_uri=REPLY_URI,
+ in_reply_to=local_post.object_uri,
+ )
+
+
+def _create_message(actor_uri, local_post) -> dict:
+ return {
+ "id": f"{actor_uri}activities/create/1",
+ "type": "Create",
+ "actor": actor_uri,
+ "object": {
+ "id": REPLY_URI,
+ "type": "Note",
+ "content": "
Reply
",
+ "attributedTo": actor_uri,
+ "inReplyTo": local_post.object_uri,
+ # handle_received processes canonicalised documents, where the
+ # Public collection URI is compacted to as:Public
+ "to": ["as:Public"],
+ },
+ }
+
+
+def _raw(message) -> dict:
+ return {
+ "@context": "https://www.w3.org/ns/activitystreams",
+ **message,
+ "signature": {
+ "type": "RsaSignature2017",
+ "creator": message["actor"] + "#main-key",
+ "created": "2026-07-01T10:00:00Z",
+ "signatureValue": "fake==",
+ },
+ }
+
+
+@pytest.mark.django_db
+def test_signed_reply_forwarded_to_thread_followers(
+ identity, remote_identity, config_system
+):
+ """
+ An LD-signed remote reply to a local post is forwarded to the local
+ author's remote followers, except those on the reply's origin server.
+ """
+ local_post = Post.create_local(author=identity, content="
Thread
")
+ third_party = _remote_follower(
+ identity, "remote2.test", shared_inbox="https://remote2.test/inbox/"
+ )
+ # Follower on the reply author's own server: must be skipped
+ origin_domain = remote_identity.domain
+ origin_follower = Identity.objects.create(
+ actor_uri="https://remote.test/users/g/",
+ inbox_uri="https://remote.test/users/g/inbox/",
+ username="g",
+ domain=origin_domain,
+ local=False,
+ state="updated",
+ )
+ Follow.objects.create(source=origin_follower, target=identity, state="accepted")
+
+ _reply_from(remote_identity, local_post)
+ message = _create_message(remote_identity.actor_uri, local_post)
+ Post.forward_activity_ap(message, _raw(message))
+
+ forwards = FanOut.objects.filter(type=FanOut.Types.forward)
+ assert forwards.count() == 1
+ forward = forwards.get()
+ assert forward.identity == third_party
+ assert forward.subject_document == _raw(message)
+ assert forward.subject_post is None
+
+
+@pytest.mark.django_db
+def test_unsigned_reply_not_forwarded(identity, remote_identity, config_system):
+ local_post = Post.create_local(author=identity, content="
Thread
")
+ _remote_follower(identity, "remote2.test")
+ _reply_from(remote_identity, local_post)
+ message = _create_message(remote_identity.actor_uri, local_post)
+ # Raw document without an LD signature must not be forwarded
+ Post.forward_activity_ap(message, {**message})
+ assert not FanOut.objects.filter(type=FanOut.Types.forward).exists()
+
+
+@pytest.mark.django_db
+def test_private_reply_not_forwarded(identity, remote_identity, config_system):
+ local_post = Post.create_local(author=identity, content="
Thread
")
+ _remote_follower(identity, "remote2.test")
+ _reply_from(remote_identity, local_post, visibility=Post.Visibilities.followers)
+ message = _create_message(remote_identity.actor_uri, local_post)
+ Post.forward_activity_ap(message, _raw(message))
+ assert not FanOut.objects.filter(type=FanOut.Types.forward).exists()
+
+
+@pytest.mark.django_db
+def test_reply_to_remote_post_not_forwarded(identity, remote_identity, config_system):
+ remote_parent = Post.objects.create(
+ content="
Remote thread
",
+ author=remote_identity,
+ local=False,
+ object_uri="https://remote.test/posts/parent/1",
+ )
+ _remote_follower(identity, "remote2.test")
+ reply = Post.objects.create(
+ content="
Reply
",
+ author=remote_identity,
+ local=False,
+ object_uri=REPLY_URI,
+ in_reply_to=remote_parent.object_uri,
+ )
+ message = _create_message(remote_identity.actor_uri, remote_parent)
+ message["object"]["inReplyTo"] = remote_parent.object_uri
+ assert reply.in_reply_to == remote_parent.object_uri
+ Post.forward_activity_ap(message, _raw(message))
+ assert not FanOut.objects.filter(type=FanOut.Types.forward).exists()
+
+
+@pytest.mark.django_db
+def test_actor_mismatch_not_forwarded(
+ identity, remote_identity, remote_identity2, config_system
+):
+ local_post = Post.create_local(author=identity, content="
Thread
")
+ _remote_follower(identity, "remote3.test")
+ _reply_from(remote_identity, local_post)
+ # remote_identity2 claims an activity about remote_identity's reply
+ message = _create_message(remote_identity2.actor_uri, local_post)
+ message["object"]["id"] = REPLY_URI
+ Post.forward_activity_ap(message, _raw(message))
+ assert not FanOut.objects.filter(type=FanOut.Types.forward).exists()
+
+
+@pytest.mark.django_db
+@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
+def test_forward_fan_out_delivers_raw_document(
+ httpx_mock: HTTPXMock, identity, remote_identity, config_system
+):
+ """
+ A forward fan-out re-sends the stored document verbatim (LD signature
+ intact), HTTP-signed by the system actor.
+ """
+ local_post = Post.create_local(author=identity, content="
Thread
")
+ follower = _remote_follower(
+ identity, "remote2.test", shared_inbox="https://remote2.test/inbox/"
+ )
+ message = _create_message(remote_identity.actor_uri, local_post)
+ raw = _raw(message)
+ fan_out = FanOut.objects.create(
+ identity=follower,
+ type=FanOut.Types.forward,
+ subject_document=raw,
+ )
+ httpx_mock.add_response(url="https://remote2.test/inbox/", status_code=202)
+
+ assert FanOutStates.handle_new(fan_out) == FanOutStates.sent
+ request = httpx_mock.get_requests()[-1]
+ assert str(request.url) == "https://remote2.test/inbox/"
+ assert json.loads(request.content) == raw
+ assert "Signature" in request.headers
+ # Signed by the system actor, not the original author
+ assert "/actor/" in request.headers["Signature"]
+
+
+@pytest.mark.django_db
+def test_handle_received_forwards_reply_create(
+ identity, remote_identity, config_system
+):
+ """
+ End to end through InboxMessage processing: a stored raw_document on a
+ Create leads to forward fan-outs once the reply is ingested.
+ """
+ local_post = Post.create_local(author=identity, content="
Thread
")
+ _remote_follower(identity, "remote2.test")
+ message = _create_message(remote_identity.actor_uri, local_post)
+ inbox_message = InboxMessage.objects.create(
+ message=message, raw_document=_raw(message)
+ )
+ assert (
+ InboxMessageStates.handle_received(inbox_message)
+ == InboxMessageStates.processed
+ )
+ assert Post.objects.filter(object_uri=REPLY_URI).exists()
+ assert FanOut.objects.filter(type=FanOut.Types.forward).count() == 1
+
+
+@pytest.mark.django_db
+def test_handle_received_forwards_reply_delete_before_removal(
+ identity, remote_identity, config_system
+):
+ """
+ A Delete of a forwarded reply is forwarded too, even though our copy
+ of the reply is removed in the same processing step.
+ """
+ local_post = Post.create_local(author=identity, content="
Thread
")
+ _remote_follower(identity, "remote2.test")
+ _reply_from(remote_identity, local_post)
+ message = {
+ "id": f"{remote_identity.actor_uri}activities/delete/1",
+ "type": "Delete",
+ "actor": remote_identity.actor_uri,
+ "object": {"id": REPLY_URI, "type": "Tombstone"},
+ }
+ inbox_message = InboxMessage.objects.create(
+ message=message, raw_document=_raw(message)
+ )
+ assert (
+ InboxMessageStates.handle_received(inbox_message)
+ == InboxMessageStates.processed
+ )
+ assert not Post.objects.filter(object_uri=REPLY_URI).exists()
+ forward = FanOut.objects.get(type=FanOut.Types.forward)
+ assert forward.subject_document["type"] == "Delete"
diff --git a/tests/activities/models/test_post.py b/tests/activities/models/test_post.py
index af70824a..49623467 100644
--- a/tests/activities/models/test_post.py
+++ b/tests/activities/models/test_post.py
@@ -293,6 +293,58 @@ def test_content_map(remote_identity):
assert post3.language == "en"
+@pytest.mark.django_db
+def test_quote_url_only_accepts_urls(remote_identity):
+ """
+ FEP-044f `quote` is a URL. BookWyrm Quotation objects overload the key
+ with HTML quotation text - rejecting non-URL strings prevents the post
+ save from blowing up the varchar(2048) quote_url column.
+ """
+ bookwyrm_quote_html = (
+ "
" + ("Car le totalitarisme politique n'est pas la forme " * 100) + "
"
+ )
+ assert len(bookwyrm_quote_html) > 2048
+
+ post = Post.by_ap(
+ data={
+ "id": "https://remote.test/user/u/quotation/1",
+ "type": "Note",
+ "content": "
see quote
",
+ "quote": bookwyrm_quote_html,
+ "attributedTo": "https://remote.test/test-actor/",
+ "published": "2026-05-15T10:00:00Z",
+ },
+ create=True,
+ )
+ assert post.quote_url is None
+
+ post2 = Post.by_ap(
+ data={
+ "id": "https://remote.test/posts/quote-url/",
+ "type": "Note",
+ "content": "
real quote
",
+ "quote": "https://other.test/posts/42",
+ "attributedTo": "https://remote.test/test-actor/",
+ "published": "2026-05-15T10:00:00Z",
+ },
+ create=True,
+ )
+ assert post2.quote_url == "https://other.test/posts/42"
+
+ post3 = Post.by_ap(
+ data={
+ "id": "https://remote.test/posts/quote-link/",
+ "type": "Note",
+ "content": "
linked quote
",
+ "quote": {"id": "https://other.test/posts/43", "type": "Link"},
+ "attributedTo": "https://remote.test/test-actor/",
+ "published": "2026-05-15T10:00:00Z",
+ },
+ create=True,
+ )
+ assert post3.quote_url == "https://other.test/posts/43"
+
+
@pytest.mark.django_db
def test_content_map_question(remote_identity: Identity):
"""
@@ -401,6 +453,70 @@ def test_by_ap_attributed_to_object(remote_identity):
assert post.author.actor_uri == "https://remote.test/test-actor/"
+@pytest.mark.django_db
+def test_by_ap_attributed_to_list(remote_identity):
+ """
+ Tests that by_ap handles attributedTo as a list of URIs. WriteFreely
+ blog Articles ship ``attributedTo: [author_person, blog_group]`` and
+ we should accept that, taking the first entry (the author) as the
+ canonical Identity.
+ """
+ post = Post.by_ap(
+ data={
+ "id": "https://remote.test/posts/writefreely-1/",
+ "type": "Article",
+ "name": "Hello",
+ "content": "Hello from WriteFreely",
+ "summary": "
Hello from WriteFreely excerpt
",
+ "attributedTo": [
+ "https://remote.test/test-actor/",
+ "https://remote.test/blog-group/",
+ ],
+ "published": "2026-05-08T11:35:17Z",
+ },
+ create=True,
+ )
+ assert post.content == "Hello from WriteFreely"
+ assert post.author.actor_uri == "https://remote.test/test-actor/"
+ # Article posts must keep the full AS object on ``type_data`` (under an
+ # ``object`` key) so downstream renderers can read ``name`` / ``summary``
+ # for title-card teasers. Stripping to ``ArticleData`` -- as the previous
+ # path did -- dropped the title entirely and made write.as / WriteFreely
+ # articles unrenderable in the timeline.
+ assert isinstance(post.type_data, dict)
+ assert post.type_data["object"]["name"] == "Hello"
+ assert (
+ post.type_data["object"]["summary"] == "
Hello from WriteFreely excerpt
"
+ )
+
+
+@pytest.mark.django_db
+def test_by_ap_attributed_to_list_reversed_prefers_known_person(remote_identity):
+ """
+ If a server inverts the WriteFreely convention to ``[blog, author]``,
+ we should still attribute the post to the Person actor when it is
+ already known locally as non-Group. The Group URI is not (yet) a
+ stored Identity, so the cheap DB lookup picks the Person.
+ """
+ # ``remote_identity`` fixture is a Person actor on remote.test.
+ assert remote_identity.actor_type == "person"
+ post = Post.by_ap(
+ data={
+ "id": "https://remote.test/posts/writefreely-rev/",
+ "type": "Article",
+ "name": "Hello",
+ "content": "Hello again",
+ "attributedTo": [
+ "https://remote.test/blog-group/",
+ remote_identity.actor_uri,
+ ],
+ "published": "2026-05-08T11:35:17Z",
+ },
+ create=True,
+ )
+ assert post.author.actor_uri == remote_identity.actor_uri
+
+
@pytest.mark.django_db
@pytest.mark.parametrize("delete_type", ["note", "tombstone", "ref"])
def test_inbound_posts(
@@ -577,3 +693,76 @@ def test_post_targets_to_ap(
elif visibility == Post.Visibilities.mentioned:
assert "to" not in ap_dict
assert ap_dict["cc"] == [other_identity.actor_uri]
+
+
+@pytest.mark.django_db
+def test_article_web_view_shows_cover_and_links_tags(remote_identity):
+ """
+ The web article view (local render) surfaces the AS ``image`` as a lead
+ image and renders hashtags as links; the Mastodon API content (remote
+ render) keeps neither the cover nor its
.
+ """
+ post = Post.by_ap(
+ data={
+ "id": "https://remote.test/posts/article-cover/",
+ "type": "Article",
+ "name": "Cover Test",
+ "content": "
Body text.
",
+ "image": {"type": "Image", "url": "https://remote.test/lead.jpg"},
+ "tag": [
+ {
+ "type": "Hashtag",
+ "name": "#news",
+ "href": "https://remote.test/tags/news/",
+ }
+ ],
+ "attributedTo": "https://remote.test/test-actor/",
+ "published": "2026-05-08T11:35:17Z",
+ },
+ create=True,
+ )
+
+ assert post.article_cover_url == "https://remote.test/lead.jpg"
+
+ web = post.safe_content_local()
+ assert "https://remote.test/lead.jpg" in web
+ assert 'href="https://remote.test/tags/news/"' in web
+ assert "#news" in web
+
+ api = post.safe_content_remote()
+ assert "https://remote.test/lead.jpg" not in api
+ assert "Body text." in api
+
+
+@pytest.mark.parametrize(
+ "image,expected",
+ [
+ ("https://remote.test/a.jpg", "https://remote.test/a.jpg"),
+ (
+ {"type": "Image", "url": "https://remote.test/b.jpg"},
+ "https://remote.test/b.jpg",
+ ),
+ (
+ {"url": {"type": "Link", "href": "https://remote.test/c.jpg"}},
+ "https://remote.test/c.jpg",
+ ),
+ (
+ [{"url": "https://remote.test/d.jpg"}, "ignored"],
+ "https://remote.test/d.jpg",
+ ),
+ (None, None),
+ ({}, None),
+ ("ftp://remote.test/e.jpg", None),
+ ],
+)
+def test_article_cover_url_normalizes_image_shapes(image, expected):
+ post = Post(type=Post.Types.article, type_data={"object": {"image": image}})
+ assert post.article_cover_url == expected
+
+
+def test_article_cover_url_none_for_non_article():
+ post = Post(
+ type=Post.Types.note,
+ type_data={"object": {"image": "https://remote.test/x.jpg"}},
+ )
+ assert post.article_cover_url is None
diff --git a/tests/activities/models/test_post_interaction.py b/tests/activities/models/test_post_interaction.py
index 2c032f27..03479b17 100644
--- a/tests/activities/models/test_post_interaction.py
+++ b/tests/activities/models/test_post_interaction.py
@@ -433,3 +433,48 @@ def test_handle_remove_ap(remote_identity: Identity, config_system):
# Remove activity on unknown post is a no-op
PostInteraction.handle_remove_ap(data=remove_ap | {"object": "unknown-post"})
+
+
+@pytest.mark.django_db
+def test_vote_with_invalid_option_ignored(
+ identity: Identity, remote_identity: Identity, config_system
+):
+ post = Post.create_local(
+ author=identity,
+ content="
Test Question
",
+ question={
+ "type": "Question",
+ "mode": "oneOf",
+ "options": [
+ {"name": "Option 1", "type": "Note", "votes": 0},
+ {"name": "Option 2", "type": "Note", "votes": 0},
+ ],
+ "voter_count": 0,
+ "end_time": format_ld_date(timezone.now() + timedelta(1)),
+ },
+ )
+
+ def vote_payload(vote_id, note):
+ return {
+ "id": f"https://remote.test/test-actor#votes/{vote_id}/activity",
+ "to": "https://example.com/@test@example.com/",
+ "type": "Create",
+ "actor": "https://remote.test/test-actor/",
+ "object": {
+ "id": f"https://remote.test/users/test-actor#votes/{vote_id}",
+ "to": "https://example.com/@test@example.com/",
+ "type": "Note",
+ "inReplyTo": post.object_uri,
+ "attributedTo": "https://remote.test/test-actor/",
+ **note,
+ },
+ }
+
+ # A vote for an option that does not exist is dropped, not an error
+ PostInteraction.handle_ap(vote_payload(21, {"name": "Nonexistent Option"}))
+ # A bare Note without a name is dropped too
+ PostInteraction.handle_ap(vote_payload(22, {}))
+
+ post.refresh_from_db()
+ assert post.type_data.voter_count == 0
+ assert not post.interactions.filter(type=PostInteraction.Types.vote).exists()
diff --git a/tests/activities/models/test_post_replies.py b/tests/activities/models/test_post_replies.py
index b5d30cda..8aee0149 100644
--- a/tests/activities/models/test_post_replies.py
+++ b/tests/activities/models/test_post_replies.py
@@ -1,9 +1,64 @@
import pytest
-from activities.models import Post
+from activities.models import Post, PostStates
+from activities.services import PostService
from users.models import InboxMessage
+@pytest.mark.django_db
+def test_reply_count_recalculated_after_reply_deleted(identity, stator):
+ """Regression for #1648: deleting a reply must decrease the parent's
+ replies_count instead of leaving it inflated indefinitely."""
+ parent = Post.create_local(
+ author=identity,
+ content="Parent post",
+ visibility=Post.Visibilities.public,
+ )
+ reply = Post.create_local(
+ author=identity,
+ content="A reply",
+ visibility=Post.Visibilities.public,
+ reply_to=parent,
+ )
+ # create_local() recalculates the parent's stats when a reply is created.
+ parent.refresh_from_db()
+ assert parent.stats["replies"] == 1
+
+ # Delete the reply and let the deleted-state handler run, as stator would.
+ PostService(reply).delete()
+ reply.refresh_from_db()
+ assert reply.state == str(PostStates.deleted)
+ stator.run_single_cycle()
+
+ parent.refresh_from_db()
+ assert parent.stats["replies"] == 0
+
+
+@pytest.mark.django_db
+def test_reply_count_recalculated_after_reply_hard_deleted(identity, config_system):
+ """Hard deletes (incoming AP Delete, prune, admin) bypass handle_deleted,
+ so the post_delete signal must also refresh the parent's replies_count."""
+ parent = Post.create_local(
+ author=identity,
+ content="Parent post",
+ visibility=Post.Visibilities.public,
+ )
+ reply = Post.create_local(
+ author=identity,
+ content="A reply",
+ visibility=Post.Visibilities.public,
+ reply_to=parent,
+ )
+ parent.refresh_from_db()
+ assert parent.stats["replies"] == 1
+
+ # Hard delete the row directly, firing the post_delete signal.
+ reply.delete()
+
+ parent.refresh_from_db()
+ assert parent.stats["replies"] == 0
+
+
@pytest.mark.django_db
def test_to_ap_includes_replies_collection(identity):
"""Local posts should include a replies Collection in their AP representation."""
diff --git a/tests/activities/models/test_post_types.py b/tests/activities/models/test_post_types.py
index a2a88d2b..76f44a3c 100644
--- a/tests/activities/models/test_post_types.py
+++ b/tests/activities/models/test_post_types.py
@@ -1,10 +1,23 @@
import pytest
from activities.models import Post
-from activities.models.post_types import QuestionData
+from activities.models.post_types import (
+ PostTypeData,
+ QuestionData,
+ QuestionOption,
+)
from core.ld import canonicalise
+def test_question_option_coalesces_duplicated_name_list():
+ # JSON-LD canonicalisation can duplicate scalar fields into a list when
+ # the remote sends the same value in both `name` and `nameMap` (seen in
+ # the wild from pl.fediverse.pl Question posts).
+ option = QuestionOption(name=["oba", "oba"], replies={"totalItems": 5})
+ assert option.name == "oba"
+ assert option.votes == 5
+
+
@pytest.mark.django_db
def test_question_post(config_system, identity, remote_identity, httpx_mock):
data = {
@@ -67,3 +80,28 @@ def test_question_post(config_system, identity, remote_identity, httpx_mock):
assert len(question_data.options) == 2
assert question_data.options[0].votes == 2
assert question_data.options[1].votes == 1
+
+
+def test_question_closed_datetime_marks_expired():
+ question = PostTypeData(
+ root={
+ "type": "Question",
+ "oneOf": [{"name": "A"}, {"name": "B"}],
+ "closed": "2022-01-01T00:00:00Z",
+ }
+ ).root
+ assert isinstance(question, QuestionData)
+ assert question.is_expired
+ assert question.effective_end_time is not None
+
+
+def test_question_closed_boolean_marks_expired():
+ question = PostTypeData(
+ root={
+ "type": "Question",
+ "oneOf": [{"name": "A"}, {"name": "B"}],
+ "closed": True,
+ }
+ ).root
+ assert isinstance(question, QuestionData)
+ assert question.is_expired
diff --git a/tests/activities/models/test_preview_card.py b/tests/activities/models/test_preview_card.py
index d2a3b80c..6f78ff6f 100644
--- a/tests/activities/models/test_preview_card.py
+++ b/tests/activities/models/test_preview_card.py
@@ -10,9 +10,8 @@
from activities.models.preview_card import (
PreviewCard,
PreviewCardStates,
- SSRFAttemptError,
- _check_url_safety,
)
+from core.files import SSRFAttemptError, check_url_safety
from django.core.management import call_command
@@ -114,20 +113,6 @@ def test_url_without_params_unchanged():
assert PreviewCard.strip_tracking_params(url) == url
-# ---------------------------------------------------------------------------
-# DB smoke test
-# ---------------------------------------------------------------------------
-
-
-@pytest.mark.django_db
-def test_preview_card_create_defaults():
- card = PreviewCard.objects.create(url="https://example.com/article")
- assert card.state == "needs_fetch"
- assert card.card_type == "link"
- assert card.title == ""
- assert card.image_url == ""
-
-
# ---------------------------------------------------------------------------
# SSRF protection
# ---------------------------------------------------------------------------
@@ -142,42 +127,42 @@ def test_ssrf_blocks_loopback():
req = httpx.Request("GET", "http://localhost/admin")
with patch("socket.getaddrinfo", return_value=_mock_getaddrinfo("127.0.0.1")):
with pytest.raises(SSRFAttemptError):
- _check_url_safety(req)
+ check_url_safety(req)
def test_ssrf_blocks_private_10():
req = httpx.Request("GET", "http://internal.corp/secret")
with patch("socket.getaddrinfo", return_value=_mock_getaddrinfo("10.0.0.5")):
with pytest.raises(SSRFAttemptError):
- _check_url_safety(req)
+ check_url_safety(req)
def test_ssrf_blocks_private_192_168():
req = httpx.Request("GET", "http://router.local/")
with patch("socket.getaddrinfo", return_value=_mock_getaddrinfo("192.168.1.1")):
with pytest.raises(SSRFAttemptError):
- _check_url_safety(req)
+ check_url_safety(req)
def test_ssrf_blocks_aws_metadata():
req = httpx.Request("GET", "http://169.254.169.254/latest/meta-data/")
with patch("socket.getaddrinfo", return_value=_mock_getaddrinfo("169.254.169.254")):
with pytest.raises(SSRFAttemptError):
- _check_url_safety(req)
+ check_url_safety(req)
-def test_ssrf_blocks_unresolvable():
+def test_ssrf_raises_connect_error_for_unresolvable():
req = httpx.Request("GET", "http://doesnotexist.invalid/")
with patch("socket.getaddrinfo", side_effect=socket.gaierror("not found")):
- with pytest.raises(SSRFAttemptError):
- _check_url_safety(req)
+ with pytest.raises(httpx.ConnectError):
+ check_url_safety(req)
def test_ssrf_allows_public_ip():
req = httpx.Request("GET", "https://example.com/")
# 93.184.216.34 is example.com — a real public IP
with patch("socket.getaddrinfo", return_value=_mock_getaddrinfo("93.184.216.34")):
- _check_url_safety(req) # should not raise
+ check_url_safety(req) # should not raise
# ---------------------------------------------------------------------------
@@ -279,6 +264,65 @@ def test_handle_needs_fetch_ssrf_blocked(httpx_mock, config_system):
assert result == PreviewCardStates.fetch_failed
+@pytest.mark.django_db
+@pytest.mark.httpx_mock(
+ assert_all_requests_were_expected=False, can_send_already_matched_responses=True
+)
+def test_handle_needs_fetch_drops_oversized_image_url(httpx_mock, config_system):
+ """An oversized og:image is dropped along with its now-meaningless dimensions."""
+ long_image = "https://example.com/" + ("a" * 3000) + ".jpg"
+ html = (
+ "
T "
+ f'
'
+ '
'
+ '
'
+ ""
+ )
+ httpx_mock.add_response(
+ url="https://example.com/big-image",
+ headers={"Content-Type": "text/html"},
+ text=html,
+ )
+ card = PreviewCard.objects.create(url="https://example.com/big-image")
+ with patch(
+ "socket.getaddrinfo", return_value=[(2, 1, 0, "", ("93.184.216.34", 443))]
+ ):
+ result = PreviewCardStates.handle_needs_fetch(card)
+ card.refresh_from_db()
+ assert result == PreviewCardStates.fetched
+ assert card.image_url == ""
+ # Dimensions must not linger without an image (avoids inconsistent API output).
+ assert card.image_width is None
+ assert card.image_height is None
+
+
+@pytest.mark.django_db
+@pytest.mark.httpx_mock(
+ assert_all_requests_were_expected=False, can_send_already_matched_responses=True
+)
+def test_handle_needs_fetch_truncates_long_author(httpx_mock, config_system):
+ """An og:article:author longer than the column limit is truncated to fit."""
+ long_author = "X" * 600
+ html = (
+ "
T "
+ f'
'
+ ""
+ )
+ httpx_mock.add_response(
+ url="https://example.com/long-author",
+ headers={"Content-Type": "text/html"},
+ text=html,
+ )
+ card = PreviewCard.objects.create(url="https://example.com/long-author")
+ with patch(
+ "socket.getaddrinfo", return_value=[(2, 1, 0, "", ("93.184.216.34", 443))]
+ ):
+ result = PreviewCardStates.handle_needs_fetch(card)
+ card.refresh_from_db()
+ assert result == PreviewCardStates.fetched
+ assert card.author_name == "X" * 500
+
+
# ---------------------------------------------------------------------------
# to_mastodon_json
# ---------------------------------------------------------------------------
diff --git a/tests/activities/models/test_question_lifecycle.py b/tests/activities/models/test_question_lifecycle.py
new file mode 100644
index 00000000..75792ec5
--- /dev/null
+++ b/tests/activities/models/test_question_lifecycle.py
@@ -0,0 +1,306 @@
+from datetime import timedelta
+
+import pytest
+from django.utils import timezone
+from pytest_httpx import HTTPXMock
+
+from activities.models import (
+ FanOut,
+ Post,
+ PostInteraction,
+ PostStates,
+ TimelineEvent,
+)
+from activities.models.post_types import QuestionData
+from core.ld import format_ld_date
+from users.models import Identity
+
+
+def make_local_poll(author, mode="oneOf", expires=timedelta(days=1), hide_totals=False):
+ return Post.create_local(
+ author=author,
+ content="
Test Question
",
+ question={
+ "type": "Question",
+ "mode": mode,
+ "options": [
+ {"name": "Option 1", "type": "Note", "votes": 0},
+ {"name": "Option 2", "type": "Note", "votes": 0},
+ ],
+ "voter_count": 0,
+ "hide_totals": hide_totals,
+ "end_time": format_ld_date(timezone.now() + expires),
+ },
+ )
+
+
+def expire_poll(post):
+ post.type_data.end_time = timezone.now() - timedelta(hours=1)
+ post.save()
+
+
+def edited_fan_outs(post):
+ return FanOut.objects.filter(subject_post=post, type=FanOut.Types.post_edited)
+
+
+@pytest.mark.django_db
+def test_new_local_poll_enters_question_open(identity: Identity, config_system):
+ post = make_local_poll(identity)
+ assert post.type_data.last_distributed_tally == "0:0:0"
+ assert PostStates.handle_new(post) == PostStates.question_open
+
+
+@pytest.mark.django_db
+def test_new_remote_question_stays_fanned_out(remote_identity: Identity, config_system):
+ post = Post.objects.create(
+ author=remote_identity,
+ local=False,
+ content="
Test Question
",
+ object_uri="https://remote.test/status/poll-new",
+ type=Post.Types.question,
+ type_data={
+ "type": "Question",
+ "mode": "oneOf",
+ "options": [
+ {"name": "Option 1", "type": "Note", "votes": 0},
+ {"name": "Option 2", "type": "Note", "votes": 0},
+ ],
+ "voter_count": 0,
+ "end_time": format_ld_date(timezone.now() + timedelta(1)),
+ },
+ )
+ post.refresh_from_db()
+ assert PostStates.handle_new(post) == PostStates.fanned_out
+
+
+@pytest.mark.django_db
+def test_question_open_distributes_tally_updates(
+ identity: Identity, identity2: Identity, config_system
+):
+ post = make_local_poll(identity)
+ # No votes yet: nothing to distribute, stays in question_open
+ assert PostStates.handle_question_open(post) is None
+ assert not edited_fan_outs(post).exists()
+
+ PostInteraction.create_votes(post, identity2, [0])
+ post.refresh_from_db()
+ assert PostStates.handle_question_open(post) is None
+ assert edited_fan_outs(post).filter(identity=identity2).exists()
+ post.refresh_from_db()
+ assert post.type_data.last_distributed_tally == "1:1:0"
+
+ # Unchanged tallies do not fan out again
+ edited_fan_outs(post).delete()
+ post.refresh_from_db()
+ assert PostStates.handle_question_open(post) is None
+ assert not edited_fan_outs(post).exists()
+
+
+@pytest.mark.django_db
+def test_question_open_hide_totals(
+ identity: Identity, identity2: Identity, config_system
+):
+ post = make_local_poll(identity, hide_totals=True)
+ PostInteraction.create_votes(post, identity2, [0])
+ post.refresh_from_db()
+ # Hidden totals: no tally Updates while the poll is running
+ assert PostStates.handle_question_open(post) is None
+ assert not edited_fan_outs(post).exists()
+ # Per-option tallies are hidden in the API and over AP
+ assert post.type_data.to_mastodon_json(post)["options"][0]["votes_count"] is None
+ ap = post.to_ap()
+ assert ap["oneOf"][0]["replies"]["totalItems"] == 0
+
+ # The final Update after expiry reveals the tallies
+ expire_poll(post)
+ post.refresh_from_db()
+ assert PostStates.handle_question_open(post) == PostStates.fanned_out
+ assert edited_fan_outs(post).exists()
+ post.refresh_from_db()
+ assert post.type_data.to_mastodon_json(post)["options"][0]["votes_count"] == 1
+ assert post.to_ap()["oneOf"][0]["replies"]["totalItems"] == 1
+
+
+@pytest.mark.django_db
+def test_question_open_expiry_notifies_and_closes(
+ identity: Identity, identity2: Identity, config_system
+):
+ post = make_local_poll(identity)
+ PostInteraction.create_votes(post, identity2, [1])
+ post.refresh_from_db()
+ expire_poll(post)
+ post.refresh_from_db()
+
+ assert PostStates.handle_question_open(post) == PostStates.fanned_out
+ assert edited_fan_outs(post).exists()
+ # Both the author and the local voter get a poll-ended notification
+ assert TimelineEvent.objects.filter(
+ identity=identity, type=TimelineEvent.Types.poll, subject_post=post
+ ).exists()
+ assert TimelineEvent.objects.filter(
+ identity=identity2, type=TimelineEvent.Types.poll, subject_post=post
+ ).exists()
+ # The final AP representation carries `closed`
+ ap = post.to_ap()
+ assert ap["closed"] == ap["endTime"]
+
+
+@pytest.mark.django_db
+def test_remote_poll_vote_tracks_expiry(
+ identity: Identity, remote_identity: Identity, config_system
+):
+ post = Post.objects.create(
+ author=remote_identity,
+ local=False,
+ content="
Test Question
",
+ object_uri="https://remote.test/status/poll-track",
+ state=PostStates.fanned_out,
+ type=Post.Types.question,
+ type_data={
+ "type": "Question",
+ "mode": "oneOf",
+ "options": [
+ {"name": "Option 1", "type": "Note", "votes": 3},
+ {"name": "Option 2", "type": "Note", "votes": 4},
+ ],
+ "voter_count": 7,
+ "end_time": format_ld_date(timezone.now() + timedelta(1)),
+ },
+ )
+ post.refresh_from_db()
+ PostInteraction.create_votes(post, identity, [0])
+ post.refresh_from_db()
+ # Voting on a remote poll starts expiry tracking
+ assert post.state == str(PostStates.question_open)
+
+ # Not expired yet: stays put and does not notify
+ assert PostStates.handle_question_open(post) is None
+
+ expire_poll(post)
+ post.refresh_from_db()
+ assert PostStates.handle_question_open(post) == PostStates.fanned_out
+ assert TimelineEvent.objects.filter(
+ identity=identity, type=TimelineEvent.Types.poll, subject_post=post
+ ).exists()
+
+
+@pytest.mark.django_db
+@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
+def test_refresh_question_from_remote(
+ httpx_mock: HTTPXMock, identity: Identity, remote_identity: Identity, config_system
+):
+ post = Post.objects.create(
+ author=remote_identity,
+ local=False,
+ content="
Test Question
",
+ object_uri="https://remote.test/status/poll-refresh",
+ state=PostStates.fanned_out,
+ type=Post.Types.question,
+ type_data={
+ "type": "Question",
+ "mode": "oneOf",
+ "options": [
+ {"name": "Option 1", "type": "Note", "votes": 3},
+ {"name": "Option 2", "type": "Note", "votes": 4},
+ ],
+ "voter_count": 7,
+ "end_time": format_ld_date(timezone.now() + timedelta(1)),
+ },
+ )
+ post.refresh_from_db()
+ httpx_mock.add_response(
+ url="https://remote.test/status/poll-refresh",
+ headers={"Content-Type": "application/activity+json"},
+ json={
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ {
+ "toot": "http://joinmastodon.org/ns#",
+ "votersCount": "toot:votersCount",
+ },
+ ],
+ "id": "https://remote.test/status/poll-refresh",
+ "type": "Question",
+ "attributedTo": remote_identity.actor_uri,
+ "content": "
Test Question
",
+ "endTime": format_ld_date(timezone.now() + timedelta(1)),
+ "votersCount": 12,
+ "oneOf": [
+ {
+ "name": "Option 1",
+ "type": "Note",
+ "replies": {"type": "Collection", "totalItems": 5},
+ },
+ {
+ "name": "Option 2",
+ "type": "Note",
+ "replies": {"type": "Collection", "totalItems": 7},
+ },
+ ],
+ },
+ )
+ refreshed = post.refresh_question_if_stale()
+ assert isinstance(refreshed.type_data, QuestionData)
+ assert refreshed.type_data.voter_count == 12
+ assert refreshed.type_data.options[0].votes == 5
+ assert refreshed.type_data.last_fetched is not None
+ # A fresh fetch within a minute is skipped (no second request mocked)
+ assert refreshed.refresh_question_if_stale().type_data.voter_count == 12
+
+
+@pytest.mark.django_db
+def test_vote_on_remote_poll_with_long_option(
+ identity: Identity, remote_identity: Identity, config_system
+):
+ long_name = "A" * 80
+ post = Post.objects.create(
+ author=remote_identity,
+ local=False,
+ content="
Test Question
",
+ object_uri="https://remote.test/status/poll-long",
+ state=PostStates.fanned_out,
+ type=Post.Types.question,
+ type_data={
+ "type": "Question",
+ "mode": "oneOf",
+ "options": [
+ {"name": long_name, "type": "Note", "votes": 0},
+ {"name": "Short", "type": "Note", "votes": 0},
+ ],
+ "voter_count": 0,
+ "end_time": format_ld_date(timezone.now() + timedelta(1)),
+ },
+ )
+ post.refresh_from_db()
+ vote = PostInteraction.create_votes(post, identity, [0])[0]
+ # The stored value fits the 50-char column, and own_votes still resolve
+ assert vote.value == long_name[:50]
+ json = post.type_data.to_mastodon_json(post, identity=identity)
+ assert json["own_votes"] == [0]
+
+
+@pytest.mark.django_db
+def test_undone_votes_are_not_counted(
+ identity: Identity, identity2: Identity, config_system
+):
+ from activities.models import PostInteractionStates
+
+ post = make_local_poll(identity)
+ vote = PostInteraction.create_votes(post, identity2, [0])[0]
+ post.refresh_from_db()
+ assert post.type_data.options[0].votes == 1
+
+ # e.g. a block or an AP Undo forces the vote out of the active states
+ vote.transition_perform(PostInteractionStates.undone_fanned_out)
+ post.calculate_type_data()
+ post.refresh_from_db()
+ assert post.type_data.options[0].votes == 0
+ assert post.type_data.voter_count == 0
+ assert post.question_local_voters() == []
+
+
+@pytest.mark.django_db
+def test_update_ap_ids_are_unique_per_revision(identity: Identity, config_system):
+ post = make_local_poll(identity)
+ first = post.to_update_ap()["id"]
+ assert "#updates/" in first
diff --git a/tests/activities/models/test_quote_authorization.py b/tests/activities/models/test_quote_authorization.py
new file mode 100644
index 00000000..4d75fd0b
--- /dev/null
+++ b/tests/activities/models/test_quote_authorization.py
@@ -0,0 +1,166 @@
+import json
+
+import pytest
+from activities.models import Post, QuoteAuthorization
+from django.test import Client
+
+from users.models import Identity
+
+
+@pytest.fixture(autouse=True)
+def _enable_federation(settings):
+ original = settings.SETUP.NO_FEDERATION
+ settings.SETUP.NO_FEDERATION = False
+ yield
+ settings.SETUP.NO_FEDERATION = original
+
+
+@pytest.mark.django_db
+def test_to_ap_shape(identity: Identity, config_system):
+ post = Post.create_local(
+ author=identity, content="hi", visibility=Post.Visibilities.public
+ )
+ auth = QuoteAuthorization.objects.create(
+ target_post=post,
+ interacting_object_uri="https://remote.test/users/x/statuses/1",
+ request_uri="https://remote.test/users/x/quote-request/1",
+ )
+ ap = auth.to_ap()
+ assert ap["type"] == "QuoteAuthorization"
+ assert ap["id"] == auth.object_uri
+ assert ap["attributedTo"] == identity.actor_uri
+ assert ap["interactingObject"] == "https://remote.test/users/x/statuses/1"
+ assert ap["interactionTarget"] == post.object_uri
+
+
+@pytest.mark.django_db
+def test_url_is_post_scoped(identity: Identity, config_system):
+ post = Post.create_local(author=identity, content="hi")
+ auth = QuoteAuthorization.objects.create(
+ target_post=post,
+ interacting_object_uri="https://remote.test/users/x/statuses/1",
+ )
+ url = auth.object_uri
+ assert url.startswith(post.object_uri)
+ assert url.endswith(f"/quote-auth/{auth.id}/")
+
+
+@pytest.mark.django_db
+def test_view_serves_authorization(identity: Identity, config_system):
+ post = Post.create_local(
+ author=identity, content="hi", visibility=Post.Visibilities.public
+ )
+ auth = QuoteAuthorization.objects.create(
+ target_post=post,
+ interacting_object_uri="https://remote.test/users/x/statuses/1",
+ )
+ client = Client(HTTP_HOST="example.com")
+ path = f"/@{identity.username}@{identity.domain.domain}/posts/{post.id}/quote-auth/{auth.id}/"
+ resp = client.get(path, headers={"accept": "application/activity+json"})
+ assert resp.status_code == 200
+ assert resp.headers["content-type"].startswith("application/activity+json")
+ body = json.loads(resp.content)
+ assert body["type"] == "QuoteAuthorization"
+ assert body["id"].endswith(f"/quote-auth/{auth.id}/")
+ assert body["attributedTo"] == identity.actor_uri
+ assert body["interactingObject"] == "https://remote.test/users/x/statuses/1"
+ assert body["interactionTarget"] == post.object_uri
+
+
+@pytest.mark.django_db
+def test_view_404_for_mismatched_post(
+ identity: Identity, other_identity: Identity, config_system
+):
+ post_a = Post.create_local(author=identity, content="a")
+ post_b = Post.create_local(author=other_identity, content="b")
+ auth = QuoteAuthorization.objects.create(
+ target_post=post_a,
+ interacting_object_uri="https://remote.test/users/x/statuses/1",
+ )
+ client = Client(HTTP_HOST="example.com")
+ # Wrong post id under a's handle.
+ path = f"/@{identity.username}@{identity.domain.domain}/posts/{post_b.id}/quote-auth/{auth.id}/"
+ assert (
+ client.get(path, headers={"accept": "application/activity+json"}).status_code
+ == 404
+ )
+ # Wrong handle for the auth's post.
+ path = f"/@{other_identity.username}@{other_identity.domain.domain}/posts/{post_a.id}/quote-auth/{auth.id}/"
+ assert (
+ client.get(path, headers={"accept": "application/activity+json"}).status_code
+ == 404
+ )
+
+
+@pytest.mark.django_db
+def test_handle_quote_request_persists_and_uses_url(
+ monkeypatch, identity: Identity, remote_identity: Identity, config_system
+):
+ post = Post.create_local(
+ author=identity, content="hi", visibility=Post.Visibilities.public
+ )
+
+ sent: dict = {}
+
+ def fake_signed_request(self, method, uri, body=None):
+ sent["uri"] = uri
+ sent["body"] = body
+ return None
+
+ monkeypatch.setattr(Identity, "signed_request", fake_signed_request)
+
+ Post.handle_quote_request_ap(
+ {
+ "type": "QuoteRequest",
+ "id": "https://remote.test/users/quoter/quote-request/1",
+ "actor": remote_identity.actor_uri,
+ "object": post.object_uri,
+ "instrument": "https://remote.test/users/quoter/statuses/42",
+ }
+ )
+
+ auth = QuoteAuthorization.objects.get(target_post=post)
+ assert auth.interacting_object_uri == "https://remote.test/users/quoter/statuses/42"
+ assert auth.request_uri == "https://remote.test/users/quoter/quote-request/1"
+
+ assert sent["uri"] == remote_identity.inbox_uri
+ accept = sent["body"]
+ assert accept["type"] == "Accept"
+ # The Accept's result is the QuoteAuthorization with a real URL, not a fragment.
+ result = accept["result"]
+ assert result["type"] == "QuoteAuthorization"
+ assert result["id"] == auth.object_uri
+ assert "#" not in result["id"]
+ assert result["attributedTo"] == identity.actor_uri
+ assert result["interactingObject"] == "https://remote.test/users/quoter/statuses/42"
+ assert result["interactionTarget"] == post.object_uri
+
+
+@pytest.mark.django_db
+def test_handle_quote_request_rejects_non_public(
+ monkeypatch, identity: Identity, remote_identity: Identity, config_system
+):
+ post = Post.create_local(
+ author=identity, content="hi", visibility=Post.Visibilities.followers
+ )
+
+ sent: dict = {}
+
+ def fake_signed_request(self, method, uri, body=None):
+ sent["body"] = body
+ return None
+
+ monkeypatch.setattr(Identity, "signed_request", fake_signed_request)
+
+ Post.handle_quote_request_ap(
+ {
+ "type": "QuoteRequest",
+ "id": "https://remote.test/users/quoter/quote-request/2",
+ "actor": remote_identity.actor_uri,
+ "object": post.object_uri,
+ "instrument": "https://remote.test/users/quoter/statuses/43",
+ }
+ )
+
+ assert not QuoteAuthorization.objects.filter(target_post=post).exists()
+ assert sent["body"]["type"] == "Reject"
diff --git a/tests/activities/models/test_timeline_event.py b/tests/activities/models/test_timeline_event.py
index 02cc98c7..a87811bc 100644
--- a/tests/activities/models/test_timeline_event.py
+++ b/tests/activities/models/test_timeline_event.py
@@ -1,4 +1,7 @@
+from unittest.mock import patch
+
import pytest
+from django.db import OperationalError
from django.utils import timezone
from activities.models import (
@@ -9,6 +12,7 @@
)
from activities.services import PostService, TimelineService
from core.ld import format_ld_date
+from stator.exceptions import TryAgainLater
from users.models import Block, Follow, Identity, InboxMessage
from users.services import IdentityService
@@ -394,3 +398,44 @@ def test_non_exclusive_list_does_not_exclude_from_home_timeline(
e.type == TimelineEvent.Types.post and e.subject_post_id == post.pk
for e in home
), "post from non-exclusive list member should remain in home timeline"
+
+
+@pytest.mark.django_db
+def test_handle_clear_timeline_translates_deadlock_to_tryagainlater(
+ identity: Identity,
+ other_identity: Identity,
+):
+ """
+ A Postgres deadlock during ClearTimeline cleanup should surface as
+ TryAgainLater so Stator silently reschedules instead of logging the
+ OperationalError. Other OperationalErrors must still propagate.
+ """
+ message = {"actor": str(identity.pk), "object": str(other_identity.pk)}
+
+ deadlock = OperationalError(
+ "deadlock detected\nDETAIL: Process A waits for ShareLock..."
+ )
+
+ class _BoomQuerySet:
+ def filter(self, *args, **kwargs):
+ return self
+
+ def delete(self):
+ raise deadlock
+
+ with patch.object(TimelineEvent, "objects", _BoomQuerySet()):
+ with pytest.raises(TryAgainLater):
+ TimelineEvent.handle_clear_timeline(message)
+
+ other = OperationalError("connection terminated")
+
+ class _OtherErrorQuerySet:
+ def filter(self, *args, **kwargs):
+ return self
+
+ def delete(self):
+ raise other
+
+ with patch.object(TimelineEvent, "objects", _OtherErrorQuerySet()):
+ with pytest.raises(OperationalError):
+ TimelineEvent.handle_clear_timeline(message)
diff --git a/tests/activities/services/test_search.py b/tests/activities/services/test_search.py
new file mode 100644
index 00000000..2e814034
--- /dev/null
+++ b/tests/activities/services/test_search.py
@@ -0,0 +1,181 @@
+import httpx
+import pytest
+
+from activities.models import Post
+from activities.services.search import SearchService
+from users.models import Identity
+from users.models.system_actor import SystemActor
+
+
+@pytest.mark.django_db
+def test_search_url_follows_ap_alternate(monkeypatch, config_system):
+ """A permalink that returns HTML with an AP alternate Link should
+ cause search_url to refetch the alternate URL before giving up.
+
+ Regression: WordPress's ActivityPub plugin does not content-negotiate
+ permalinks, so the AP object is only reachable via the Link header.
+ """
+ permalink = "https://blog.example/2026/04/22/post-slug/"
+ ap_object_url = "https://blog.example/?p=42"
+
+ html_response = httpx.Response(
+ 200,
+ headers={
+ "Content-Type": "text/html; charset=UTF-8",
+ "Link": (
+ f'<{ap_object_url}>; rel="alternate"; type="application/activity+json"'
+ ),
+ },
+ content=b"not JSON",
+ request=httpx.Request("GET", permalink),
+ )
+ ap_response = httpx.Response(
+ 200,
+ headers={"Content-Type": "application/activity+json"},
+ json={
+ "@context": "https://www.w3.org/ns/activitystreams",
+ "id": ap_object_url,
+ "type": "Article",
+ "attributedTo": "https://blog.example/?author=0",
+ "content": "
Article body
",
+ "published": "2026-04-22T12:00:00Z",
+ },
+ request=httpx.Request("GET", ap_object_url),
+ )
+
+ calls: list[str] = []
+
+ def fake_signed_request(self, method, uri, body=None):
+ calls.append(uri)
+ if uri == permalink:
+ return html_response
+ if uri == ap_object_url:
+ return ap_response
+ raise AssertionError(f"unexpected uri: {uri}")
+
+ monkeypatch.setattr(SystemActor, "signed_request", fake_signed_request)
+
+ captured: dict = {}
+
+ def fake_by_object_uri(cls, uri, fetch=False, fetch_as=None):
+ captured["uri"] = uri
+ captured["fetch"] = fetch
+ raise Post.DoesNotExist()
+
+ monkeypatch.setattr(Post, "by_object_uri", classmethod(fake_by_object_uri))
+
+ result = SearchService(permalink, None).search_url()
+
+ assert calls == [permalink, ap_object_url], (
+ "search_url should follow the AP alternate Link header"
+ )
+ assert captured["uri"] == ap_object_url
+ assert captured["fetch"] is True
+ assert result is None
+
+
+@pytest.mark.django_db
+def test_search_identities_by_domain(identity, identity2):
+ """Searching a bare domain name should return identities on that
+ domain, matched case-insensitively.
+
+ Regression: ``domain__iexact`` raised FieldError as ``iexact``
+ cannot be applied directly to the ForeignKey.
+ """
+ results = SearchService("Example.COM", None).search_identities_handle()
+
+ assert identity in results
+ assert identity2 not in results
+
+
+@pytest.mark.django_db
+def test_search_url_gives_up_when_no_alternate(monkeypatch, config_system):
+ """If the response is HTML without an AP alternate hint, search_url
+ must give up rather than loop or raise."""
+ url = "https://blog.example/no-ap/"
+ response = httpx.Response(
+ 200,
+ headers={"Content-Type": "text/html"},
+ content=b"nope",
+ request=httpx.Request("GET", url),
+ )
+
+ calls: list[str] = []
+
+ def fake_signed_request(self, method, uri, body=None):
+ calls.append(uri)
+ return response
+
+ monkeypatch.setattr(SystemActor, "signed_request", fake_signed_request)
+
+ assert SearchService(url, None).search_url() is None
+ assert calls == [url]
+
+
+@pytest.mark.django_db
+def test_search_url_does_not_loop_on_self_referential_alternate(
+ monkeypatch, config_system
+):
+ """A misconfigured alternate that points back at the same URL must
+ not cause an infinite loop."""
+ url = "https://blog.example/loop/"
+ response = httpx.Response(
+ 200,
+ headers={
+ "Content-Type": "text/html",
+ "Link": f'<{url}>; rel="alternate"; type="application/activity+json"',
+ },
+ content=b"",
+ request=httpx.Request("GET", url),
+ )
+
+ call_count = 0
+
+ def fake_signed_request(self, method, uri, body=None):
+ nonlocal call_count
+ call_count += 1
+ return response
+
+ monkeypatch.setattr(SystemActor, "signed_request", fake_signed_request)
+
+ assert SearchService(url, None).search_url() is None
+ assert call_count == 1
+
+
+@pytest.mark.django_db
+def test_search_url_handles_list_type(monkeypatch, config_system):
+ """An actor whose JSON-LD "type" is a list (e.g. ActivityPods emits
+ ["Person", "foaf:Person"]) must still be recognised as an identity."""
+ url = "https://pods.example/u/test"
+ response = httpx.Response(
+ 200,
+ headers={"Content-Type": "application/activity+json"},
+ json={
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ {"foaf": "http://xmlns.com/foaf/0.1/"},
+ ],
+ "id": url,
+ "type": ["Person", "foaf:Person"],
+ "inbox": f"{url}/inbox",
+ "preferredUsername": "test",
+ },
+ request=httpx.Request("GET", url),
+ )
+
+ def fake_signed_request(self, method, uri, body=None):
+ return response
+
+ monkeypatch.setattr(SystemActor, "signed_request", fake_signed_request)
+
+ captured: dict = {}
+
+ def fake_by_actor_uri(cls, uri, create=False):
+ captured["uri"] = uri
+ return None
+
+ monkeypatch.setattr(Identity, "by_actor_uri", classmethod(fake_by_actor_uri))
+
+ assert SearchService(url, None).search_url() is None
+ # Routed to the identity branch, not dropped as an unknown type
+ assert captured["uri"] == url
diff --git a/tests/activities/views/test_poll_voting.py b/tests/activities/views/test_poll_voting.py
new file mode 100644
index 00000000..d7e484b4
--- /dev/null
+++ b/tests/activities/views/test_poll_voting.py
@@ -0,0 +1,205 @@
+from datetime import timedelta
+
+import pytest
+from django.utils import timezone
+
+from activities.models import Post, PostInteraction
+from core.ld import format_ld_date
+
+
+def make_poll(author, *, mode="oneOf", expires=timedelta(hours=1)):
+ return Post.create_local(
+ author=author,
+ content="
Choose a route
",
+ question={
+ "type": "Question",
+ "mode": mode,
+ "options": [
+ {"name": "Route A", "type": "Note", "votes": 0},
+ {"name": "Route B", "type": "Note", "votes": 0},
+ {"name": "Route C", "type": "Note", "votes": 0},
+ ],
+ "voter_count": 0,
+ "end_time": format_ld_date(timezone.now() + expires),
+ },
+ )
+
+
+def post_path(post):
+ return f"/@{post.author.handle}/posts/{post.pk}/"
+
+
+@pytest.mark.django_db
+def test_anonymous_question_page_links_to_login(config_system, identity, client):
+ post = make_poll(identity)
+ path = post_path(post)
+
+ page = client.get(path)
+
+ assert page.status_code == 200
+ assert b"Sign in to vote" in page.content
+ assert b'name="choices"' in page.content
+ assert b"disabled" in page.content
+
+ response = client.post(
+ f"{path}vote/",
+ {"identity": identity.pk, "choices": "0"},
+ )
+
+ assert response.status_code == 302
+ assert response.url.startswith("/auth/login/")
+
+
+@pytest.mark.django_db
+def test_question_page_casts_single_choice_vote(
+ config_system,
+ identity,
+ identity2,
+ client_with_user,
+):
+ post = make_poll(identity2)
+ path = post_path(post)
+
+ page = client_with_user.get(path, {"identity": identity.pk})
+
+ assert page.status_code == 200
+ assert b"Vote as" in page.content
+ assert b"
Client:
+ """Build an authed client for an Application with the given redirect_uris."""
+ application = Application.objects.create(
+ name="Test App",
+ client_id=client_id,
+ client_secret="verifysecret",
+ redirect_uris=redirect_uris,
+ )
+ token = Token.objects.create(
+ application=application,
+ user=identity.users.first(),
+ identity=identity,
+ token=f"token-{client_id}",
+ scopes=["read"],
+ )
+ return Client(
+ headers={
+ "authorization": f"Bearer {token.token}",
+ "accept": "application/json",
+ }
+ )
+
+
+@pytest.mark.django_db
+def test_verify_credentials_redirect_uri_shape(identity):
+ """
+ /api/v1/apps/verify_credentials must serialize redirect_uri as a string and
+ redirect_uris as a list even though it is stored as a plain string,
+ rather than raising a pydantic ValidationError.
+ """
+ client = _verify_credentials_client(
+ identity, client_id="tk-verify-test", redirect_uris="neodb://oauth/callback"
+ )
+
+ response = client.get("/api/v1/apps/verify_credentials")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["redirect_uri"] == "neodb://oauth/callback"
+ assert data["redirect_uris"] == ["neodb://oauth/callback"]
+ # verify_credentials must not leak client keys
+ assert data["client_secret"] == ""
+
+
+@pytest.mark.django_db
+def test_verify_credentials_splits_multiple_redirect_uris(identity):
+ """
+ Multiple redirect URIs stored as one delimited string (add_app joins a list
+ with commas; Mastodon clients may use newlines) must be split into separate
+ entries, not returned as a single mashed-together element.
+ """
+ client = _verify_credentials_client(
+ identity,
+ client_id="tk-multi-test",
+ redirect_uris="https://a.example/cb,https://b.example/cb",
+ )
+
+ response = client.get("/api/v1/apps/verify_credentials")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["redirect_uris"] == ["https://a.example/cb", "https://b.example/cb"]
+ assert data["redirect_uri"] == "https://a.example/cb\nhttps://b.example/cb"
diff --git a/tests/api/test_new_endpoints.py b/tests/api/test_new_endpoints.py
index 2c3bd1a5..6c00839c 100644
--- a/tests/api/test_new_endpoints.py
+++ b/tests/api/test_new_endpoints.py
@@ -140,3 +140,36 @@ def test_account_note_in_relationship(api_client, identity, other_identity):
data = response.json()
assert len(data) == 1
assert data[0]["note"] == "My note"
+
+
+@pytest.mark.django_db
+def test_relationship_null_boolean_columns(identity, other_identity):
+ """
+ Legacy/imported follow rows can hold NULL in the notify/boosts columns.
+ The relationship JSON must coerce these to real booleans so the Mastodon
+ Relationship schema validation does not fail.
+ """
+ from api.schemas import Relationship
+ from users.models.follow import Follow, FollowStates
+ from users.services import IdentityService
+
+ follow = Follow.create_local(source=identity, target=other_identity)
+ follow.state = FollowStates.accepted
+ # Simulate NULL columns from imported data.
+ follow.notify = None
+ follow.boosts = None
+
+ relationships = {
+ "outbound_follow": follow,
+ "inbound_follow": None,
+ "outbound_block": None,
+ "inbound_block": None,
+ "outbound_mute": None,
+ }
+ data = IdentityService(other_identity)._build_relationship_json(
+ relationships, identity
+ )
+ assert data["notifying"] is False
+ assert data["showing_reblogs"] is False
+ # Must not raise pydantic ValidationError.
+ Relationship(**data)
diff --git a/tests/api/notifications.py b/tests/api/test_notifications.py
similarity index 100%
rename from tests/api/notifications.py
rename to tests/api/test_notifications.py
diff --git a/tests/api/test_oauth.py b/tests/api/test_oauth.py
new file mode 100644
index 00000000..073dc960
--- /dev/null
+++ b/tests/api/test_oauth.py
@@ -0,0 +1,14 @@
+import pytest
+
+
+@pytest.mark.django_db
+def test_authorize_missing_redirect_uri(client_with_user):
+ """
+ Hitting /oauth/authorize without a redirect_uri must return a graceful 400
+ error page naming the missing param rather than raising
+ MultiValueDictKeyError. response_type is supplied so the
+ 400 can only originate from the redirect_uri guard, not a later check.
+ """
+ response = client_with_user.get("/oauth/authorize?response_type=code")
+ assert response.status_code == 400
+ assert "Missing redirect_uri" in response.content.decode()
diff --git a/tests/api/test_polls.py b/tests/api/test_polls.py
index 4043078f..0e0a64c4 100644
--- a/tests/api/test_polls.py
+++ b/tests/api/test_polls.py
@@ -3,10 +3,38 @@
import pytest
from django.utils import timezone
-from activities.models import Post
+from activities.models import Post, PostInteraction, TimelineEvent
+from activities.models.post_types import POLL_MAX_OPTIONS
from core.ld import format_ld_date
+def make_poll_post(author, mode="oneOf", hide_totals=False, expires=timedelta(1)):
+ return Post.create_local(
+ author=author,
+ content="Test Question
",
+ question={
+ "type": "Question",
+ "mode": mode,
+ "options": [
+ {"name": "Option 1", "type": "Note", "votes": 0},
+ {"name": "Option 2", "type": "Note", "votes": 0},
+ {"name": "Option 3", "type": "Note", "votes": 0},
+ ],
+ "voter_count": 0,
+ "hide_totals": hide_totals,
+ "end_time": format_ld_date(timezone.now() + expires),
+ },
+ )
+
+
+def vote(api_client, post_id, choices):
+ return api_client.post(
+ f"/api/v1/polls/{post_id}/votes",
+ content_type="application/json",
+ data={"choices": choices},
+ )
+
+
@pytest.mark.django_db
def test_get_poll(api_client):
response = api_client.post(
@@ -33,9 +61,225 @@ def test_get_poll(api_client):
@pytest.mark.django_db
def test_vote_poll(api_client, identity2):
+ post = make_poll_post(identity2)
+
+ response = vote(api_client, post.id, [0]).json()
+
+ assert response["id"] == str(post.id)
+ assert response["voted"]
+ assert response["votes_count"] == 1
+ assert response["own_votes"] == [0]
+
+
+@pytest.mark.django_db
+def test_vote_poll_own_poll_rejected(api_client, identity):
+ post = make_poll_post(identity)
+ response = vote(api_client, post.id, [0])
+ assert response.status_code == 422
+ assert "own poll" in response.json()["error"]
+
+
+@pytest.mark.django_db
+def test_vote_poll_invalid_choice(api_client, identity2):
+ post = make_poll_post(identity2)
+ response = vote(api_client, post.id, [3])
+ assert response.status_code == 422
+ assert "does not exist" in response.json()["error"]
+
+
+@pytest.mark.django_db
+def test_vote_poll_no_choices(api_client, identity2):
+ post = make_poll_post(identity2)
+ response = vote(api_client, post.id, [])
+ assert response.status_code == 422
+
+
+@pytest.mark.django_db
+def test_vote_poll_twice_rejected(api_client, identity2):
+ post = make_poll_post(identity2)
+ assert vote(api_client, post.id, [0]).status_code == 200
+ response = vote(api_client, post.id, [1])
+ assert response.status_code == 422
+ assert "already voted" in response.json()["error"]
+
+
+@pytest.mark.django_db
+def test_vote_poll_single_choice_multiple_votes_rejected(api_client, identity2):
+ post = make_poll_post(identity2)
+ response = vote(api_client, post.id, [0, 1])
+ assert response.status_code == 422
+
+
+@pytest.mark.django_db
+def test_vote_poll_multiple_additive(api_client, identity2):
+ post = make_poll_post(identity2, mode="anyOf")
+ assert vote(api_client, post.id, [0]).status_code == 200
+ # Adding a different choice later is allowed on multiple-choice polls
+ response = vote(api_client, post.id, [1]).json()
+ assert sorted(response["own_votes"]) == [0, 1]
+ assert response["votes_count"] == 2
+ assert response["voters_count"] == 1
+ # Re-voting an already chosen option is not
+ assert vote(api_client, post.id, [1]).status_code == 422
+
+
+@pytest.mark.django_db
+def test_vote_poll_expired(api_client, identity2):
+ post = make_poll_post(identity2, expires=timedelta(hours=-1))
+ response = vote(api_client, post.id, [0])
+ assert response.status_code == 422
+ assert "already ended" in response.json()["error"]
+
+
+@pytest.mark.django_db
+def test_poll_hide_totals(api_client, identity2):
+ post = make_poll_post(identity2, hide_totals=True)
+ response = vote(api_client, post.id, [0]).json()
+ assert response["options"][0]["votes_count"] is None
+ assert response["votes_count"] == 1
+ assert response["own_votes"] == [0]
+
+ # Once the poll ends, the tallies are revealed
+ post.refresh_from_db()
+ post.type_data.end_time = timezone.now() - timedelta(hours=1)
+ post.save()
+ response = api_client.get(f"/api/v1/polls/{post.id}").json()
+ assert response["expired"]
+ assert response["options"][0]["votes_count"] == 1
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize(
+ "poll,error",
+ [
+ ({"options": ["A"], "expires_in": 300}, "more than one item"),
+ (
+ {
+ "options": [f"O{i}" for i in range(POLL_MAX_OPTIONS + 1)],
+ "expires_in": 300,
+ },
+ "can't contain more than",
+ ),
+ ({"options": ["A", "A"], "expires_in": 300}, "duplicate"),
+ ({"options": ["A", " "], "expires_in": 300}, "blank"),
+ ({"options": ["A", "B" * 51], "expires_in": 300}, "characters each"),
+ ({"options": ["A", "B"], "expires_in": 60}, "too soon"),
+ ({"options": ["A", "B"], "expires_in": 3000000}, "too far into the future"),
+ ],
+)
+def test_create_poll_validation(api_client, poll, error):
+ response = api_client.post(
+ "/api/v1/statuses",
+ content_type="application/json",
+ data={"status": "Poll!", "poll": poll},
+ )
+ assert response.status_code == 422
+ assert error in response.json()["error"]
+
+
+@pytest.mark.django_db
+def test_create_poll_max_options_allowed(api_client):
+ response = api_client.post(
+ "/api/v1/statuses",
+ content_type="application/json",
+ data={
+ "status": "Poll!",
+ "poll": {
+ "options": [f"O{i}" for i in range(POLL_MAX_OPTIONS)],
+ "expires_in": 300,
+ },
+ },
+ )
+ assert response.status_code == 200
+ assert len(response.json()["poll"]["options"]) == POLL_MAX_OPTIONS
+
+
+@pytest.mark.django_db
+def test_create_poll_with_media_rejected(api_client):
+ response = api_client.post(
+ "/api/v1/statuses",
+ content_type="application/json",
+ data={
+ "status": "Poll!",
+ "media_ids": ["12345"],
+ "poll": {"options": ["A", "B"], "expires_in": 300},
+ },
+ )
+ assert response.status_code == 422
+
+
+@pytest.mark.django_db
+def test_edit_poll(api_client, identity, identity2):
+ response = api_client.post(
+ "/api/v1/statuses",
+ content_type="application/json",
+ data={
+ "status": "Poll!",
+ "poll": {"options": ["A", "B"], "expires_in": 3600},
+ },
+ ).json()
+ post = Post.objects.get(pk=response["id"])
+ PostInteraction.create_votes(post, identity2, [0])
+ post.refresh_from_db()
+ assert post.type_data.options[0].votes == 1
+
+ # Editing without changing the options keeps the votes
+ response = api_client.put(
+ f"/api/v1/statuses/{post.id}",
+ content_type="application/json",
+ data={
+ "status": "Poll! (edited)",
+ "poll": {"options": ["A", "B"], "expires_in": 3600},
+ },
+ ).json()
+ assert response["poll"]["votes_count"] == 1
+
+ # Changing the options resets all votes
+ response = api_client.put(
+ f"/api/v1/statuses/{post.id}",
+ content_type="application/json",
+ data={
+ "status": "Poll! (edited again)",
+ "poll": {"options": ["X", "Y", "Z"], "expires_in": 3600},
+ },
+ ).json()
+ assert response["poll"]["votes_count"] == 0
+ assert [o["title"] for o in response["poll"]["options"]] == ["X", "Y", "Z"]
+ post.refresh_from_db()
+ assert not post.interactions.filter(type=PostInteraction.Types.vote).exists()
+
+ # Removing the poll turns the post back into a plain note
+ response = api_client.put(
+ f"/api/v1/statuses/{post.id}",
+ content_type="application/json",
+ data={"status": "No poll anymore"},
+ ).json()
+ assert response["poll"] is None
+ post.refresh_from_db()
+ assert post.type == Post.Types.note
+
+
+@pytest.mark.django_db
+def test_poll_notification(api_client, identity, identity2):
+ post = make_poll_post(identity2)
+ TimelineEvent.add_poll_ended(identity, post)
+
+ response = api_client.get(
+ "/api/v1/notifications",
+ data={"types[]": ["poll"]},
+ ).json()
+ assert len(response) == 1
+ assert response[0]["type"] == "poll"
+ assert response[0]["status"]["id"] == str(post.id)
+ assert response[0]["account"]["id"] == str(identity2.id)
+
+
+@pytest.mark.django_db
+def test_poll_visibility_respected(api_client, identity2):
post = Post.create_local(
author=identity2,
- content="Test Question
",
+ content="Followers only poll
",
+ visibility=Post.Visibilities.followers,
question={
"type": "Question",
"mode": "oneOf",
@@ -47,16 +291,6 @@ def test_vote_poll(api_client, identity2):
"end_time": format_ld_date(timezone.now() + timedelta(1)),
},
)
-
- response = api_client.post(
- f"/api/v1/polls/{post.id}/votes",
- content_type="application/json",
- data={
- "choices": [0],
- },
- ).json()
-
- assert response["id"] == str(post.id)
- assert response["voted"]
- assert response["votes_count"] == 1
- assert response["own_votes"] == [0]
+ # The API identity does not follow identity2, so the poll is invisible
+ assert api_client.get(f"/api/v1/polls/{post.id}").status_code == 404
+ assert vote(api_client, post.id, [0]).status_code == 404
diff --git a/tests/api/test_tokens.py b/tests/api/test_tokens.py
index 16f42d1b..0d6c06ae 100644
--- a/tests/api/test_tokens.py
+++ b/tests/api/test_tokens.py
@@ -1,5 +1,7 @@
import pytest
+from api.models import Application, Authorization
+
@pytest.mark.django_db
def test_has_scope(api_token):
@@ -9,3 +11,94 @@ def test_has_scope(api_token):
assert api_token.has_scope("read")
assert api_token.has_scope("read:statuses")
assert not api_token.has_scope("destroyearth")
+
+
+@pytest.mark.django_db
+def test_authorization_code_single_use(client, identity):
+ """
+ An OAuth authorization code must mint exactly one token; replaying the
+ same code must be rejected.
+ """
+ application = Application.objects.create(
+ name="Code App",
+ client_id="tk-code-test",
+ client_secret="codesecret",
+ redirect_uris="https://example.com/callback",
+ )
+ Authorization.objects.create(
+ application=application,
+ user=identity.users.first(),
+ identity=identity,
+ code="testauthcode",
+ redirect_uri="https://example.com/callback",
+ scopes=["read"],
+ )
+ data = {
+ "grant_type": "authorization_code",
+ "code": "testauthcode",
+ "client_id": "tk-code-test",
+ "client_secret": "codesecret",
+ "redirect_uri": "https://example.com/callback",
+ }
+
+ response = client.post("/oauth/token", data)
+ assert response.status_code == 200
+ assert response.json()["access_token"]
+
+ response = client.post("/oauth/token", data)
+ assert response.status_code == 401
+
+
+@pytest.mark.django_db
+def test_token_non_ascii_credentials_rejected(client, identity):
+ """
+ A non-ASCII client_id/client_secret must fail authentication cleanly.
+ hmac.compare_digest raises TypeError on non-ASCII str arguments, which
+ turned a bad credential into a 500 instead of an access_denied.
+ """
+ application = Application.objects.create(
+ name="Unicode App",
+ client_id="tk-unicode-test",
+ client_secret="unicodesecret",
+ redirect_uris="https://example.com/callback",
+ )
+ Authorization.objects.create(
+ application=application,
+ user=identity.users.first(),
+ identity=identity,
+ code="unicodeauthcode",
+ redirect_uri="https://example.com/callback",
+ scopes=["read"],
+ )
+
+ for client_id, client_secret in [
+ ("tk-unicode-tëst", "unicodesecret"),
+ ("tk-unicode-test", "unicodesécret"),
+ ("クライアント", "秘密"),
+ ]:
+ response = client.post(
+ "/oauth/token",
+ {
+ "grant_type": "authorization_code",
+ "code": "unicodeauthcode",
+ "client_id": client_id,
+ "client_secret": client_secret,
+ "redirect_uri": "https://example.com/callback",
+ },
+ )
+ assert response.status_code == 401
+ assert response.json() == {"error": "access_denied"}
+
+ # The rejected attempts must not have consumed the code
+ response = client.post(
+ "/oauth/token",
+ {
+ "grant_type": "authorization_code",
+ "code": "unicodeauthcode",
+ "client_id": "tk-unicode-test",
+ "client_secret": "unicodesecret",
+ "redirect_uri": "https://example.com/callback",
+ },
+ )
+ assert response.status_code == 200
+ assert response.json()["access_token"]
diff --git a/tests/conftest.py b/tests/conftest.py
index d5151d0f..460a73ce 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,11 +1,11 @@
import time
import pytest
-from django.test import Client
-
from api.models import Application, Token
from core.models import Config
+from django.test import Client
from stator.runner import StatorModel, StatorRunner
+
from users.models import Domain, Identity, User
@@ -56,6 +56,15 @@ def keypair():
}
+@pytest.fixture(autouse=True)
+def _bypass_ssrf_check(monkeypatch):
+ """Disable SSRF DNS check in tests so pytest_httpx mocks work with fake domains."""
+ _noop = lambda request: None # noqa: E731
+ monkeypatch.setattr("core.files.check_url_safety", _noop)
+ monkeypatch.setattr("core.signatures.check_url_safety", _noop)
+ monkeypatch.setattr("users.models.identity.check_url_safety", _noop)
+
+
@pytest.fixture(autouse=True)
def _test_settings(settings):
# We use `StaticFilesStorage` instead of `ManifestStaticFilesStorage` in tests
@@ -94,29 +103,25 @@ def client_with_user(client, user):
@pytest.fixture
-@pytest.mark.django_db
-def user() -> User:
+def user(db) -> User:
return User.objects.create(email="test@example.com")
@pytest.fixture
-@pytest.mark.django_db
-def domain() -> Domain:
+def domain(db) -> Domain:
return Domain.objects.create(
domain="example.com", local=True, public=True, state="updated"
)
@pytest.fixture
-@pytest.mark.django_db
-def domain2() -> Domain:
+def domain2(db) -> Domain:
return Domain.objects.create(
domain="example2.com", local=True, public=True, state="updated"
)
@pytest.fixture
-@pytest.mark.django_db
def identity_factory(user, domain, keypair):
"""
Factory for creating identities with custom parameters
@@ -145,7 +150,6 @@ def _create_identity(username="test", actor_type="person", **kwargs):
@pytest.fixture
-@pytest.mark.django_db
def identity(identity_factory) -> Identity:
"""
Creates a basic test identity with a user and domain.
@@ -154,7 +158,6 @@ def identity(identity_factory) -> Identity:
@pytest.fixture
-@pytest.mark.django_db
def identity2(user, domain2) -> Identity:
"""
Creates a basic test identity with a user and domain.
@@ -188,8 +191,7 @@ def other_identity(user, domain) -> Identity:
@pytest.fixture
-@pytest.mark.django_db
-def remote_identity() -> Identity:
+def remote_identity(db) -> Identity:
"""
Creates a basic remote test identity with a domain.
"""
@@ -209,8 +211,7 @@ def remote_identity() -> Identity:
@pytest.fixture
-@pytest.mark.django_db
-def remote_identity2() -> Identity:
+def remote_identity2(db) -> Identity:
"""
Creates a basic remote test identity with a domain.
"""
@@ -226,7 +227,6 @@ def remote_identity2() -> Identity:
@pytest.fixture
-@pytest.mark.django_db
def api_token(identity) -> Token:
"""
Creates an API application, an identity, and a token for that identity
diff --git a/tests/core/test_html.py b/tests/core/test_html.py
index 7cacf03c..166f8e58 100644
--- a/tests/core/test_html.py
+++ b/tests/core/test_html.py
@@ -154,6 +154,23 @@ def test_parser_same_name_mentions(remote_identity, remote_identity2):
assert parser.plain_text == "@test @test"
+@pytest.mark.django_db
+def test_parser_bare_mention_deterministic(remote_identity, remote_identity2):
+ """
+ A bare @username shared by identities on different domains must resolve
+ deterministically, regardless of the order mentions are supplied in (#1616).
+ """
+
+ def render(mentions):
+ return FediverseHtmlParser("Hey @test
", mentions=mentions).html
+
+ forward = render([remote_identity, remote_identity2])
+ backward = render([remote_identity2, remote_identity])
+ assert forward == backward
+ assert 'href="https://remote2.test/@test/"' in forward
+ assert 'href="https://remote.test/@test/"' not in forward
+
+
@pytest.mark.django_db
def test_parser_emoji_img():
"""
diff --git a/tests/core/test_json.py b/tests/core/test_json.py
new file mode 100644
index 00000000..294e3988
--- /dev/null
+++ b/tests/core/test_json.py
@@ -0,0 +1,99 @@
+import httpx
+
+from core.json import find_ap_alternate
+
+
+def _resp(url: str, *, content: bytes = b"", headers: list[tuple[str, str]] = None):
+ return httpx.Response(
+ status_code=200,
+ headers=headers or [],
+ content=content,
+ request=httpx.Request("GET", url),
+ )
+
+
+def test_find_ap_alternate_from_link_header():
+ """WordPress AP plugin: HTML body + Link header pointing at the AP object."""
+ response = _resp(
+ "https://blog.example/2026/04/22/post-slug/",
+ content=b"",
+ headers=[
+ ("content-type", "text/html; charset=UTF-8"),
+ (
+ "link",
+ "; "
+ 'rel="alternate"; type="application/activity+json"',
+ ),
+ ],
+ )
+ assert find_ap_alternate(response) == "https://blog.example/?p=15463"
+
+
+def test_find_ap_alternate_picks_ap_among_multiple_alternates():
+ """A page can advertise several alternates; we want the AP one."""
+ response = _resp(
+ "https://blog.example/post/",
+ content=b"",
+ headers=[
+ ("content-type", "text/html"),
+ (
+ "link",
+ ' ; rel="alternate"; type="application/rss+xml",'
+ ' ; rel="alternate"; type="application/activity+json"',
+ ),
+ ],
+ )
+ assert find_ap_alternate(response) == "https://blog.example/?p=42"
+
+
+def test_find_ap_alternate_resolves_relative_url():
+ response = _resp(
+ "https://blog.example/post/",
+ content=b"",
+ headers=[
+ ("content-type", "text/html"),
+ ("link", '?p=42>; rel="alternate"; type="application/activity+json"'),
+ ],
+ )
+ assert find_ap_alternate(response) == "https://blog.example/?p=42"
+
+
+def test_find_ap_alternate_from_html_link_tag():
+ """Fallback to in HTML."""
+ body = (
+ b""
+ b' '
+ b' '
+ b""
+ )
+ response = _resp(
+ "https://blog.example/post/",
+ content=body,
+ headers=[("content-type", "text/html; charset=utf-8")],
+ )
+ assert find_ap_alternate(response) == "https://blog.example/?p=42"
+
+
+def test_find_ap_alternate_returns_none_when_absent():
+ response = _resp(
+ "https://blog.example/post/",
+ content=b"",
+ headers=[("content-type", "text/html")],
+ )
+ assert find_ap_alternate(response) is None
+
+
+def test_find_ap_alternate_ignores_non_alternate_rels():
+ response = _resp(
+ "https://blog.example/post/",
+ content=b"",
+ headers=[
+ ("content-type", "text/html"),
+ (
+ "link",
+ '; rel="self"; type="application/activity+json"',
+ ),
+ ],
+ )
+ assert find_ap_alternate(response) is None
diff --git a/tests/core/test_ld.py b/tests/core/test_ld.py
index a7250cf1..af9f0ae5 100644
--- a/tests/core/test_ld.py
+++ b/tests/core/test_ld.py
@@ -2,7 +2,12 @@
from dateutil.tz import tzutc
-from core.ld import canonicalise, get_language, parse_ld_date
+from core.ld import (
+ canonicalise,
+ get_first_concrete_type,
+ get_language,
+ parse_ld_date,
+)
def test_parse_ld_date():
@@ -144,3 +149,32 @@ def test_get_language():
assert get_language({"contentMap": {"EN": "Hello
"}}) == "en"
assert get_language({"contentMap": {"und": "Hello
"}}) is None
assert get_language({}) is None
+
+
+def test_get_first_concrete_type():
+ """
+ JSON-LD permits "type" to be a list, so we have to cope with both forms
+ """
+ ACTOR_TYPES = ["person", "service", "application", "group", "organization"]
+
+ assert get_first_concrete_type("Person") == "person"
+ assert get_first_concrete_type(["Person"]) == "person"
+ # Generic AS base classes lose to a concrete sibling
+ assert get_first_concrete_type(["Object", "Note"]) == "note"
+ assert get_first_concrete_type(["Activity", "Create"]) == "create"
+ # ... but are still returned if that's all we got
+ assert get_first_concrete_type(["Collection"]) == "collection"
+
+ # A known type wins regardless of position, so a vocabulary-prefixed
+ # duplicate doesn't get stored as the actor type
+ assert get_first_concrete_type(["Person", "foaf:Person"], ACTOR_TYPES) == "person"
+ assert get_first_concrete_type(["foaf:Person", "Person"], ACTOR_TYPES) == "person"
+ # Nothing known: fall back to the first concrete type
+ assert get_first_concrete_type(["foaf:Person"], ACTOR_TYPES) == "foaf:person"
+
+ # Junk in, None out
+ assert get_first_concrete_type(None) is None
+ assert get_first_concrete_type("") is None
+ assert get_first_concrete_type([]) is None
+ assert get_first_concrete_type([{"id": "Person"}]) is None
+ assert get_first_concrete_type({"@value": "Person"}) is None
diff --git a/tests/mediaproxy/test_views.py b/tests/mediaproxy/test_views.py
new file mode 100644
index 00000000..3225b063
--- /dev/null
+++ b/tests/mediaproxy/test_views.py
@@ -0,0 +1,67 @@
+import pytest
+from django.templatetags.static import static
+
+
+def _icon_url(identity_id) -> str:
+ return f"/proxy/identity_icon/{identity_id}/"
+
+
+def _expected_default_avatar() -> str:
+ return static("img/avatar.png")
+
+
+@pytest.mark.django_db
+def test_missing_identity_redirects_to_default_avatar(client):
+ """A non-existent identity id serves the default avatar instead of 404."""
+ response = client.get(_icon_url(602895974513284306))
+ assert response.status_code == 302
+ assert response["Location"] == _expected_default_avatar()
+
+
+@pytest.mark.django_db
+def test_identity_without_icon_uri_redirects(client, remote_identity):
+ """A remote identity with no stored icon_uri serves the default avatar."""
+ assert not remote_identity.icon_uri
+ response = client.get(_icon_url(remote_identity.pk))
+ assert response.status_code == 302
+ assert response["Location"] == _expected_default_avatar()
+
+
+@pytest.mark.django_db
+def test_local_identity_redirects(client, identity):
+ """Local identities are not proxied; they serve the default avatar."""
+ assert identity.local
+ response = client.get(_icon_url(identity.pk))
+ assert response.status_code == 302
+ assert response["Location"] == _expected_default_avatar()
+
+
+@pytest.mark.django_db
+def test_remote_fetch_failure_redirects(client, remote_identity, httpx_mock):
+ """A failing remote fetch falls back to the default avatar rather than 502."""
+ remote_identity.icon_uri = "https://remote.test/missing-icon.png"
+ remote_identity.save()
+ httpx_mock.add_response(
+ url="https://remote.test/missing-icon.png",
+ status_code=404,
+ )
+ response = client.get(_icon_url(remote_identity.pk))
+ assert response.status_code == 302
+ assert response["Location"] == _expected_default_avatar()
+
+
+@pytest.mark.django_db
+def test_successful_proxy_returns_image(client, remote_identity, httpx_mock):
+ """A reachable remote icon is proxied through unchanged."""
+ remote_identity.icon_uri = "https://remote.test/icon.png"
+ remote_identity.save()
+ httpx_mock.add_response(
+ url="https://remote.test/icon.png",
+ status_code=200,
+ content=b"fake-png-bytes",
+ headers={"Content-Type": "image/png"},
+ )
+ response = client.get(_icon_url(remote_identity.pk))
+ assert response.status_code == 200
+ assert response["Content-Type"] == "image/png"
+ assert response.content == b"fake-png-bytes"
diff --git a/tests/users/models/test_follow.py b/tests/users/models/test_follow.py
index faec2367..e480b599 100644
--- a/tests/users/models/test_follow.py
+++ b/tests/users/models/test_follow.py
@@ -1,5 +1,6 @@
import json
+import httpx
import pytest
from pytest_httpx import HTTPXMock
@@ -56,3 +57,169 @@ def test_follow(
stator.run_single_cycle()
stator.run_single_cycle()
assert Follow.objects.get(pk=follow.pk).state == FollowStates.accepted
+
+
+@pytest.mark.django_db
+@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
+def test_follow_accepted_after_failed_delivery(
+ identity: Identity,
+ remote_identity: Identity,
+ stator,
+ httpx_mock: HTTPXMock,
+):
+ """
+ An Accept is honoured even when delivering the Follow looked like a
+ failure to us: the target may have processed it before we timed out.
+ """
+ follow = IdentityService(identity).follow(remote_identity)
+ httpx_mock.add_exception(httpx.ReadTimeout("timed out"))
+ stator.run_single_cycle()
+ assert Follow.objects.get(pk=follow.pk).state == FollowStates.unrequested
+ # The target accepted it regardless
+ InboxMessage.objects.create(
+ message={
+ "type": "Accept",
+ "id": "test",
+ "actor": remote_identity.actor_uri,
+ "object": f"{identity.actor_uri}follow/{follow.pk}/",
+ }
+ )
+ stator.run_single_cycle()
+ stator.run_single_cycle()
+ assert Follow.objects.get(pk=follow.pk).state == FollowStates.accepted
+
+
+@pytest.mark.django_db
+@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
+def test_follow_refused_delivery_is_not_resent(
+ identity: Identity,
+ remote_identity: Identity,
+ stator,
+ httpx_mock: HTTPXMock,
+):
+ """
+ A Follow refused with a 4xx is not sent again: servers deduplicating by
+ activity id (Lemmy) refuse every resend of one they already processed.
+ """
+ follow = IdentityService(identity).follow(remote_identity)
+ httpx_mock.add_response(
+ url="https://remote.test/@test/inbox/",
+ status_code=400,
+ content=b'{"error":"unknown","message":""}',
+ )
+ stator.run_single_cycle()
+ assert Follow.objects.get(pk=follow.pk).state == FollowStates.pending_approval
+ # Nothing resends it while it waits for an Accept
+ stator.run_single_cycle()
+ deliveries = httpx_mock.get_requests(
+ url="https://remote.test/@test/inbox/", method="POST"
+ )
+ assert len(deliveries) == 1
+
+
+@pytest.mark.django_db
+@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
+def test_follow_deferred_delivery_is_retried(
+ identity: Identity,
+ remote_identity: Identity,
+ stator,
+ httpx_mock: HTTPXMock,
+):
+ """
+ A Follow refused with a retryable status is sent again later.
+ """
+ follow = IdentityService(identity).follow(remote_identity)
+ httpx_mock.add_response(
+ url="https://remote.test/@test/inbox/",
+ status_code=429,
+ )
+ stator.run_single_cycle()
+ assert Follow.objects.get(pk=follow.pk).state == FollowStates.unrequested
+
+
+@pytest.mark.django_db
+@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
+def test_follow_unauthorized_delivery_is_rejected(
+ identity: Identity,
+ remote_identity: Identity,
+ stator,
+ httpx_mock: HTTPXMock,
+):
+ """
+ A Follow the target refuses to authorise is dropped rather than waiting
+ for an Accept that cannot arrive.
+ """
+ follow = IdentityService(identity).follow(remote_identity)
+ httpx_mock.add_response(
+ url="https://remote.test/@test/inbox/",
+ status_code=403,
+ )
+ stator.run_single_cycle()
+ assert Follow.objects.get(pk=follow.pk).state == FollowStates.rejecting
+
+
+def _stats(i: Identity) -> dict:
+ return Identity.objects.get(pk=i.pk).stats or {}
+
+
+@pytest.mark.django_db
+def test_follow_counts_track_state_changes(
+ identity: Identity,
+ other_identity: Identity,
+ stator,
+):
+ """#1616: stats must track follow, accept, unfollow and re-follow."""
+ # Following is an active outbound follow, but not yet an accepted follower.
+ IdentityService(identity).follow(other_identity)
+ assert _stats(identity).get("following_count") == 1
+ assert _stats(other_identity).get("followers_count") == 0
+
+ # Stator accepts the local follow.
+ stator.run_single_cycle()
+ stator.run_single_cycle()
+ assert (
+ Follow.objects.get(source=identity, target=other_identity).state
+ == FollowStates.accepted
+ )
+ assert _stats(identity).get("following_count") == 1
+ assert _stats(other_identity).get("followers_count") == 1
+
+ # Unfollowing drops both counts before the row is asynchronously removed.
+ IdentityService(identity).unfollow(other_identity)
+ assert Follow.objects.filter(source=identity, target=other_identity).exists()
+ assert _stats(identity).get("following_count") == 0
+ assert _stats(other_identity).get("followers_count") == 0
+
+ # Re-following reuses the still-present inactive row.
+ IdentityService(identity).follow(other_identity)
+ assert _stats(identity).get("following_count") == 1
+
+
+@pytest.mark.django_db
+def test_calculate_stats_ignores_inactive_follows(
+ identity: Identity,
+ identity_factory,
+):
+ """#1616: calculate_stats counts only active outbound and accepted inbound."""
+ followee_active = identity_factory(username="followeeactive")
+ followee_undone = identity_factory(username="followeeundone")
+ follower_accepted = identity_factory(username="followeraccepted")
+ follower_pending = identity_factory(username="followerpending")
+
+ Follow.objects.create(
+ source=identity, target=followee_active, state=FollowStates.accepted
+ )
+ Follow.objects.create(
+ source=identity, target=followee_undone, state=FollowStates.undone
+ )
+ Follow.objects.create(
+ source=follower_accepted, target=identity, state=FollowStates.accepted
+ )
+ Follow.objects.create(
+ source=follower_pending, target=identity, state=FollowStates.pending_approval
+ )
+
+ identity.calculate_stats()
+ stats = _stats(identity)
+ assert stats["following_count"] == 1
+ assert stats["followers_count"] == 1
diff --git a/tests/users/models/test_identity.py b/tests/users/models/test_identity.py
index 6ce707d3..880f1f9f 100644
--- a/tests/users/models/test_identity.py
+++ b/tests/users/models/test_identity.py
@@ -1,5 +1,7 @@
import httpx
import pytest
+from django.conf import settings
+from django.templatetags.static import static
from pytest_httpx import HTTPXMock
from core.models import Config
@@ -234,6 +236,123 @@ def test_fetch_actor(httpx_mock, config_system):
assert not identity.indexable
+@pytest.mark.django_db
+@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
+def test_fetch_actor_without_url_falls_back_to_actor_uri(httpx_mock, config_system):
+ """
+ Lemmy (and some other implementations) don't emit a top-level "url"; the
+ actor id is the web profile. profile_uri should fall back to actor_uri.
+ """
+ identity = Identity.objects.create(
+ actor_uri="https://lemmy.example/c/books",
+ local=False,
+ )
+ httpx_mock.add_response(
+ url="https://lemmy.example/.well-known/webfinger?resource=acct:books@lemmy.example",
+ headers={"Content-Type": "application/activity+json"},
+ json={
+ "subject": "acct:books@lemmy.example",
+ "links": [
+ {
+ "rel": "self",
+ "type": "application/activity+json",
+ "href": "https://lemmy.example/c/books",
+ },
+ ],
+ },
+ )
+ httpx_mock.add_response(
+ url="https://lemmy.example/c/books",
+ headers={"Content-Type": "application/activity+json"},
+ json={
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ "https://w3id.org/security/v1",
+ ],
+ "id": "https://lemmy.example/c/books",
+ "type": "Group",
+ "inbox": "https://lemmy.example/c/books/inbox",
+ "followers": "https://lemmy.example/c/books/followers",
+ "publicKey": {
+ "id": "https://lemmy.example/c/books#main-key",
+ "owner": "https://lemmy.example/c/books",
+ "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nits-a-faaaake\n-----END PUBLIC KEY-----\n",
+ },
+ "name": "Books",
+ "preferredUsername": "books",
+ "summary": "Book reader community.
",
+ "icon": {
+ "type": "Image",
+ "url": "https://lemmy.example/pictrs/image/books.png",
+ },
+ },
+ )
+ identity.fetch_actor()
+
+ identity = Identity.objects.get(pk=identity.pk)
+ assert identity.username == "books"
+ assert identity.actor_type == "group"
+ # No top-level "url" in the document -> fall back to the actor uri
+ assert identity.profile_uri == "https://lemmy.example/c/books"
+ assert identity.icon_uri == "https://lemmy.example/pictrs/image/books.png"
+
+
+@pytest.mark.django_db
+@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
+def test_fetch_actor_with_list_type(httpx_mock, config_system):
+ """
+ JSON-LD allows "type" to be a list, and ActivityPods-style servers emit
+ ["Person", "foaf:Person"]. It should resolve to the known actor type.
+ """
+ identity = Identity.objects.create(
+ actor_uri="https://pods.example/u/test",
+ local=False,
+ )
+ httpx_mock.add_response(
+ url="https://pods.example/.well-known/webfinger?resource=acct:test@pods.example",
+ headers={"Content-Type": "application/activity+json"},
+ json={
+ "subject": "acct:test@pods.example",
+ "links": [
+ {
+ "rel": "self",
+ "type": "application/activity+json",
+ "href": "https://pods.example/u/test",
+ },
+ ],
+ },
+ )
+ httpx_mock.add_response(
+ url="https://pods.example/u/test",
+ headers={"Content-Type": "application/activity+json"},
+ json={
+ "@context": [
+ "https://www.w3.org/ns/activitystreams",
+ "https://w3id.org/security/v1",
+ {"foaf": "http://xmlns.com/foaf/0.1/"},
+ ],
+ "id": "https://pods.example/u/test",
+ "type": ["Person", "foaf:Person"],
+ "inbox": "https://pods.example/u/test/inbox",
+ "followers": "https://pods.example/u/test/followers",
+ "publicKey": {
+ "id": "https://pods.example/u/test#main-key",
+ "owner": "https://pods.example/u/test",
+ "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nits-a-faaaake\n-----END PUBLIC KEY-----\n",
+ },
+ "name": "Test Pod User",
+ "preferredUsername": "test",
+ "url": "https://pods.example/u/test",
+ },
+ )
+ assert identity.fetch_actor()
+
+ identity = Identity.objects.get(pk=identity.pk)
+ assert identity.actor_type == "person"
+ assert identity.username == "test"
+ assert identity.name == "Test Pod User"
+
+
@pytest.mark.django_db
@pytest.mark.httpx_mock(assert_all_requests_were_expected=False)
def test_fetch_webfinger_url(httpx_mock: HTTPXMock, config_system):
@@ -314,3 +433,77 @@ def test_attachment_to_ap(identity: Identity, config_system):
''
'http:// example.com '
)
+
+
+@pytest.mark.django_db
+def test_default_icon_to_ap(identity: Identity, config_system):
+ """
+ Local identities without a custom avatar serve the default icon in AP,
+ while a custom icon_uri takes precedence.
+ """
+ response = identity.to_ap()
+
+ # Built from static() rather than hardcoded: the default icon must be at a
+ # URL this instance actually serves, whatever STATIC_URL is set to.
+ assert response["icon"] == {
+ "type": "Image",
+ "mediaType": "image/png",
+ "url": f"https://example.com{static('img/avatar.png')}",
+ }
+ assert response["icon"]["url"].startswith(
+ f"https://example.com{settings.STATIC_URL}"
+ )
+
+ identity.icon_uri = "https://example.com/icon.jpg"
+ response = identity.to_ap()
+
+ assert response["icon"]["url"] == "https://example.com/icon.jpg"
+
+
+@pytest.mark.django_db
+def test_default_icon_migration(identity_factory, domain):
+ """
+ The 0035 data migration clears stock avatar.svg icon_uri values and
+ transitions affected local identities to "edited" for AP fanout.
+ """
+ from importlib import import_module
+
+ from django.apps import apps
+
+ migration = import_module("users.migrations.0035_fanout_default_icon_update")
+
+ no_icon = identity_factory(username="noicon")
+ no_icon.state = "updated"
+ no_icon.save()
+ old_default = identity_factory(
+ username="olddefault", icon_uri="https://example.com/s/img/avatar.svg"
+ )
+ old_default.state = "updated"
+ old_default.save()
+ custom = identity_factory(
+ username="custom", icon_uri="https://example.com/custom.jpg"
+ )
+ custom.state = "updated"
+ custom.save()
+ remote = Identity.objects.create(
+ actor_uri="https://remote.example/@someone/",
+ username="someone",
+ domain=domain,
+ local=False,
+ icon_uri="https://remote.example/s/img/avatar.svg",
+ state="updated",
+ )
+
+ migration.update_default_icons(apps, None)
+
+ no_icon.refresh_from_db()
+ assert no_icon.state == "edited"
+ old_default.refresh_from_db()
+ assert old_default.icon_uri == ""
+ assert old_default.state == "edited"
+ custom.refresh_from_db()
+ assert custom.icon_uri == "https://example.com/custom.jpg"
+ assert custom.state == "updated"
+ remote.refresh_from_db()
+ assert remote.icon_uri == "https://remote.example/s/img/avatar.svg"
+ assert remote.state == "updated"
diff --git a/tests/users/views/test_identity_feed.py b/tests/users/views/test_identity_feed.py
new file mode 100644
index 00000000..1de71266
--- /dev/null
+++ b/tests/users/views/test_identity_feed.py
@@ -0,0 +1,27 @@
+import pytest
+
+from activities.models import Post
+from users.views.identity import IdentityFeed
+
+
+@pytest.mark.django_db
+def test_item_description_appends_quote_link(config_system, identity):
+ post = Post.create_local(author=identity, content="Look here")
+ post.quote_url = "https://remote.test/posts/abc"
+ snippet = 'Quote: https://remote.test/posts/abc
'
+ assert snippet in IdentityFeed().item_description(post)
+
+
+@pytest.mark.django_db
+def test_item_description_escapes_quote_url(config_system, identity):
+ post = Post.create_local(author=identity, content="Look here")
+ post.quote_url = 'https://remote.test/posts/">