comparison tests/unit/test_ap-gateway.py @ 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
children 04ecc8eeb81a
comparison
equal deleted inserted replaced
3732:0fac164ff2d8 3733:6cc39a3b8c14
1 #!/usr/bin/env python3
2
3 # Libervia: an XMPP client
4 # Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
5
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU Affero General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
10
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU Affero General Public License for more details.
15
16 # You should have received a copy of the GNU Affero General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
18
19 from copy import deepcopy
20 from unittest.mock import MagicMock, patch
21 from urllib import parse
22
23 import pytest
24 from pytest_twisted import ensureDeferred as ed
25 from twisted.words.protocols.jabber import jid
26 from twisted.web.server import Request
27 from wokkel import rsm, pubsub
28
29 from sat.core import exceptions
30 from sat.plugins import plugin_comp_ap_gateway
31 from sat.plugins.plugin_comp_ap_gateway.http_server import HTTPServer
32 from sat.tools.utils import xmpp_date
33 from sat.tools import xml_tools
34
35
36 TEST_BASE_URL = "https://example.org"
37 TEST_USER = "test_user"
38 TEST_AP_ACCOUNT = f"{TEST_USER}@example.org"
39
40 AP_REQUESTS = {
41 f"{TEST_BASE_URL}/.well-known/webfinger?"
42 f"resource=acct:{parse.quote(TEST_AP_ACCOUNT)}": {
43 "aliases": [
44 f"{TEST_BASE_URL}/@{TEST_USER}",
45 f"{TEST_BASE_URL}/users/{TEST_USER}"
46 ],
47 "links": [
48 {
49 "href": f"{TEST_BASE_URL}/users/{TEST_USER}",
50 "rel": "self",
51 "type": "application/activity+json"
52 },
53 ],
54 "subject": f"acct:{TEST_AP_ACCOUNT}"
55 },
56
57 f"{TEST_BASE_URL}/users/{TEST_USER}": {
58 "@context": [
59 "https://www.w3.org/ns/activitystreams",
60 ],
61 "endpoints": {
62 "sharedInbox": f"{TEST_BASE_URL}/inbox"
63 },
64 "followers": f"{TEST_BASE_URL}/users/{TEST_USER}/followers",
65 "following": f"{TEST_BASE_URL}/users/{TEST_USER}/following",
66 "id": f"{TEST_BASE_URL}/users/{TEST_USER}",
67 "inbox": f"{TEST_BASE_URL}/users/{TEST_USER}/inbox",
68 "name": "",
69 "outbox": f"{TEST_BASE_URL}/users/{TEST_USER}/outbox",
70 "preferredUsername": f"{TEST_USER}",
71 "type": "Person",
72 "url": f"{TEST_BASE_URL}/@{TEST_USER}"
73 },
74
75 f"{TEST_BASE_URL}/users/{TEST_USER}/outbox": {
76 "@context": "https://www.w3.org/ns/activitystreams",
77 "first": f"{TEST_BASE_URL}/users/{TEST_USER}/outbox?page=true",
78 "id": f"{TEST_BASE_URL}/users/{TEST_USER}/outbox",
79 "last": f"{TEST_BASE_URL}/users/{TEST_USER}/outbox?page=true",
80 "totalItems": 4,
81 "type": "OrderedCollection"
82 },
83 f"{TEST_BASE_URL}/users/{TEST_USER}/outbox?page=true": {
84 "@context": [
85 "https://www.w3.org/ns/activitystreams",
86 ],
87 "id": f"{TEST_BASE_URL}/users/{TEST_USER}/outbox?page=true",
88 "orderedItems": [
89 {
90 "actor": f"{TEST_BASE_URL}/users/{TEST_USER}",
91 "cc": [
92 f"{TEST_BASE_URL}/users/{TEST_USER}/followers",
93 ],
94 "id": f"{TEST_BASE_URL}/users/{TEST_USER}/statuses/1/activity",
95 "object": {
96 "attributedTo": f"{TEST_BASE_URL}/users/{TEST_USER}",
97 "cc": [
98 f"{TEST_BASE_URL}/users/{TEST_USER}/followers",
99 ],
100 "content": "<p>test message 1</p>",
101 "contentMap": {
102 "en": "<p>test message 1</p>"
103 },
104 "id": f"{TEST_BASE_URL}/users/{TEST_USER}/statuses/1",
105 "inReplyTo": None,
106 "published": "2021-12-16T17:28:03Z",
107 "sensitive": False,
108 "summary": None,
109 "tag": [],
110 "to": [
111 "https://www.w3.org/ns/activitystreams#Public"
112 ],
113 "type": "Note",
114 "url": f"{TEST_BASE_URL}/@{TEST_USER}/1"
115 },
116 "published": "2021-12-16T17:28:03Z",
117 "to": [
118 "https://www.w3.org/ns/activitystreams#Public"
119 ],
120 "type": "Create"
121 },
122 {
123 "actor": f"{TEST_BASE_URL}/users/{TEST_USER}",
124 "cc": [
125 f"{TEST_BASE_URL}/users/{TEST_USER}/followers",
126 ],
127 "id": f"{TEST_BASE_URL}/users/{TEST_USER}/statuses/2/activity",
128 "object": {
129 "attributedTo": f"{TEST_BASE_URL}/users/{TEST_USER}",
130 "cc": [
131 f"{TEST_BASE_URL}/users/{TEST_USER}/followers",
132 ],
133 "content": "<p>test message 2</p>",
134 "contentMap": {
135 "en": "<p>test message 2</p>"
136 },
137 "id": f"{TEST_BASE_URL}/users/{TEST_USER}/statuses/2",
138 "inReplyTo": None,
139 "published": "2021-12-16T17:27:03Z",
140 "sensitive": False,
141 "summary": None,
142 "tag": [],
143 "to": [
144 "https://www.w3.org/ns/activitystreams#Public"
145 ],
146 "type": "Note",
147 "url": f"{TEST_BASE_URL}/@{TEST_USER}/2"
148 },
149 "published": "2021-12-16T17:27:03Z",
150 "to": [
151 "https://www.w3.org/ns/activitystreams#Public"
152 ],
153 "type": "Create"
154 },
155 {
156 "actor": f"{TEST_BASE_URL}/users/{TEST_USER}",
157 "cc": [
158 f"{TEST_BASE_URL}/users/{TEST_USER}/followers",
159 ],
160 "id": f"{TEST_BASE_URL}/users/{TEST_USER}/statuses/3/activity",
161 "object": {
162 "attributedTo": f"{TEST_BASE_URL}/users/{TEST_USER}",
163 "cc": [
164 f"{TEST_BASE_URL}/users/{TEST_USER}/followers",
165 ],
166 "content": "<p>test message 3</p>",
167 "contentMap": {
168 "en": "<p>test message 3</p>"
169 },
170 "id": f"{TEST_BASE_URL}/users/{TEST_USER}/statuses/3",
171 "inReplyTo": None,
172 "published": "2021-12-16T17:26:03Z",
173 "sensitive": False,
174 "summary": None,
175 "tag": [],
176 "to": [
177 "https://www.w3.org/ns/activitystreams#Public"
178 ],
179 "type": "Note",
180 "url": f"{TEST_BASE_URL}/@{TEST_USER}/3"
181 },
182 "published": "2021-12-16T17:26:03Z",
183 "to": [
184 "https://www.w3.org/ns/activitystreams#Public"
185 ],
186 "type": "Create"
187 },
188 {
189 "actor": f"{TEST_BASE_URL}/users/{TEST_USER}",
190 "cc": [
191 f"{TEST_BASE_URL}/users/{TEST_USER}/followers",
192 ],
193 "id": f"{TEST_BASE_URL}/users/{TEST_USER}/statuses/4/activity",
194 "object": {
195 "attributedTo": f"{TEST_BASE_URL}/users/{TEST_USER}",
196 "cc": [
197 f"{TEST_BASE_URL}/users/{TEST_USER}/followers",
198 ],
199 "content": "<p>test message 4</p>",
200 "contentMap": {
201 "en": "<p>test message 4</p>"
202 },
203 "id": f"{TEST_BASE_URL}/users/{TEST_USER}/statuses/4",
204 "inReplyTo": None,
205 "published": "2021-12-16T17:25:03Z",
206 "sensitive": False,
207 "summary": None,
208 "tag": [],
209 "to": [
210 "https://www.w3.org/ns/activitystreams#Public"
211 ],
212 "type": "Note",
213 "url": f"{TEST_BASE_URL}/@{TEST_USER}/4"
214 },
215 "published": "2021-12-16T17:25:03Z",
216 "to": [
217 "https://www.w3.org/ns/activitystreams#Public"
218 ],
219 "type": "Create"
220 },
221 ],
222 "partOf": f"{TEST_BASE_URL}/users/{TEST_USER}/outbox",
223 "prev": None,
224 "type": "OrderedCollectionPage"
225 }
226
227 }
228
229 XMPP_ITEM_TPL = """
230 <item id='{id}' publisher='{publisher_jid}'>
231 <entry xmlns='http://www.w3.org/2005/Atom' xml:lang='en'>
232 <title type='xhtml'>
233 <div xmlns='http://www.w3.org/1999/xhtml'>
234 <p>
235 XMPP item {id}
236 </p>
237 </div>
238 </title>
239 <title type='text'>
240 XMPP item {id}
241 </title>
242 <author>
243 <name>
244 test_user
245 </name>
246 <uri>
247 xmpp:{publisher_jid}
248 </uri>
249 </author>
250 <updated>
251 {updated}
252 </updated>
253 <published>
254 {published}
255 </published>
256 <id>
257 xmpp:{publisher_jid}?;node=urn%3Axmpp%3Amicroblog%3A0;item={id}
258 </id>
259 </entry>
260 </item>
261 """
262
263 ITEM_BASE_TS = 1643385499
264 XMPP_ITEMS = [
265 xml_tools.parse(
266 "".join(
267 l.strip() for l in XMPP_ITEM_TPL.format(
268 id=i,
269 publisher_jid="some_user@test.example",
270 updated=xmpp_date(ITEM_BASE_TS + i * 60),
271 published=xmpp_date(ITEM_BASE_TS + i * 60),
272 ).split("\n")
273 ),
274 namespace=pubsub.NS_PUBSUB
275 )
276 for i in range(1, 5)
277 ]
278
279 async def mock_ap_get(url):
280 return deepcopy(AP_REQUESTS[url])
281
282
283 async def mock_treq_json(data):
284 return dict(data)
285
286
287 async def mock_getItems(*args, **kwargs):
288 rsm_resp = rsm.RSMResponse(
289 first=XMPP_ITEMS[0]["id"],
290 last=XMPP_ITEMS[-1]["id"],
291 index=0,
292 count=len(XMPP_ITEMS)
293 )
294 return XMPP_ITEMS, {"rsm": rsm_resp.toDict(), "complete": True}
295
296
297 @pytest.fixture(scope="session")
298 def ap_gateway(host):
299 gateway = plugin_comp_ap_gateway.APGateway(host)
300 gateway.initialised = True
301 client = MagicMock()
302 client.jid = jid.JID("ap.test.example")
303 client.host = "test.example"
304 gateway.client = client
305 gateway.local_only = True
306 gateway.public_url = "test.example"
307 gateway.ap_path = '_ap'
308 gateway.base_ap_url = parse.urljoin(
309 f"https://{gateway.public_url}",
310 f"{gateway.ap_path}/"
311 )
312 gateway.server = HTTPServer(gateway)
313 return gateway
314
315
316 class TestActivityPubGateway:
317
318 @ed
319 async def test_jid_and_node_convert_to_ap_handle(self, ap_gateway):
320 """JID and pubsub node are converted correctly to an AP actor handle"""
321 get_account = ap_gateway.getAPAccountFromJidAndNode
322
323 # local jid
324 assert await get_account(
325 jid_ = jid.JID("simple@test.example"),
326 node = None
327 ) == "simple@test.example"
328
329 # non local jid
330 assert await get_account(
331 jid_ = jid.JID("simple@example.org"),
332 node = None
333 ) == "___simple.40example.2eorg@ap.test.example"
334
335 # local jid with non microblog node
336 assert await get_account(
337 jid_ = jid.JID("simple@test.example"),
338 node = "some_other_node"
339 ) == "some_other_node---simple@test.example"
340
341 # local pubsub node
342 with patch.object(ap_gateway, "isPubsub") as isPubsub:
343 isPubsub.return_value = True
344 assert await get_account(
345 jid_ = jid.JID("pubsub.test.example"),
346 node = "some_node"
347 ) == "some_node@pubsub.test.example"
348
349 # non local pubsub node
350 with patch.object(ap_gateway, "isPubsub") as isPubsub:
351 isPubsub.return_value = True
352 assert await get_account(
353 jid_ = jid.JID("pubsub.example.org"),
354 node = "some_node"
355 ) == "___some_node.40pubsub.2eexample.2eorg@ap.test.example"
356
357 @ed
358 async def test_ap_handle_convert_to_jid_and_node(self, ap_gateway, monkeypatch):
359 """AP actor handle convert correctly to JID and pubsub node"""
360 get_jid_node = ap_gateway.getJIDAndNode
361
362 # for following assertion, host is not a pubsub service
363 with patch.object(ap_gateway, "isPubsub") as isPubsub:
364 isPubsub.return_value = False
365
366 # simple local jid
367 assert await get_jid_node(
368 "toto@test.example"
369 ) == (jid.JID("toto@test.example"), None)
370
371 # simple external jid
372
373 ## with "local_only" set, it should raise an exception
374 with pytest.raises(exceptions.PermissionError):
375 await get_jid_node("toto@example.org")
376
377 ## with "local_only" unset, it should work
378 with monkeypatch.context() as m:
379 m.setattr(ap_gateway, "local_only", False, raising=True)
380 assert await get_jid_node(
381 "toto@example.org"
382 ) == (jid.JID("toto@example.org"), None)
383
384 # explicit node
385 assert await get_jid_node(
386 "tata---toto@test.example"
387 ) == (jid.JID("toto@test.example"), "tata")
388
389 # for following assertion, host is a pubsub service
390 with patch.object(ap_gateway, "isPubsub") as isPubsub:
391 isPubsub.return_value = True
392
393 # simple local node
394 assert await get_jid_node(
395 "toto@pubsub.test.example"
396 ) == (jid.JID("pubsub.test.example"), "toto")
397
398 # encoded local node
399 assert await get_jid_node(
400 "___urn.3axmpp.3amicroblog.3a0@pubsub.test.example"
401 ) == (jid.JID("pubsub.test.example"), "urn:xmpp:microblog:0")
402
403 @ed
404 async def test_ap_to_pubsub_conversion(self, ap_gateway, monkeypatch):
405 """AP requests are converted to pubsub"""
406 monkeypatch.setattr(plugin_comp_ap_gateway.treq, "get", mock_ap_get)
407 monkeypatch.setattr(plugin_comp_ap_gateway.treq, "json_content", mock_treq_json)
408 monkeypatch.setattr(ap_gateway, "apGet", mock_ap_get)
409
410 items, rsm_resp = await ap_gateway.getAPItems(TEST_AP_ACCOUNT, 2)
411
412 assert rsm_resp.count == 4
413 assert rsm_resp.index == 0
414 assert rsm_resp.first == "https://example.org/users/test_user/statuses/4"
415 assert rsm_resp.last == "https://example.org/users/test_user/statuses/3"
416
417 assert items[0].entry.title.toXml() == (
418 "<title xmlns='http://www.w3.org/2005/Atom' type='xhtml'>"
419 "<div xmlns='http://www.w3.org/1999/xhtml'><p>test message 4</p></div>"
420 "</title>"
421 )
422 author_uri = str(
423 [e for e in items[0].entry.author.elements() if e.name == "uri"][0]
424 )
425 assert author_uri == "xmpp:test_user\\40example.org@ap.test.example"
426 assert str(items[0].entry.published) == "2021-12-16T17:25:03Z"
427
428 assert items[1].entry.title.toXml() == (
429 "<title xmlns='http://www.w3.org/2005/Atom' type='xhtml'>"
430 "<div xmlns='http://www.w3.org/1999/xhtml'><p>test message 3</p></div>"
431 "</title>"
432 )
433 author_uri = str(
434 [e for e in items[1].entry.author.elements() if e.name == "uri"][0]
435 )
436 assert author_uri == "xmpp:test_user\\40example.org@ap.test.example"
437 assert str(items[1].entry.published) == "2021-12-16T17:26:03Z"
438
439 items, rsm_resp = await ap_gateway.getAPItems(
440 TEST_AP_ACCOUNT,
441 max_items=2,
442 after_id="https://example.org/users/test_user/statuses/3",
443 )
444
445 assert rsm_resp.count == 4
446 assert rsm_resp.index == 2
447 assert rsm_resp.first == "https://example.org/users/test_user/statuses/2"
448 assert rsm_resp.last == "https://example.org/users/test_user/statuses/1"
449
450 assert items[0].entry.title.toXml() == (
451 "<title xmlns='http://www.w3.org/2005/Atom' type='xhtml'>"
452 "<div xmlns='http://www.w3.org/1999/xhtml'><p>test message 2</p></div>"
453 "</title>"
454 )
455 author_uri = str(
456 [e for e in items[0].entry.author.elements() if e.name == "uri"][0]
457 )
458 assert author_uri == "xmpp:test_user\\40example.org@ap.test.example"
459 assert str(items[0].entry.published) == "2021-12-16T17:27:03Z"
460
461 assert items[1].entry.title.toXml() == (
462 "<title xmlns='http://www.w3.org/2005/Atom' type='xhtml'>"
463 "<div xmlns='http://www.w3.org/1999/xhtml'><p>test message 1</p></div>"
464 "</title>"
465 )
466 author_uri = str(
467 [e for e in items[1].entry.author.elements() if e.name == "uri"][0]
468 )
469 assert author_uri == "xmpp:test_user\\40example.org@ap.test.example"
470 assert str(items[1].entry.published) == "2021-12-16T17:28:03Z"
471
472 items, rsm_resp = await ap_gateway.getAPItems(
473 TEST_AP_ACCOUNT,
474 max_items=1,
475 start_index=2
476 )
477
478 assert rsm_resp.count == 4
479 assert rsm_resp.index == 2
480 assert rsm_resp.first == "https://example.org/users/test_user/statuses/2"
481 assert rsm_resp.last == "https://example.org/users/test_user/statuses/2"
482 assert len(items) == 1
483
484 assert items[0].entry.title.toXml() == (
485 "<title xmlns='http://www.w3.org/2005/Atom' type='xhtml'>"
486 "<div xmlns='http://www.w3.org/1999/xhtml'><p>test message 2</p></div>"
487 "</title>"
488 )
489 assert str(items[0].entry.published) == "2021-12-16T17:27:03Z"
490
491 items, rsm_resp = await ap_gateway.getAPItems(
492 TEST_AP_ACCOUNT,
493 max_items=3,
494 chronological_pagination=False
495 )
496 assert rsm_resp.count == 4
497 assert rsm_resp.index == 1
498 assert rsm_resp.first == "https://example.org/users/test_user/statuses/3"
499 assert rsm_resp.last == "https://example.org/users/test_user/statuses/1"
500 assert len(items) == 3
501 assert items[0].entry.title.toXml() == (
502 "<title xmlns='http://www.w3.org/2005/Atom' type='xhtml'>"
503 "<div xmlns='http://www.w3.org/1999/xhtml'><p>test message 3</p></div>"
504 "</title>"
505 )
506 assert items[2].entry.title.toXml() == (
507 "<title xmlns='http://www.w3.org/2005/Atom' type='xhtml'>"
508 "<div xmlns='http://www.w3.org/1999/xhtml'><p>test message 1</p></div>"
509 "</title>"
510 )
511
512 def ap_request_params(self, ap_gateway, type_=None, url=None, query_data=None):
513 """Generate parameters for HTTPAPGServer's AP*Request
514
515 @param type_: one of the AP query type (e.g. "outbox")
516 @param url: URL to query (mutually exclusif with type_)
517 @param query_data: query data as returned by parse.parse_qs
518 @return dict with kw params to use
519 """
520 assert type_ and url is None or url and type_ is None
521 if type_ is not None:
522 path = f"_ap/{type_}/some_user%40test.example"
523 else:
524 url_parsed = parse.urlparse(url)
525 path = url_parsed.path.lstrip("/")
526 type_ = path.split("/")[1]
527 if query_data is None:
528 query_data = parse.parse_qs(url_parsed.query)
529
530 if query_data:
531 uri = f"{path}?{parse.urlencode(query_data, doseq=True)}"
532 else:
533 uri = path
534
535 test_jid = jid.JID("some_user@test.example")
536 request = Request(MagicMock())
537 request.path = path.encode()
538 request.uri = uri.encode()
539 ap_url = parse.urljoin(
540 f"https://{ap_gateway.public_url}",
541 path
542 )
543 kwargs = {
544 "request": request,
545 "account_jid": test_jid,
546 "node": None,
547 "ap_account": test_jid.full(),
548 "ap_url": ap_url,
549 }
550 if type_ == "outbox" and query_data:
551 kwargs["query_data"] = query_data
552 return kwargs
553
554 @ed
555 async def test_pubsub_to_ap_conversion(self, ap_gateway, monkeypatch):
556 """Pubsub nodes are converted to AP collections"""
557 monkeypatch.setattr(ap_gateway._p, "getItems", mock_getItems)
558 outbox = await ap_gateway.server.resource.APOutboxRequest(
559 **self.ap_request_params(ap_gateway, "outbox")
560 )
561 assert outbox["@context"] == "https://www.w3.org/ns/activitystreams"
562 assert outbox["id"] == "https://test.example/_ap/outbox/some_user%40test.example"
563 assert outbox["totalItems"] == len(XMPP_ITEMS)
564 assert outbox["type"] == "OrderedCollection"
565 assert outbox["first"]
566 assert outbox["last"]
567
568 first_page = await ap_gateway.server.resource.APOutboxPageRequest(
569 **self.ap_request_params(ap_gateway, url=outbox["first"])
570 )
571 assert first_page["@context"] == "https://www.w3.org/ns/activitystreams"
572 assert first_page["id"] == "https://test.example/_ap/outbox/some_user%40test.example?page=first"
573 assert first_page["type"] == "OrderedCollectionPage"
574 assert first_page["partOf"] == outbox["id"]
575 assert len(first_page["orderedItems"]) == len(XMPP_ITEMS)
576 first_item = first_page["orderedItems"][0]
577 assert first_item["@context"] == "https://www.w3.org/ns/activitystreams"
578 assert first_item["id"] == "https://test.example/_ap/item/some_user%40test.example/4"
579 assert first_item["actor"] == "https://test.example/_ap/actor/some_user%40test.example"
580 assert first_item["type"] == "Create"
581 first_item_obj = first_item["object"]
582 assert first_item_obj["id"] == first_item["id"]
583 assert first_item_obj["type"] == "Note"
584 assert first_item_obj["published"] == "2022-01-28T16:02:19Z"
585 assert first_item_obj["attributedTo"] == first_item["actor"]
586 assert first_item_obj["content"] == "<div><p>XMPP item 4</p></div>"
587 assert first_item_obj["to"] == "https://www.w3.org/ns/activitystreams#Public"