diff --git a/plugins/Fediverse/activitypub.py b/plugins/Fediverse/activitypub.py
index bc2efd9b1..f23b61bfc 100644
--- a/plugins/Fediverse/activitypub.py
+++ b/plugins/Fediverse/activitypub.py
@@ -32,7 +32,6 @@ import os
import json
import email
import base64
-import datetime
import functools
import contextlib
import urllib.parse
@@ -193,20 +192,14 @@ def signed_request(url, headers=None, data=None):
instance_actor_url = get_instance_actor_url()
headers = gen.InsensitivePreservingDict(headers or {})
- if 'Date' not in headers:
- headers['Date'] = email.utils.formatdate(usegmt=True)
+ if "Date" not in headers:
+ headers["Date"] = email.utils.formatdate(usegmt=True)
if instance_actor_url:
parsed_url = urllib.parse.urlparse(url)
signed_headers = [
- (
- "(request-target)",
- method + " " + parsed_url.path,
- ),
- (
- "host",
- parsed_url.hostname,
- ),
+ ("(request-target)", method + " " + parsed_url.path),
+ ("host", parsed_url.hostname),
]
for (header_name, header_value) in headers.items():
signed_headers.append((header_name.lower(), header_value))
diff --git a/plugins/Fediverse/plugin.py b/plugins/Fediverse/plugin.py
index a438cf189..a3d3ddcc7 100644
--- a/plugins/Fediverse/plugin.py
+++ b/plugins/Fediverse/plugin.py
@@ -237,10 +237,14 @@ class Fediverse(callbacks.PluginRegexp):
"""
actor = self._get_actor(irc, username)
if "featured" not in actor:
- irc.error(_("No featured statuses."), Raise=True)
+ irc.reply(_("No featured statuses."))
+ return
statuses = json.loads(ap.signed_request(actor["featured"])).get(
"orderedItems", []
)
+ if not statuses:
+ irc.reply(_("No featured statuses."))
+ return
irc.replies(
filter(
bool, (self._format_status(irc, status) for status in statuses)
diff --git a/plugins/Fediverse/test.py b/plugins/Fediverse/test.py
index 07990d822..23599d688 100644
--- a/plugins/Fediverse/test.py
+++ b/plugins/Fediverse/test.py
@@ -28,7 +28,11 @@
###
-from supybot import conf
+import json
+import contextlib
+from multiprocessing import Manager
+
+from supybot import conf, utils
from supybot.test import *
@@ -62,13 +66,288 @@ rut+UhYHat9fo6950Wvxa4Iee9q0NOF0HUkD6WupcPyC0nSEex8Z6A==
-----END RSA PRIVATE KEY-----
"""
+HOSTMETA_URL = "https://example.org/.well-known/host-meta"
+HOSTMETA_DATA = b"""
+
My Biography
", + "url": "https://example.org/@someuser", + "manuallyApprovesFollowers": False, + "discoverable": True, + "publicKey": { + "id": "https://example.org/users/someuser#main-key", + "owner": "https://example.org/users/someuser", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkaY84E/OjpjF7Dgy/nC+\nySBCiQvSOKBpNl468XP1QiOiMsILC1ec2J+LpU1Tm0kAC+uY8budLx6Wt+oz+4FU\n/82S9j9jVkWPiNVHJSQHXi13F9YQ4+MwC8niKc+qsmKUL8crSbd7dmCnOBxhvJWf\nfwOk1TW4u1fxXqHMFuw5zdfDlmRlU2FLX1LYTOxLnGp/ef/BAykV3rz6VouhAQwO\nhRay7ZgI5zlT7NtCoA17I8YiYfEs7MH0nBMrKOMw5eR1WDf5Gw78C/IAZHP1WVMv\n63V3N71OrMSfCH20OZ1H2Gyov5GX4+NSx7HI26dMDldQWOb2rYS9d0/7qM2xNUK8\n3wIDAQAB\n-----END PUBLIC KEY-----\n", + }, + "attachment": [ + {"type": "PropertyValue", "name": "Pronoun", "value": "they"}, + {"type": "PropertyValue", "name": "Location", "value": "Somewhere"}, + ], + "endpoints": {"sharedInbox": "https://example.org/inbox"}, + "icon": { + "type": "Image", + "mediaType": "image/png", + "url": "https://assets.example.org/avatar.png", + }, + "image": { + "type": "Image", + "mediaType": "image/png", + "url": "https://assets.example.org/header.png", + }, +} +ACTOR_DATA = json.dumps(ACTOR_VALUE).encode() + +OUTBOX_URL = "https://example.org/users/someuser/outbox" +OUTBOX_VALUE = { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://example.org/users/someuser/outbox", + "type": "OrderedCollection", + "totalItems": 4835, + "first": "https://example.org/users/someuser/outbox?page=true", + "last": "https://example.org/users/someuser/outbox?min_id=0&page=true", +} +OUTBOX_DATA = json.dumps(OUTBOX_VALUE).encode() + +OUTBOX_FIRSTPAGE_URL = "https://example.org/users/someuser/outbox?page=true" +OUTBOX_FIRSTPAGE_VALUE = { + "@context": [ + "https://www.w3.org/ns/activitystreams", + { + "ostatus": "http://ostatus.org#", + "atomUri": "ostatus:atomUri", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "sensitive": "as:sensitive", + "toot": "http://joinmastodon.org/ns#", + "votersCount": "toot:votersCount", + "Emoji": "toot:Emoji", + "focalPoint": {"@container": "@list", "@id": "toot:focalPoint"}, + }, + ], + "id": "https://example.org/users/someuser/outbox?page=true", + "type": "OrderedCollectionPage", + "next": "https://example.org/users/someuser/outbox?max_id=104101144953797529&page=true", + "prev": "https://example.org/users/someuser/outbox?min_id=104135036335976677&page=true", + "partOf": "https://example.org/users/someuser/outbox", + "orderedItems": [ + { + "id": "https://example.org/users/someuser/statuses/104135036335976677/activity", + "type": "Create", + "actor": "https://example.org/users/someuser", + "published": "2020-05-08T01:23:45Z", + "to": ["https://example.org/users/someuser/followers"], + "cc": [ + "https://www.w3.org/ns/activitystreams#Public", + "https://example.com/users/FirstAuthor", + ], + "object": { + "id": "https://example.org/users/someuser/statuses/1234", + "type": "Note", + "summary": None, + "inReplyTo": "https://example.com/users/FirstAuthor/statuses/42", + "published": "2020-05-08T01:23:45Z", + "url": "https://example.org/@FirstAuthor/42", + "attributedTo": "https://example.org/users/someuser", + "to": ["https://example.org/users/someuser/followers"], + "cc": [ + "https://www.w3.org/ns/activitystreams#Public", + "https://example.com/users/FirstAuthor", + ], + "sensitive": False, + "atomUri": "https://example.org/users/someuser/statuses/1234", + "inReplyToAtomUri": "https://example.com/users/FirstAuthor/statuses/42", + "conversation": "tag:example.com,2020-05-08:objectId=aaaa:objectType=Conversation", + "content": '@FirstAuthor I am replying to you
', + "contentMap": { + "en": '@FirstAuthor I am replying to you
' + }, + "attachment": [], + "tag": [ + { + "type": "Mention", + "href": "https://example.com/users/FirstAuthor", + "name": "@FirstAuthor@example.com", + } + ], + "replies": { + "id": "https://example.org/users/someuser/statuses/1234/replies", + "type": "Collection", + "first": { + "type": "CollectionPage", + "next": "https://example.org/users/someuser/statuses/1234/replies?only_other_accounts=true&page=true", + "partOf": "https://example.org/users/someuser/statuses/1234/replies", + "items": [], + }, + }, + }, + }, + { + "id": "https://example.org/users/someuser/statuses/12345/activity", + "type": "Announce", + "actor": "https://example.org/users/someuser", + "published": "2020-05-05T11:22:33Z", + "to": ["https://example.org/users/someuser/followers"], + "cc": [ + "https://example.net/users/BoostedUser", + "https://www.w3.org/ns/activitystreams#Public", + ], + "object": "https://example.net/users/BoostedUser/statuses/123456", + "atomUri": "https://example.org/users/someuser/statuses/12345/activity", + }, + ], +} +OUTBOX_FIRSTPAGE_DATA = json.dumps(OUTBOX_FIRSTPAGE_VALUE).encode() + +BOOSTED_URL = ( + "https://example.net/users/BoostedUser/statuses/123456" +) +BOOSTED_VALUE = { + "@context": [ + "https://www.w3.org/ns/activitystreams", + { + "ostatus": "http://ostatus.org#", + "atomUri": "ostatus:atomUri", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "sensitive": "as:sensitive", + "toot": "http://joinmastodon.org/ns#", + "votersCount": "toot:votersCount", + "blurhash": "toot:blurhash", + "focalPoint": {"@container": "@list", "@id": "toot:focalPoint"}, + }, + ], + "id": "https://example.net/users/BoostedUser/statuses/123456", + "type": "Note", + "summary": None, + "inReplyTo": None, + "published": "2020-05-05T11:00:00Z", + "url": "https://example.net/@BoostedUser/123456", + "attributedTo": "https://example.net/users/BoostedUser", + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "cc": ["https://example.net/users/BoostedUser/followers"], + "sensitive": False, + "atomUri": "https://example.net/users/BoostedUser/statuses/123456", + "inReplyToAtomUri": None, + "conversation": "tag:example.net,2020-05-05:objectId=bbbbb:objectType=Conversation", + "content": "Status Content
", + "contentMap": {"en": "Status Content
"}, + "attachment": [ + { + "type": "Document", + "mediaType": "image/png", + "url": "https://example.net/system/media_attachments/image.png", + "name": "Alt Text", + "focalPoint": [0.0, 0.0], + } + ], + "tag": [], + "replies": { + "id": "https://example.net/users/BoostedUser/statuses/123456/replies", + "type": "Collection", + "first": { + "type": "CollectionPage", + "next": "https://example.net/users/BoostedUser/statuses/123456/replies?only_other_accounts=true&page=true", + "partOf": "https://example.net/users/BoostedUser/statuses/123456/replies", + "items": [], + }, + }, +} +BOOSTED_DATA = json.dumps(BOOSTED_VALUE).encode() + +BOOSTED_ACTOR_URL = "https://example.net/users/BoostedUser" +BOOSTED_ACTOR_VALUE = { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "toot": "http://joinmastodon.org/ns#", + "featured": {"@id": "toot:featured", "@type": "@id"}, + "alsoKnownAs": {"@id": "as:alsoKnownAs", "@type": "@id"}, + "movedTo": {"@id": "as:movedTo", "@type": "@id"}, + "schema": "http://schema.org#", + "PropertyValue": "schema:PropertyValue", + "value": "schema:value", + "IdentityProof": "toot:IdentityProof", + "discoverable": "toot:discoverable", + "focalPoint": {"@container": "@list", "@id": "toot:focalPoint"}, + }, + ], + "id": "https://example.net/users/BoostedUser", + "type": "Person", + "following": "https://example.net/users/BoostedUser/following", + "followers": "https://example.net/users/BoostedUser/followers", + "inbox": "https://example.net/users/BoostedUser/inbox", + "outbox": "https://example.net/users/BoostedUser/outbox", + "featured": "https://example.net/users/BoostedUser/collections/featured", + "preferredUsername": "BoostedUser", + "name": "Boosted User", + "url": "https://example.net/@BoostedUser", + "endpoints": {"sharedInbox": "https://example.net/inbox"}, +} +BOOSTED_ACTOR_DATA = json.dumps(BOOSTED_ACTOR_VALUE).encode() + + +class FediverseTestCase(ChannelPluginTestCase): plugins = ("Fediverse",) - config = { - "supybot.servers.http.port": 18080, - "supybot.servers.http.publicUrl": "https://particle18080.test.progval.net/", - } def setUp(self): super().setUp() @@ -79,18 +358,205 @@ class FediverseTestCase(PluginTestCase): with open(path, "wb") as fd: fd.write(PRIVATE_KEY) + @contextlib.contextmanager + def mockRequests(self, expected_requests): + with Manager() as m: + expected_requests = m.list(expected_requests) + original_getUrlContent = utils.web.getUrlContent + + @functools.wraps(original_getUrlContent) + def newf(url, headers={}, data=None): + self.assertIsNone(data, "Unexpected POST") + assert expected_requests, url + (expected_url, response) = expected_requests.pop(0) + self.assertEqual(url, expected_url, "Unexpected URL") + + if isinstance(response, bytes): + return response + elif isinstance(response, Exception): + raise response + else: + assert False, response + + utils.web.getUrlContent = newf + + try: + yield + finally: + utils.web.getUrlContent = original_getUrlContent + + self.assertEqual( + list(expected_requests), [], "Less requests than expected." + ) + if network: - def testProfile(self): + def testNetworkProfile(self): self.assertRegexp("profile @val@oc.todon.fr", "0E082B40E4376B1E") # TODO: add a test with an instance which only allows fetches # with valid signatures. - def testProfileUnknown(self): + def testNetworkProfileUnknown(self): self.assertResponse( "profile @nonexistinguser@oc.todon.fr", "Error: Unknown user @nonexistinguser@oc.todon.fr.", ) + def testFeaturedNone(self): + featured = { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://example.org/users/someuser/collections/featured", + "type": "OrderedCollection", + "orderedItems": [], + } + expected_requests = [ + (HOSTMETA_URL, HOSTMETA_DATA), + (WEBFINGER_URL, WEBFINGER_DATA), + (ACTOR_URL, ACTOR_DATA), + ( + "https://example.org/users/someuser/collections/featured", + json.dumps(featured).encode(), + ), + ] + with self.mockRequests(expected_requests): + self.assertResponse( + "featured @someuser@example.org", "No featured statuses." + ) + + def testFeaturedSome(self): + featured = { + "@context": [ + "https://www.w3.org/ns/activitystreams", + { + "ostatus": "http://ostatus.org#", + "atomUri": "ostatus:atomUri", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "sensitive": "as:sensitive", + "toot": "http://joinmastodon.org/ns#", + "votersCount": "toot:votersCount", + }, + ], + "id": "https://example.org/users/someuser/collections/featured", + "type": "OrderedCollection", + "orderedItems": [ + { + "id": "https://example.org/users/someuser/statuses/123456789", + "type": "Note", + "summary": None, + "inReplyTo": "https://example.org/users/someuser/statuses/100543712346463856", + "published": "2018-08-13T15:49:00Z", + "url": "https://example.org/@someuser/123456789", + "attributedTo": "https://example.org/users/someuser", + "to": ["https://example.org/users/someuser/followers"], + "cc": ["https://www.w3.org/ns/activitystreams#Public"], + "sensitive": False, + "atomUri": "https://example.org/users/someuser/statuses/123456789", + "inReplyToAtomUri": "https://example.org/users/someuser/statuses/100543712346463856", + "conversation": "tag:example.org,2018-08-13:objectId=3002048:objectType=Conversation", + "content": "This is a pinned toot
", + "contentMap": {"en": "This is a pinned toot
"}, + "attachment": [], + "tag": [], + "replies": { + "id": "https://example.org/users/someuser/statuses/123456789/replies", + "type": "Collection", + "first": { + "type": "CollectionPage", + "next": "https://example.org/users/someuser/statuses/123456789/replies?min_id=100723569923690076&page=true", + "partOf": "https://example.org/users/someuser/statuses/123456789/replies", + "items": [ + "https://example.org/users/someuser/statuses/100723569923690076" + ], + }, + }, + } + ], + } + + expected_requests = [ + (HOSTMETA_URL, HOSTMETA_DATA), + (WEBFINGER_URL, WEBFINGER_DATA), + (ACTOR_URL, ACTOR_DATA), + ( + "https://example.org/users/someuser/collections/featured", + json.dumps(featured).encode(), + ), + ] + + with self.mockRequests(expected_requests): + self.assertRegexp( + "featured @someuser@example.org", "This is a pinned toot" + ) + + def testProfile(self): + expected_requests = [ + (HOSTMETA_URL, HOSTMETA_DATA), + (WEBFINGER_URL, WEBFINGER_DATA), + (ACTOR_URL, ACTOR_DATA), + ] + + with self.mockRequests(expected_requests): + self.assertResponse( + "profile @someuser@example.org", + "\x02someuser\x02 (@someuser@example.org): My Biography", + ) + + def testProfileSnarfer(self): + with conf.supybot.plugins.Fediverse.usernameSnarfer.context(True): + expected_requests = [ + (HOSTMETA_URL, HOSTMETA_DATA), + (WEBFINGER_URL, WEBFINGER_DATA), + (ACTOR_URL, ACTOR_DATA), + ] + + with self.mockRequests(expected_requests): + self.assertSnarfResponse( + "aaa @someuser@example.org bbb", + "\x02someuser\x02 (@someuser@example.org): My Biography", + ) + + expected_requests = [ + (HOSTMETA_URL, HOSTMETA_DATA), + (WEBFINGER_URL, utils.web.Error("blah")), + ] + + with self.mockRequests(expected_requests): + self.assertSnarfNoResponse( + "aaa @nonexistinguser@example.org bbb", timeout=1 + ) + + def testProfileUnknown(self): + expected_requests = [ + (HOSTMETA_URL, HOSTMETA_DATA), + (WEBFINGER_URL, utils.web.Error("blah")), + ] + + with self.mockRequests(expected_requests): + self.assertResponse( + "profile @nonexistinguser@example.org", + "Error: Unknown user @nonexistinguser@example.org.", + ) + + def testStatuses(self): + expected_requests = [ + (HOSTMETA_URL, HOSTMETA_DATA), + (WEBFINGER_URL, WEBFINGER_DATA), + (ACTOR_URL, ACTOR_DATA), + (OUTBOX_URL, OUTBOX_DATA), + (OUTBOX_FIRSTPAGE_URL, OUTBOX_FIRSTPAGE_DATA), + (BOOSTED_URL, BOOSTED_DATA), + (BOOSTED_ACTOR_URL, BOOSTED_ACTOR_DATA), + ] + + with self.mockRequests(expected_requests): + self.assertResponse( + "statuses @someuser@example.org", + "\x02someuser (@someuser@example.org)\x02: " + + "@ FirstAuthor I am replying to you and " + + "\x02Boosted User (@BoostedUser@example.net)\x02: " + + "Status Content", + ) + # vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79: