diff --git a/tests/unit/test_ap-gateway.py b/tests/unit/test_ap-gateway.py
new file mode 100644
index 00000000..6fcedfbf
--- /dev/null
+++ b/tests/unit/test_ap-gateway.py
@@ -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 = """
+-
+
+
+
+
+
+ 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() == (
+ ""
+ ""
+ ""
+ )
+ 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() == (
+ ""
+ ""
+ ""
+ )
+ 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() == (
+ ""
+ ""
+ ""
+ )
+ 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() == (
+ ""
+ ""
+ ""
+ )
+ 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() == (
+ ""
+ ""
+ ""
+ )
+ 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() == (
+ ""
+ ""
+ ""
+ )
+ assert items[2].entry.title.toXml() == (
+ ""
+ ""
+ ""
+ )
+
+ 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"] == ""
+ assert first_item_obj["to"] == "https://www.w3.org/ns/activitystreams#Public"