# HG changeset patch # User Goffi # Date 1643650552 -3600 # Node ID 6cc39a3b8c14f6bc125cb0b6fe39f118f0ff6b60 # Parent 0fac164ff2d83007fc2e58acd4cdf8daa287bb49 tests (unit): AP gateway unit tests: are covered: - AP actor handle to XMPP JID/pubsub node - XMPP JID/pubsub node to AP actor handle - AP request to JID/pubsub node (AP collection to items/RSM metadata conversion) - pubsub request to AP actor (pubsub request with RSM to AP collection/pagination requests conversion) ticket 363 diff -r 0fac164ff2d8 -r 6cc39a3b8c14 tests/unit/test_ap-gateway.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/unit/test_ap-gateway.py Mon Jan 31 18:35:52 2022 +0100 @@ -0,0 +1,587 @@ +#!/usr/bin/env python3 + +# Libervia: an XMPP client +# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from copy import deepcopy +from unittest.mock import MagicMock, patch +from urllib import parse + +import pytest +from pytest_twisted import ensureDeferred as ed +from twisted.words.protocols.jabber import jid +from twisted.web.server import Request +from wokkel import rsm, pubsub + +from sat.core import exceptions +from sat.plugins import plugin_comp_ap_gateway +from sat.plugins.plugin_comp_ap_gateway.http_server import HTTPServer +from sat.tools.utils import xmpp_date +from sat.tools import xml_tools + + +TEST_BASE_URL = "https://example.org" +TEST_USER = "test_user" +TEST_AP_ACCOUNT = f"{TEST_USER}@example.org" + +AP_REQUESTS = { + f"{TEST_BASE_URL}/.well-known/webfinger?" + f"resource=acct:{parse.quote(TEST_AP_ACCOUNT)}": { + "aliases": [ + f"{TEST_BASE_URL}/@{TEST_USER}", + f"{TEST_BASE_URL}/users/{TEST_USER}" + ], + "links": [ + { + "href": f"{TEST_BASE_URL}/users/{TEST_USER}", + "rel": "self", + "type": "application/activity+json" + }, + ], + "subject": f"acct:{TEST_AP_ACCOUNT}" + }, + + f"{TEST_BASE_URL}/users/{TEST_USER}": { + "@context": [ + "https://www.w3.org/ns/activitystreams", + ], + "endpoints": { + "sharedInbox": f"{TEST_BASE_URL}/inbox" + }, + "followers": f"{TEST_BASE_URL}/users/{TEST_USER}/followers", + "following": f"{TEST_BASE_URL}/users/{TEST_USER}/following", + "id": f"{TEST_BASE_URL}/users/{TEST_USER}", + "inbox": f"{TEST_BASE_URL}/users/{TEST_USER}/inbox", + "name": "", + "outbox": f"{TEST_BASE_URL}/users/{TEST_USER}/outbox", + "preferredUsername": f"{TEST_USER}", + "type": "Person", + "url": f"{TEST_BASE_URL}/@{TEST_USER}" + }, + + f"{TEST_BASE_URL}/users/{TEST_USER}/outbox": { + "@context": "https://www.w3.org/ns/activitystreams", + "first": f"{TEST_BASE_URL}/users/{TEST_USER}/outbox?page=true", + "id": f"{TEST_BASE_URL}/users/{TEST_USER}/outbox", + "last": f"{TEST_BASE_URL}/users/{TEST_USER}/outbox?page=true", + "totalItems": 4, + "type": "OrderedCollection" + }, + f"{TEST_BASE_URL}/users/{TEST_USER}/outbox?page=true": { + "@context": [ + "https://www.w3.org/ns/activitystreams", + ], + "id": f"{TEST_BASE_URL}/users/{TEST_USER}/outbox?page=true", + "orderedItems": [ + { + "actor": f"{TEST_BASE_URL}/users/{TEST_USER}", + "cc": [ + f"{TEST_BASE_URL}/users/{TEST_USER}/followers", + ], + "id": f"{TEST_BASE_URL}/users/{TEST_USER}/statuses/1/activity", + "object": { + "attributedTo": f"{TEST_BASE_URL}/users/{TEST_USER}", + "cc": [ + f"{TEST_BASE_URL}/users/{TEST_USER}/followers", + ], + "content": "

test message 1

", + "contentMap": { + "en": "

test message 1

" + }, + "id": f"{TEST_BASE_URL}/users/{TEST_USER}/statuses/1", + "inReplyTo": None, + "published": "2021-12-16T17:28:03Z", + "sensitive": False, + "summary": None, + "tag": [], + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "type": "Note", + "url": f"{TEST_BASE_URL}/@{TEST_USER}/1" + }, + "published": "2021-12-16T17:28:03Z", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "type": "Create" + }, + { + "actor": f"{TEST_BASE_URL}/users/{TEST_USER}", + "cc": [ + f"{TEST_BASE_URL}/users/{TEST_USER}/followers", + ], + "id": f"{TEST_BASE_URL}/users/{TEST_USER}/statuses/2/activity", + "object": { + "attributedTo": f"{TEST_BASE_URL}/users/{TEST_USER}", + "cc": [ + f"{TEST_BASE_URL}/users/{TEST_USER}/followers", + ], + "content": "

test message 2

", + "contentMap": { + "en": "

test message 2

" + }, + "id": f"{TEST_BASE_URL}/users/{TEST_USER}/statuses/2", + "inReplyTo": None, + "published": "2021-12-16T17:27:03Z", + "sensitive": False, + "summary": None, + "tag": [], + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "type": "Note", + "url": f"{TEST_BASE_URL}/@{TEST_USER}/2" + }, + "published": "2021-12-16T17:27:03Z", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "type": "Create" + }, + { + "actor": f"{TEST_BASE_URL}/users/{TEST_USER}", + "cc": [ + f"{TEST_BASE_URL}/users/{TEST_USER}/followers", + ], + "id": f"{TEST_BASE_URL}/users/{TEST_USER}/statuses/3/activity", + "object": { + "attributedTo": f"{TEST_BASE_URL}/users/{TEST_USER}", + "cc": [ + f"{TEST_BASE_URL}/users/{TEST_USER}/followers", + ], + "content": "

test message 3

", + "contentMap": { + "en": "

test message 3

" + }, + "id": f"{TEST_BASE_URL}/users/{TEST_USER}/statuses/3", + "inReplyTo": None, + "published": "2021-12-16T17:26:03Z", + "sensitive": False, + "summary": None, + "tag": [], + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "type": "Note", + "url": f"{TEST_BASE_URL}/@{TEST_USER}/3" + }, + "published": "2021-12-16T17:26:03Z", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "type": "Create" + }, + { + "actor": f"{TEST_BASE_URL}/users/{TEST_USER}", + "cc": [ + f"{TEST_BASE_URL}/users/{TEST_USER}/followers", + ], + "id": f"{TEST_BASE_URL}/users/{TEST_USER}/statuses/4/activity", + "object": { + "attributedTo": f"{TEST_BASE_URL}/users/{TEST_USER}", + "cc": [ + f"{TEST_BASE_URL}/users/{TEST_USER}/followers", + ], + "content": "

test message 4

", + "contentMap": { + "en": "

test message 4

" + }, + "id": f"{TEST_BASE_URL}/users/{TEST_USER}/statuses/4", + "inReplyTo": None, + "published": "2021-12-16T17:25:03Z", + "sensitive": False, + "summary": None, + "tag": [], + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "type": "Note", + "url": f"{TEST_BASE_URL}/@{TEST_USER}/4" + }, + "published": "2021-12-16T17:25:03Z", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "type": "Create" + }, + ], + "partOf": f"{TEST_BASE_URL}/users/{TEST_USER}/outbox", + "prev": None, + "type": "OrderedCollectionPage" + } + +} + +XMPP_ITEM_TPL = """ + + + + <div xmlns='http://www.w3.org/1999/xhtml'> + <p> + XMPP item {id} + </p> + </div> + + + XMPP item {id} + + + + test_user + + + xmpp:{publisher_jid} + + + + {updated} + + + {published} + + + xmpp:{publisher_jid}?;node=urn%3Axmpp%3Amicroblog%3A0;item={id} + + + +""" + +ITEM_BASE_TS = 1643385499 +XMPP_ITEMS = [ + xml_tools.parse( + "".join( + l.strip() for l in XMPP_ITEM_TPL.format( + id=i, + publisher_jid="some_user@test.example", + updated=xmpp_date(ITEM_BASE_TS + i * 60), + published=xmpp_date(ITEM_BASE_TS + i * 60), + ).split("\n") + ), + namespace=pubsub.NS_PUBSUB + ) + for i in range(1, 5) +] + +async def mock_ap_get(url): + return deepcopy(AP_REQUESTS[url]) + + +async def mock_treq_json(data): + return dict(data) + + +async def mock_getItems(*args, **kwargs): + rsm_resp = rsm.RSMResponse( + first=XMPP_ITEMS[0]["id"], + last=XMPP_ITEMS[-1]["id"], + index=0, + count=len(XMPP_ITEMS) + ) + return XMPP_ITEMS, {"rsm": rsm_resp.toDict(), "complete": True} + + +@pytest.fixture(scope="session") +def ap_gateway(host): + gateway = plugin_comp_ap_gateway.APGateway(host) + gateway.initialised = True + client = MagicMock() + client.jid = jid.JID("ap.test.example") + client.host = "test.example" + gateway.client = client + gateway.local_only = True + gateway.public_url = "test.example" + gateway.ap_path = '_ap' + gateway.base_ap_url = parse.urljoin( + f"https://{gateway.public_url}", + f"{gateway.ap_path}/" + ) + gateway.server = HTTPServer(gateway) + return gateway + + +class TestActivityPubGateway: + + @ed + async def test_jid_and_node_convert_to_ap_handle(self, ap_gateway): + """JID and pubsub node are converted correctly to an AP actor handle""" + get_account = ap_gateway.getAPAccountFromJidAndNode + + # local jid + assert await get_account( + jid_ = jid.JID("simple@test.example"), + node = None + ) == "simple@test.example" + + # non local jid + assert await get_account( + jid_ = jid.JID("simple@example.org"), + node = None + ) == "___simple.40example.2eorg@ap.test.example" + + # local jid with non microblog node + assert await get_account( + jid_ = jid.JID("simple@test.example"), + node = "some_other_node" + ) == "some_other_node---simple@test.example" + + # local pubsub node + with patch.object(ap_gateway, "isPubsub") as isPubsub: + isPubsub.return_value = True + assert await get_account( + jid_ = jid.JID("pubsub.test.example"), + node = "some_node" + ) == "some_node@pubsub.test.example" + + # non local pubsub node + with patch.object(ap_gateway, "isPubsub") as isPubsub: + isPubsub.return_value = True + assert await get_account( + jid_ = jid.JID("pubsub.example.org"), + node = "some_node" + ) == "___some_node.40pubsub.2eexample.2eorg@ap.test.example" + + @ed + async def test_ap_handle_convert_to_jid_and_node(self, ap_gateway, monkeypatch): + """AP actor handle convert correctly to JID and pubsub node""" + get_jid_node = ap_gateway.getJIDAndNode + + # for following assertion, host is not a pubsub service + with patch.object(ap_gateway, "isPubsub") as isPubsub: + isPubsub.return_value = False + + # simple local jid + assert await get_jid_node( + "toto@test.example" + ) == (jid.JID("toto@test.example"), None) + + # simple external jid + + ## with "local_only" set, it should raise an exception + with pytest.raises(exceptions.PermissionError): + await get_jid_node("toto@example.org") + + ## with "local_only" unset, it should work + with monkeypatch.context() as m: + m.setattr(ap_gateway, "local_only", False, raising=True) + assert await get_jid_node( + "toto@example.org" + ) == (jid.JID("toto@example.org"), None) + + # explicit node + assert await get_jid_node( + "tata---toto@test.example" + ) == (jid.JID("toto@test.example"), "tata") + + # for following assertion, host is a pubsub service + with patch.object(ap_gateway, "isPubsub") as isPubsub: + isPubsub.return_value = True + + # simple local node + assert await get_jid_node( + "toto@pubsub.test.example" + ) == (jid.JID("pubsub.test.example"), "toto") + + # encoded local node + assert await get_jid_node( + "___urn.3axmpp.3amicroblog.3a0@pubsub.test.example" + ) == (jid.JID("pubsub.test.example"), "urn:xmpp:microblog:0") + + @ed + async def test_ap_to_pubsub_conversion(self, ap_gateway, monkeypatch): + """AP requests are converted to pubsub""" + monkeypatch.setattr(plugin_comp_ap_gateway.treq, "get", mock_ap_get) + monkeypatch.setattr(plugin_comp_ap_gateway.treq, "json_content", mock_treq_json) + monkeypatch.setattr(ap_gateway, "apGet", mock_ap_get) + + items, rsm_resp = await ap_gateway.getAPItems(TEST_AP_ACCOUNT, 2) + + assert rsm_resp.count == 4 + assert rsm_resp.index == 0 + assert rsm_resp.first == "https://example.org/users/test_user/statuses/4" + assert rsm_resp.last == "https://example.org/users/test_user/statuses/3" + + assert items[0].entry.title.toXml() == ( + "" + "<div xmlns='http://www.w3.org/1999/xhtml'><p>test message 4</p></div>" + "" + ) + author_uri = str( + [e for e in items[0].entry.author.elements() if e.name == "uri"][0] + ) + assert author_uri == "xmpp:test_user\\40example.org@ap.test.example" + assert str(items[0].entry.published) == "2021-12-16T17:25:03Z" + + assert items[1].entry.title.toXml() == ( + "" + "<div xmlns='http://www.w3.org/1999/xhtml'><p>test message 3</p></div>" + "" + ) + author_uri = str( + [e for e in items[1].entry.author.elements() if e.name == "uri"][0] + ) + assert author_uri == "xmpp:test_user\\40example.org@ap.test.example" + assert str(items[1].entry.published) == "2021-12-16T17:26:03Z" + + items, rsm_resp = await ap_gateway.getAPItems( + TEST_AP_ACCOUNT, + max_items=2, + after_id="https://example.org/users/test_user/statuses/3", + ) + + assert rsm_resp.count == 4 + assert rsm_resp.index == 2 + assert rsm_resp.first == "https://example.org/users/test_user/statuses/2" + assert rsm_resp.last == "https://example.org/users/test_user/statuses/1" + + assert items[0].entry.title.toXml() == ( + "" + "<div xmlns='http://www.w3.org/1999/xhtml'><p>test message 2</p></div>" + "" + ) + author_uri = str( + [e for e in items[0].entry.author.elements() if e.name == "uri"][0] + ) + assert author_uri == "xmpp:test_user\\40example.org@ap.test.example" + assert str(items[0].entry.published) == "2021-12-16T17:27:03Z" + + assert items[1].entry.title.toXml() == ( + "" + "<div xmlns='http://www.w3.org/1999/xhtml'><p>test message 1</p></div>" + "" + ) + author_uri = str( + [e for e in items[1].entry.author.elements() if e.name == "uri"][0] + ) + assert author_uri == "xmpp:test_user\\40example.org@ap.test.example" + assert str(items[1].entry.published) == "2021-12-16T17:28:03Z" + + items, rsm_resp = await ap_gateway.getAPItems( + TEST_AP_ACCOUNT, + max_items=1, + start_index=2 + ) + + assert rsm_resp.count == 4 + assert rsm_resp.index == 2 + assert rsm_resp.first == "https://example.org/users/test_user/statuses/2" + assert rsm_resp.last == "https://example.org/users/test_user/statuses/2" + assert len(items) == 1 + + assert items[0].entry.title.toXml() == ( + "" + "<div xmlns='http://www.w3.org/1999/xhtml'><p>test message 2</p></div>" + "" + ) + assert str(items[0].entry.published) == "2021-12-16T17:27:03Z" + + items, rsm_resp = await ap_gateway.getAPItems( + TEST_AP_ACCOUNT, + max_items=3, + chronological_pagination=False + ) + assert rsm_resp.count == 4 + assert rsm_resp.index == 1 + assert rsm_resp.first == "https://example.org/users/test_user/statuses/3" + assert rsm_resp.last == "https://example.org/users/test_user/statuses/1" + assert len(items) == 3 + assert items[0].entry.title.toXml() == ( + "" + "<div xmlns='http://www.w3.org/1999/xhtml'><p>test message 3</p></div>" + "" + ) + assert items[2].entry.title.toXml() == ( + "" + "<div xmlns='http://www.w3.org/1999/xhtml'><p>test message 1</p></div>" + "" + ) + + def ap_request_params(self, ap_gateway, type_=None, url=None, query_data=None): + """Generate parameters for HTTPAPGServer's AP*Request + + @param type_: one of the AP query type (e.g. "outbox") + @param url: URL to query (mutually exclusif with type_) + @param query_data: query data as returned by parse.parse_qs + @return dict with kw params to use + """ + assert type_ and url is None or url and type_ is None + if type_ is not None: + path = f"_ap/{type_}/some_user%40test.example" + else: + url_parsed = parse.urlparse(url) + path = url_parsed.path.lstrip("/") + type_ = path.split("/")[1] + if query_data is None: + query_data = parse.parse_qs(url_parsed.query) + + if query_data: + uri = f"{path}?{parse.urlencode(query_data, doseq=True)}" + else: + uri = path + + test_jid = jid.JID("some_user@test.example") + request = Request(MagicMock()) + request.path = path.encode() + request.uri = uri.encode() + ap_url = parse.urljoin( + f"https://{ap_gateway.public_url}", + path + ) + kwargs = { + "request": request, + "account_jid": test_jid, + "node": None, + "ap_account": test_jid.full(), + "ap_url": ap_url, + } + if type_ == "outbox" and query_data: + kwargs["query_data"] = query_data + return kwargs + + @ed + async def test_pubsub_to_ap_conversion(self, ap_gateway, monkeypatch): + """Pubsub nodes are converted to AP collections""" + monkeypatch.setattr(ap_gateway._p, "getItems", mock_getItems) + outbox = await ap_gateway.server.resource.APOutboxRequest( + **self.ap_request_params(ap_gateway, "outbox") + ) + assert outbox["@context"] == "https://www.w3.org/ns/activitystreams" + assert outbox["id"] == "https://test.example/_ap/outbox/some_user%40test.example" + assert outbox["totalItems"] == len(XMPP_ITEMS) + assert outbox["type"] == "OrderedCollection" + assert outbox["first"] + assert outbox["last"] + + first_page = await ap_gateway.server.resource.APOutboxPageRequest( + **self.ap_request_params(ap_gateway, url=outbox["first"]) + ) + assert first_page["@context"] == "https://www.w3.org/ns/activitystreams" + assert first_page["id"] == "https://test.example/_ap/outbox/some_user%40test.example?page=first" + assert first_page["type"] == "OrderedCollectionPage" + assert first_page["partOf"] == outbox["id"] + assert len(first_page["orderedItems"]) == len(XMPP_ITEMS) + first_item = first_page["orderedItems"][0] + assert first_item["@context"] == "https://www.w3.org/ns/activitystreams" + assert first_item["id"] == "https://test.example/_ap/item/some_user%40test.example/4" + assert first_item["actor"] == "https://test.example/_ap/actor/some_user%40test.example" + assert first_item["type"] == "Create" + first_item_obj = first_item["object"] + assert first_item_obj["id"] == first_item["id"] + assert first_item_obj["type"] == "Note" + assert first_item_obj["published"] == "2022-01-28T16:02:19Z" + assert first_item_obj["attributedTo"] == first_item["actor"] + assert first_item_obj["content"] == "

XMPP item 4

" + assert first_item_obj["to"] == "https://www.w3.org/ns/activitystreams#Public"