changeset 3733:6cc39a3b8c14

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
author Goffi <goffi@goffi.org>
date Mon, 31 Jan 2022 18:35:52 +0100
parents 0fac164ff2d8
children 643622ff1492
files tests/unit/test_ap-gateway.py
diffstat 1 files changed, 587 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /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 <http://www.gnu.org/licenses/>.
+
+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": "<p>test message 1</p>",
+                    "contentMap": {
+                        "en": "<p>test message 1</p>"
+                    },
+                    "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": "<p>test message 2</p>",
+                    "contentMap": {
+                        "en": "<p>test message 2</p>"
+                    },
+                    "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": "<p>test message 3</p>",
+                    "contentMap": {
+                        "en": "<p>test message 3</p>"
+                    },
+                    "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": "<p>test message 4</p>",
+                    "contentMap": {
+                        "en": "<p>test message 4</p>"
+                    },
+                    "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 = """
+<item id='{id}' publisher='{publisher_jid}'>
+  <entry xmlns='http://www.w3.org/2005/Atom' xml:lang='en'>
+    <title type='xhtml'>
+      <div xmlns='http://www.w3.org/1999/xhtml'>
+        <p>
+          XMPP item {id}
+        </p>
+      </div>
+    </title>
+    <title type='text'>
+      XMPP item {id}
+    </title>
+    <author>
+      <name>
+        test_user
+      </name>
+      <uri>
+        xmpp:{publisher_jid}
+      </uri>
+    </author>
+    <updated>
+      {updated}
+    </updated>
+    <published>
+      {published}
+    </published>
+    <id>
+      xmpp:{publisher_jid}?;node=urn%3Axmpp%3Amicroblog%3A0;item={id}
+    </id>
+  </entry>
+</item>
+"""
+
+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() == (
+            "<title xmlns='http://www.w3.org/2005/Atom' type='xhtml'>"
+            "<div xmlns='http://www.w3.org/1999/xhtml'><p>test message 4</p></div>"
+            "</title>"
+        )
+        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() == (
+            "<title xmlns='http://www.w3.org/2005/Atom' type='xhtml'>"
+            "<div xmlns='http://www.w3.org/1999/xhtml'><p>test message 3</p></div>"
+            "</title>"
+        )
+        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() == (
+            "<title xmlns='http://www.w3.org/2005/Atom' type='xhtml'>"
+            "<div xmlns='http://www.w3.org/1999/xhtml'><p>test message 2</p></div>"
+            "</title>"
+        )
+        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() == (
+            "<title xmlns='http://www.w3.org/2005/Atom' type='xhtml'>"
+            "<div xmlns='http://www.w3.org/1999/xhtml'><p>test message 1</p></div>"
+            "</title>"
+        )
+        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() == (
+            "<title xmlns='http://www.w3.org/2005/Atom' type='xhtml'>"
+            "<div xmlns='http://www.w3.org/1999/xhtml'><p>test message 2</p></div>"
+            "</title>"
+        )
+        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() == (
+            "<title xmlns='http://www.w3.org/2005/Atom' type='xhtml'>"
+            "<div xmlns='http://www.w3.org/1999/xhtml'><p>test message 3</p></div>"
+            "</title>"
+        )
+        assert items[2].entry.title.toXml() == (
+            "<title xmlns='http://www.w3.org/2005/Atom' type='xhtml'>"
+            "<div xmlns='http://www.w3.org/1999/xhtml'><p>test message 1</p></div>"
+            "</title>"
+        )
+
+    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"] == "<div><p>XMPP item 4</p></div>"
+        assert first_item_obj["to"] == "https://www.w3.org/ns/activitystreams#Public"